mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
feat(config): preserve exact endpoint model declarations through runtime
Persist user-declared model labels, limits, capabilities and USD rate metadata through the existing catalog/config, preserving literal wire identity and unknown fields. Carry sourced limits and frozen estimates through selection, reload, dispatch and subagents without granting provider capability authority. Validation: - config fixtures: 8 passed, 0 failed - config route tests: 96 passed, 0 failed, 1 ignored - config catalog tests: 25 passed, 0 failed - TUI all test targets: cargo check passed; native execution deferred to coordinator's integrated native build window - public-base resolver probe: expected failure, unknown vs declared context - npm test: FAILED, missing script - npm run check:web: FAILED, missing script - git diff --check: passed No provider calls, availability qualification, GUI, public push or release.
This commit is contained in:
1 parent
565bf625a9
commit
c2d150e51a
27 files changed
+1969
-215
No files matched your search
@@ -44,6 +44,8 @@ use serde_json::Value;
|
||||
use crate::models_dev::{ModelsDevCatalog, ModelsDevCost, ModelsDevLimit, ModelsDevModalities};
|
||||
use crate::route::{ModelId, ProviderId, ProviderModelOffering, RouteLimits, WireModelId};
|
||||
|
||||
pub mod configured;
|
||||
|
||||
/// Provenance of a catalog row. Drives layer precedence and UI provenance.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Persisted, operator-declared input to the existing catalog. These records
|
||||
//! describe one exact route; they never create credentials or model aliases.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use super::{CatalogOffering, CatalogSource, base_url_fingerprint};
|
||||
use crate::models_dev::{ModelsDevCost, ModelsDevLimit, ModelsDevModalities};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ConfiguredModel {
|
||||
pub provider: String,
|
||||
pub base_url: String,
|
||||
/// Exact, case-sensitive wire identity. The label is never sent instead.
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(deserialize_with = "deserialize_limit")]
|
||||
pub limit: Option<ModelsDevLimit>,
|
||||
/// USD per million tokens, using the same shape as the catalog.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(deserialize_with = "deserialize_cost")]
|
||||
pub cost: Option<ModelsDevCost>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(deserialize_with = "deserialize_modalities")]
|
||||
pub modalities: Option<ModelsDevModalities>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub attachment: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub structured_output: Option<bool>,
|
||||
/// Keep future metadata intact through typed config saves.
|
||||
#[serde(flatten)]
|
||||
pub extras: BTreeMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
impl ConfiguredModel {
|
||||
pub fn matches_route(&self, provider: &str, base_url: &str) -> bool {
|
||||
!base_url.contains(['@', '?', '#'])
|
||||
&& self.provider == provider
|
||||
&& base_url_fingerprint(&self.base_url) == base_url_fingerprint(base_url)
|
||||
}
|
||||
|
||||
/// Unknown fields stay unknown; no sibling, alias, or provider fact is
|
||||
/// inherited. Source is assigned here and cannot be supplied by the file.
|
||||
pub fn to_catalog_offering(&self) -> CatalogOffering {
|
||||
CatalogOffering {
|
||||
provider: self.provider.clone(),
|
||||
wire_model_id: self.id.clone(),
|
||||
endpoint_key: "chat".into(),
|
||||
limit: self.limit.clone(),
|
||||
cost: self.cost.clone(),
|
||||
modalities: self.modalities.clone(),
|
||||
attachment: self.attachment,
|
||||
reasoning: self.reasoning,
|
||||
tool_call: self.tool_call,
|
||||
structured_output: self.structured_output,
|
||||
source: CatalogSource::ConfigOverride,
|
||||
..CatalogOffering::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_configured_models(models: &[ConfiguredModel]) -> anyhow::Result<()> {
|
||||
let mut identities = BTreeSet::new();
|
||||
for (index, model) in models.iter().enumerate() {
|
||||
// Error messages name fields, never echo untrusted values or URLs.
|
||||
let invalid = |field| anyhow::anyhow!("custom_models[{index}].{field} is invalid");
|
||||
for (field, value) in [("provider", &model.provider), ("id", &model.id)] {
|
||||
if value.is_empty()
|
||||
|| value.len() > 256
|
||||
|| value.chars().any(char::is_whitespace)
|
||||
|| value.chars().any(char::is_control)
|
||||
{
|
||||
return Err(invalid(field));
|
||||
}
|
||||
}
|
||||
if model.id.eq_ignore_ascii_case("auto") {
|
||||
return Err(invalid("id"));
|
||||
}
|
||||
let Some((scheme, rest)) = model.base_url.split_once("://") else {
|
||||
return Err(invalid("base_url"));
|
||||
};
|
||||
if !matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https")
|
||||
|| rest.split('/').next().is_none_or(str::is_empty)
|
||||
|| model.base_url.contains(['@', '?', '#'])
|
||||
|| model
|
||||
.base_url
|
||||
.chars()
|
||||
.any(|c| c.is_whitespace() || c.is_control())
|
||||
{
|
||||
return Err(invalid("base_url"));
|
||||
}
|
||||
if model
|
||||
.display_name
|
||||
.as_ref()
|
||||
.is_some_and(|name| name.trim().is_empty() || name.chars().any(char::is_control))
|
||||
{
|
||||
return Err(invalid("display_name"));
|
||||
}
|
||||
if let Some(limit) = &model.limit {
|
||||
if [limit.context, limit.input, limit.output]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|value| value == 0 || value > u64::from(u32::MAX))
|
||||
|| limit.context.is_some_and(|context| {
|
||||
limit.input.is_some_and(|input| input > context)
|
||||
|| limit.output.is_some_and(|output| output > context)
|
||||
})
|
||||
{
|
||||
return Err(invalid("limit"));
|
||||
}
|
||||
}
|
||||
if model
|
||||
.cost
|
||||
.as_ref()
|
||||
.is_some_and(|cost| !crate::pricing::catalog_cost_is_valid(cost))
|
||||
{
|
||||
return Err(invalid("cost"));
|
||||
}
|
||||
if model.extras.keys().any(|key| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"source"
|
||||
| "canonical_model"
|
||||
| "aliases"
|
||||
| "api_key"
|
||||
| "auth"
|
||||
| "headers"
|
||||
| "endpoint_key"
|
||||
| "default_for_provider"
|
||||
)
|
||||
}) {
|
||||
return Err(invalid("metadata authority"));
|
||||
}
|
||||
if !identities.insert((
|
||||
model.provider.clone(),
|
||||
base_url_fingerprint(&model.base_url),
|
||||
model.id.clone(),
|
||||
)) {
|
||||
return Err(invalid("duplicate route"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn deserialize_configured_models<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Vec<ConfiguredModel>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let models = Option::<Vec<ConfiguredModel>>::deserialize(deserializer)?;
|
||||
validate_configured_models(models.as_deref().unwrap_or_default())
|
||||
.map_err(serde::de::Error::custom)?;
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
// Nested units and limits have precise meanings. Reject unrecognized keys
|
||||
// instead of silently dropping a currency, tier, or other pricing condition.
|
||||
fn deserialize_known<'de, D: Deserializer<'de>, T: serde::de::DeserializeOwned>(
|
||||
d: D,
|
||||
fields: &[&str],
|
||||
) -> Result<Option<T>, D::Error> {
|
||||
let value = Option::<toml::Value>::deserialize(d)?;
|
||||
value
|
||||
.map(|value| {
|
||||
if value
|
||||
.as_table()
|
||||
.is_none_or(|table| table.keys().any(|key| !fields.contains(&key.as_str())))
|
||||
{
|
||||
return Err(serde::de::Error::custom(
|
||||
"unsupported nested custom model metadata field",
|
||||
));
|
||||
}
|
||||
value
|
||||
.try_into()
|
||||
.map_err(|_| serde::de::Error::custom("invalid custom model metadata"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
fn deserialize_limit<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ModelsDevLimit>, D::Error> {
|
||||
deserialize_known(d, &["context", "input", "output"])
|
||||
}
|
||||
fn deserialize_cost<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ModelsDevCost>, D::Error> {
|
||||
deserialize_known(d, &["input", "output", "cache_read", "cache_write"])
|
||||
}
|
||||
fn deserialize_modalities<'de, D: Deserializer<'de>>(
|
||||
d: D,
|
||||
) -> Result<Option<ModelsDevModalities>, D::Error> {
|
||||
deserialize_known(d, &["input", "output"])
|
||||
}
|
||||
@@ -894,6 +894,13 @@ pub struct ConfigToml {
|
||||
pub tools: Option<ToolsToml>,
|
||||
#[serde(default, skip_serializing_if = "ProvidersToml::is_empty")]
|
||||
pub providers: ProvidersToml,
|
||||
/// Operator declarations for exact provider/endpoint/model tuples.
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "catalog::configured::deserialize_configured_models"
|
||||
)]
|
||||
pub custom_models: Option<Vec<catalog::configured::ConfiguredModel>>,
|
||||
/// Provider fallback chain (#2574). TUI runtime code may advance through
|
||||
/// these providers after recoverable provider errors; config resolution
|
||||
/// itself still reports the selected primary provider.
|
||||
@@ -5430,6 +5437,9 @@ impl ConfigStore {
|
||||
/// [`persistence::SetupTransaction`] alongside sibling files and keep the
|
||||
/// comment-preserving write atomic with the rest of the transaction.
|
||||
pub fn rendered_body(&self) -> Result<String> {
|
||||
catalog::configured::validate_configured_models(
|
||||
self.config.custom_models.as_deref().unwrap_or_default(),
|
||||
)?;
|
||||
let mut serialized =
|
||||
toml::to_string_pretty(&self.config).context("failed to serialize config")?;
|
||||
if let Some(provider_id) = self.config.named_custom_provider_id() {
|
||||
|
||||
@@ -88,6 +88,8 @@ pub enum LimitField {
|
||||
pub enum OverrideSource {
|
||||
/// Operator-configured context window.
|
||||
UserContextWindow,
|
||||
/// Limits declared by the operator for an exact model and endpoint.
|
||||
UserModelMetadata,
|
||||
/// Catalog limits describe the public API offering, not the account-scoped
|
||||
/// Codex route; the API-only limits are stripped.
|
||||
CodexPublicApiLimitStrip,
|
||||
|
||||
@@ -68,6 +68,7 @@ pub struct RouteRequest {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RouteResolver {
|
||||
offerings: Vec<ProviderModelOffering>,
|
||||
configured_offerings: Vec<(String, ProviderModelOffering)>,
|
||||
}
|
||||
|
||||
/// Offering-owned facts selected within one provider scope before the final
|
||||
@@ -136,7 +137,73 @@ impl RouteResolver {
|
||||
/// changing route-resolution semantics.
|
||||
#[must_use]
|
||||
pub fn from_offerings(offerings: Vec<ProviderModelOffering>) -> Self {
|
||||
Self { offerings }
|
||||
Self {
|
||||
offerings,
|
||||
configured_offerings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add validated operator declarations for this exact route. This does
|
||||
/// not grant provider-catalog authority or establish model availability.
|
||||
pub fn with_configured_models(
|
||||
mut self,
|
||||
models: &[crate::catalog::configured::ConfiguredModel],
|
||||
identity: &str,
|
||||
provider: ProviderKind,
|
||||
base_url: &str,
|
||||
) -> Self {
|
||||
if crate::catalog::configured::validate_configured_models(models).is_err() {
|
||||
return self;
|
||||
}
|
||||
for model in models
|
||||
.iter()
|
||||
.filter(|model| model.matches_route(identity, base_url))
|
||||
{
|
||||
let mut offering = model.to_catalog_offering().to_offering();
|
||||
offering.provider = ProviderId::from(provider.as_str());
|
||||
// Preserve an adapter's established protocol for an existing ID.
|
||||
// The label/config declaration cannot choose a new wire dialect.
|
||||
if let Some(existing) = self.offerings.iter().find(|row| {
|
||||
row.provider == offering.provider && row.wire_model_id == offering.wire_model_id
|
||||
}) {
|
||||
offering.endpoint_key.clone_from(&existing.endpoint_key);
|
||||
offering.default_for_provider = existing.default_for_provider;
|
||||
} else if ProviderDescriptor::for_kind(provider).wire_policy() == WirePolicy::ModelAware
|
||||
&& provider != ProviderKind::Deepseek
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Positive declarations are not verified executable capabilities.
|
||||
// Explicit negatives can still restrict a route conservatively.
|
||||
let declared = offering.capabilities;
|
||||
offering.capabilities = RouteCapabilities {
|
||||
attachments: if model.attachment == Some(false) {
|
||||
declared.attachments
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
image_input: if declared.image_input == super::CapabilityState::Unsupported {
|
||||
declared.image_input
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
reasoning: declared.reasoning,
|
||||
native_tool_calls: if model.tool_call == Some(false) {
|
||||
declared.native_tool_calls
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
structured_output: if model.structured_output == Some(false) {
|
||||
declared.structured_output
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
..RouteCapabilities::default()
|
||||
};
|
||||
self.configured_offerings
|
||||
.push((crate::catalog::base_url_fingerprint(base_url), offering));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Resolve a request into an executable route candidate.
|
||||
@@ -167,6 +234,45 @@ impl RouteResolver {
|
||||
&self,
|
||||
req: &RouteRequest,
|
||||
endpoint_catalog_authoritative: bool,
|
||||
) -> Result<ReadyRouteCandidate, RouteError> {
|
||||
if self.configured_offerings.is_empty() {
|
||||
return self.resolve_scoped(req, endpoint_catalog_authoritative);
|
||||
}
|
||||
let mut scoped = self.clone();
|
||||
scoped.configured_offerings.clear();
|
||||
let provider = req.explicit_provider.unwrap_or_default();
|
||||
let base_url = req
|
||||
.base_url_override
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| ProviderDescriptor::for_kind(provider).default_base_url());
|
||||
let fingerprint = crate::catalog::base_url_fingerprint(base_url);
|
||||
let selected_id = req
|
||||
.model_selector
|
||||
.as_ref()
|
||||
.map(LogicalModelRef::raw)
|
||||
.or_else(|| req.saved_provider_model.as_ref().map(WireModelId::as_str));
|
||||
for (endpoint, offering) in &self.configured_offerings {
|
||||
if !base_url.contains(['@', '?', '#'])
|
||||
&& *endpoint == fingerprint
|
||||
&& offering.provider.as_str() == provider.as_str()
|
||||
&& selected_id == Some(offering.wire_model_id.as_str())
|
||||
{
|
||||
scoped.offerings.retain(|row| {
|
||||
row.provider != offering.provider || row.wire_model_id != offering.wire_model_id
|
||||
});
|
||||
scoped.offerings.push(offering.clone());
|
||||
scoped
|
||||
.configured_offerings
|
||||
.push((endpoint.clone(), offering.clone()));
|
||||
}
|
||||
}
|
||||
scoped.resolve_scoped(req, endpoint_catalog_authoritative)
|
||||
}
|
||||
|
||||
fn resolve_scoped(
|
||||
&self,
|
||||
req: &RouteRequest,
|
||||
endpoint_catalog_authoritative: bool,
|
||||
) -> Result<ReadyRouteCandidate, RouteError> {
|
||||
// 1. Provider scope from explicit choice only; default otherwise.
|
||||
// The provider is NEVER inferred from a model prefix.
|
||||
@@ -271,6 +377,43 @@ impl RouteResolver {
|
||||
selected.capabilities = RouteCapabilities::default();
|
||||
selected.pricing = PricingSku::UnknownOrStale;
|
||||
}
|
||||
let base_url = req
|
||||
.base_url_override
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| descriptor.default_base_url());
|
||||
let mut declared_limit_overrides = Vec::new();
|
||||
if let Some((_, offering)) =
|
||||
self.configured_offerings
|
||||
.iter()
|
||||
.find(|(fingerprint, offering)| {
|
||||
!base_url.contains(['@', '?', '#'])
|
||||
&& offering.provider == provider_id
|
||||
&& offering.wire_model_id == selected.wire_model_id
|
||||
&& req
|
||||
.model_selector
|
||||
.as_ref()
|
||||
.map(LogicalModelRef::raw)
|
||||
.or_else(|| req.saved_provider_model.as_ref().map(WireModelId::as_str))
|
||||
== Some(offering.wire_model_id.as_str())
|
||||
&& *fingerprint == crate::catalog::base_url_fingerprint(base_url)
|
||||
})
|
||||
{
|
||||
selected.canonical_model = None;
|
||||
selected.limits = offering.limits;
|
||||
selected.capabilities = offering.capabilities;
|
||||
selected.pricing = offering.pricing.clone();
|
||||
for (field, value) in [
|
||||
(LimitField::ContextTokens, offering.limits.context_tokens),
|
||||
(LimitField::InputTokens, offering.limits.input_tokens),
|
||||
(LimitField::OutputTokens, offering.limits.output_tokens),
|
||||
] {
|
||||
declared_limit_overrides.push(SourcedLimitOverride {
|
||||
field,
|
||||
value,
|
||||
source: super::OverrideSource::UserModelMetadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
if provider_kind == ProviderKind::Zai {
|
||||
let effective_base_url = req
|
||||
.base_url_override
|
||||
@@ -351,7 +494,10 @@ impl RouteResolver {
|
||||
// no offering was matched or the offering carried no price.
|
||||
Some(selected.pricing),
|
||||
validation,
|
||||
req.limit_overrides.clone(),
|
||||
declared_limit_overrides
|
||||
.into_iter()
|
||||
.chain(req.limit_overrides.iter().cloned())
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -382,6 +528,12 @@ impl RouteResolver {
|
||||
.strip_prefix("opencode/")
|
||||
.or_else(|| logical_model.raw().strip_prefix("opencode-zen/"))
|
||||
.unwrap_or_else(|| logical_model.raw())
|
||||
} else if self.configured_offerings.iter().any(|(_, offering)| {
|
||||
offering.provider == *provider_id
|
||||
&& offering.wire_model_id.as_str() == logical_model.raw()
|
||||
}) {
|
||||
// An explicitly declared wire ID is not a convenience selector.
|
||||
logical_model.raw()
|
||||
} else if provider_kind == ProviderKind::Concentrate {
|
||||
// Concentrate's own namespace is not part of its wire ids.
|
||||
// `concentrate/auto` is the explicit spelling for the gateway's
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
use codewhale_config::catalog::configured::{ConfiguredModel, validate_configured_models};
|
||||
use codewhale_config::route::{
|
||||
CapabilityState, LogicalModelRef, OverrideSource, RouteRequest, RouteResolver,
|
||||
};
|
||||
use codewhale_config::{ConfigStore, ConfigToml, ProviderKind};
|
||||
|
||||
const FIXTURE: &str = include_str!("fixtures/custom_models.toml");
|
||||
const ID: &str = "deepseek-v4.1-flash-expires-on-0910";
|
||||
const BASE: &str = "https://models.example.test/v1";
|
||||
|
||||
fn models() -> Vec<ConfiguredModel> {
|
||||
toml::from_str::<ConfigToml>(FIXTURE)
|
||||
.unwrap()
|
||||
.custom_models
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn request(base: &str, id: &str) -> RouteRequest {
|
||||
RouteRequest {
|
||||
explicit_provider: Some(ProviderKind::Deepseek),
|
||||
model_selector: Some(LogicalModelRef::from(id)),
|
||||
base_url_override: Some(base.into()),
|
||||
..RouteRequest::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_model_roundtrip_reload_drives_immutable_route() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(&path, FIXTURE).unwrap();
|
||||
let mut store = ConfigStore::load(Some(path.clone())).unwrap();
|
||||
let original = store.config.custom_models.clone();
|
||||
store.config.telemetry = Some(true);
|
||||
store.save().unwrap();
|
||||
let mut reloaded = ConfigStore::load(Some(path)).unwrap();
|
||||
assert_eq!(reloaded.config.custom_models, original);
|
||||
let definitions = reloaded.config.custom_models.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
definitions[0].display_name.as_deref(),
|
||||
Some("Temporary preview")
|
||||
);
|
||||
assert_eq!(
|
||||
definitions[0].extras["future_note"].as_str(),
|
||||
Some("preserve this metadata")
|
||||
);
|
||||
let resolver = RouteResolver::new().with_configured_models(
|
||||
definitions,
|
||||
"deepseek",
|
||||
ProviderKind::Deepseek,
|
||||
BASE,
|
||||
);
|
||||
let old = resolver.resolve(&request(BASE, ID)).unwrap();
|
||||
assert_eq!(old.wire_model_id().as_str(), ID);
|
||||
assert_eq!(old.limits().context_tokens, Some(96000));
|
||||
assert_eq!(old.limits().input_tokens, Some(88000));
|
||||
assert_eq!(old.limits().output_tokens, Some(8000));
|
||||
assert_eq!(old.capabilities().image_input, CapabilityState::Unknown);
|
||||
assert_eq!(
|
||||
old.capabilities().native_tool_calls,
|
||||
CapabilityState::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
old.capabilities().structured_output,
|
||||
CapabilityState::Unsupported
|
||||
);
|
||||
assert!(
|
||||
old.applied_limit_overrides()
|
||||
.iter()
|
||||
.all(|entry| entry.source == OverrideSource::UserModelMetadata)
|
||||
);
|
||||
reloaded.config.custom_models.as_mut().unwrap()[0]
|
||||
.limit
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.context = Some(128000);
|
||||
reloaded.save().unwrap();
|
||||
reloaded.reload().unwrap();
|
||||
let next = RouteResolver::new()
|
||||
.with_configured_models(
|
||||
reloaded.config.custom_models.as_ref().unwrap(),
|
||||
"deepseek",
|
||||
ProviderKind::Deepseek,
|
||||
BASE,
|
||||
)
|
||||
.resolve(&request(BASE, ID))
|
||||
.unwrap();
|
||||
assert_eq!(next.limits().context_tokens, Some(128000));
|
||||
assert_eq!(old.limits().context_tokens, Some(96000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_identity_and_endpoint_do_not_leak_declarations() {
|
||||
let resolver = RouteResolver::new().with_configured_models(
|
||||
&models(),
|
||||
"deepseek",
|
||||
ProviderKind::Deepseek,
|
||||
BASE,
|
||||
);
|
||||
for base in [
|
||||
"https://other.example.test/v1",
|
||||
"http://models.example.test/v1",
|
||||
"https://models.example.test:444/v1",
|
||||
"https://models.example.test/V1",
|
||||
] {
|
||||
let route = resolver.resolve(&request(base, ID)).unwrap();
|
||||
assert_eq!(route.limits().context_tokens, None, "{base}");
|
||||
}
|
||||
for id in ["deepseek-v4.1-flash", "DEEPSEEK-V4.1-FLASH-EXPIRES-ON-0910"] {
|
||||
assert_eq!(
|
||||
resolver
|
||||
.resolve(&request(BASE, id))
|
||||
.unwrap()
|
||||
.limits()
|
||||
.context_tokens,
|
||||
None,
|
||||
"{id}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
resolver
|
||||
.resolve(&request("https://MODELS.example.test:443/v1/", ID))
|
||||
.unwrap()
|
||||
.limits()
|
||||
.context_tokens,
|
||||
Some(96000)
|
||||
);
|
||||
let wrong_identity = RouteResolver::new().with_configured_models(
|
||||
&models(),
|
||||
"another-provider",
|
||||
ProviderKind::Deepseek,
|
||||
BASE,
|
||||
);
|
||||
assert_eq!(
|
||||
wrong_identity
|
||||
.resolve(&request(BASE, ID))
|
||||
.unwrap()
|
||||
.limits()
|
||||
.context_tokens,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_are_not_filled_from_a_known_sibling() {
|
||||
let mut definitions = models();
|
||||
definitions[0].limit = None;
|
||||
definitions[0].cost = None;
|
||||
definitions[0].reasoning = None;
|
||||
definitions[0].modalities = None;
|
||||
definitions[0].tool_call = None;
|
||||
let offering = definitions[0].to_catalog_offering();
|
||||
assert!(offering.limit.is_none());
|
||||
assert!(offering.cost.is_none());
|
||||
assert!(offering.reasoning.is_none());
|
||||
let route = RouteResolver::new()
|
||||
.with_configured_models(&definitions, "deepseek", ProviderKind::Deepseek, BASE)
|
||||
.resolve(&request(BASE, ID))
|
||||
.unwrap();
|
||||
assert_eq!(route.limits().context_tokens, None);
|
||||
assert_eq!(route.capabilities().reasoning, CapabilityState::Unknown);
|
||||
assert_eq!(
|
||||
route.capabilities().native_tool_calls,
|
||||
CapabilityState::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declarations_cannot_expand_closed_protocol_rosters() {
|
||||
let mut definitions = models();
|
||||
definitions[0].provider = "opencode-zen".into();
|
||||
definitions[0].id = "unknown-protocol-model".into();
|
||||
let resolver = RouteResolver::new().with_configured_models(
|
||||
&definitions,
|
||||
"opencode-zen",
|
||||
ProviderKind::OpencodeZen,
|
||||
BASE,
|
||||
);
|
||||
let mut req = request(BASE, "unknown-protocol-model");
|
||||
req.explicit_provider = Some(ProviderKind::OpencodeZen);
|
||||
assert!(resolver.resolve(&req).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_limits_prices_units_and_authority_fail_closed() {
|
||||
for (before, after) in [
|
||||
("context = 96000", "context = 0"),
|
||||
("context = 96000", "context = 4294967296"),
|
||||
("output = 8000", "output = 97000"),
|
||||
("input = 0.4", "input = -1.0"),
|
||||
("input = 0.4", "input = nan"),
|
||||
("input = 0.4", "input = inf"),
|
||||
("input = 0.4", "input = 0.4, currency = 'CNY'"),
|
||||
("input = 0.4", "input = 0.4, unit = 'per_token'"),
|
||||
("future_note =", "source ="),
|
||||
("future_note =", "canonical_model ="),
|
||||
("future_note =", "api_key ="),
|
||||
(ID, "auto"),
|
||||
(BASE, "https://user:[email protected]/v1"),
|
||||
(BASE, "https://models.example.test/v1?key=secret"),
|
||||
] {
|
||||
assert!(
|
||||
toml::from_str::<ConfigToml>(&FIXTURE.replace(before, after)).is_err(),
|
||||
"{before} -> {after}"
|
||||
);
|
||||
}
|
||||
let mut duplicate = models();
|
||||
let mut other = duplicate[0].clone();
|
||||
other.base_url = "https://MODELS.example.test:443/v1/".into();
|
||||
duplicate.push(other);
|
||||
assert!(validate_configured_models(&duplicate).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_config_cannot_replace_user_model_declarations() {
|
||||
let mut user: ConfigToml = toml::from_str(FIXTURE).unwrap();
|
||||
let project: ConfigToml =
|
||||
toml::from_str(&FIXTURE.replace("context = 96000", "context = 256000")).unwrap();
|
||||
user.merge_project_overrides(project);
|
||||
assert_eq!(
|
||||
user.custom_models.unwrap()[0]
|
||||
.limit
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.context,
|
||||
Some(96000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_wire_ids_are_not_convenience_aliases() {
|
||||
for (kind, provider, base, id) in [
|
||||
(
|
||||
ProviderKind::Together,
|
||||
"together",
|
||||
"https://api.together.xyz/v1",
|
||||
"inkling",
|
||||
),
|
||||
(
|
||||
ProviderKind::Openrouter,
|
||||
"openrouter",
|
||||
"https://openrouter.ai/api/v1",
|
||||
"qwen3.7-plus",
|
||||
),
|
||||
(
|
||||
ProviderKind::Concentrate,
|
||||
"concentrate",
|
||||
"https://api.concentrate.ai/v1",
|
||||
"concentrate/example",
|
||||
),
|
||||
(
|
||||
ProviderKind::Deepseek,
|
||||
"deepseek",
|
||||
"https://api.deepseek.com",
|
||||
"deepseek-v4pro",
|
||||
),
|
||||
] {
|
||||
let mut definitions = models();
|
||||
definitions[0].provider = provider.into();
|
||||
definitions[0].base_url = base.into();
|
||||
definitions[0].id = id.into();
|
||||
let resolver =
|
||||
RouteResolver::new().with_configured_models(&definitions, provider, kind, base);
|
||||
let mut req = request(base, id);
|
||||
req.explicit_provider = Some(kind);
|
||||
let candidate = resolver.resolve(&req).unwrap();
|
||||
assert_eq!(candidate.wire_model_id().as_str(), id);
|
||||
assert_eq!(candidate.limits().output_tokens, Some(8000));
|
||||
req.base_url_override = Some("https://elsewhere.example.test/v1".into());
|
||||
assert_eq!(resolver.resolve(&req).unwrap().limits().output_tokens, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_metadata_survives_unrelated_save() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(&path, FIXTURE).unwrap();
|
||||
let mut store = ConfigStore::load(Some(path.clone())).unwrap();
|
||||
store.config.telemetry = Some(true);
|
||||
store.save().unwrap();
|
||||
let persisted: toml::Value = toml::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
persisted
|
||||
.get("custom_models")
|
||||
.and_then(|models| models.as_array())
|
||||
.and_then(|models| models.first())
|
||||
.and_then(|model| model.get("id"))
|
||||
.and_then(|id| id.as_str()),
|
||||
Some(ID)
|
||||
);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
provider = "deepseek"
|
||||
default_text_model = "deepseek-v4.1-flash-expires-on-0910"
|
||||
telemetry = false
|
||||
|
||||
[[custom_models]]
|
||||
provider = "deepseek"
|
||||
base_url = "https://models.example.test/v1"
|
||||
id = "deepseek-v4.1-flash-expires-on-0910"
|
||||
display_name = "Temporary preview"
|
||||
reasoning = false
|
||||
tool_call = true
|
||||
attachment = true
|
||||
structured_output = false
|
||||
future_note = "preserve this metadata"
|
||||
limit = { context = 96000, input = 88000, output = 8000 }
|
||||
cost = { input = 0.4, output = 1.6, cache_read = 0.1 }
|
||||
modalities = { input = ["text", "image"], output = ["text"] }
|
||||
|
||||
[providers.deepseek]
|
||||
base_url = "https://models.example.test/v1"
|
||||
+230
-43
@@ -294,6 +294,7 @@ pub struct DeepSeekClient {
|
||||
openrouter_vendor: Option<String>,
|
||||
billing_surface: Option<String>,
|
||||
billing_mode: crate::cost_status::RouteBillingMode,
|
||||
configured_models: Arc<Vec<codewhale_config::catalog::configured::ConfiguredModel>>,
|
||||
/// Non-secret limits frozen from the same resolved candidate as the
|
||||
/// endpoint and wire model. Auxiliary calls carry only this client, so
|
||||
/// they must not reconstruct output caps with `None` and discard a custom
|
||||
@@ -581,6 +582,7 @@ impl Clone for DeepSeekClient {
|
||||
openrouter_vendor: self.openrouter_vendor.clone(),
|
||||
billing_surface: self.billing_surface.clone(),
|
||||
billing_mode: self.billing_mode,
|
||||
configured_models: Arc::clone(&self.configured_models),
|
||||
route_limits: self.route_limits,
|
||||
codex_account_id: self.codex_account_id.clone(),
|
||||
wire_format: self.wire_format,
|
||||
@@ -1577,6 +1579,7 @@ impl DeepSeekClient {
|
||||
openrouter_vendor,
|
||||
billing_surface,
|
||||
billing_mode,
|
||||
configured_models: Arc::new(config.custom_models.clone().unwrap_or_default()),
|
||||
route_limits,
|
||||
codex_account_id,
|
||||
wire_format,
|
||||
@@ -1677,6 +1680,58 @@ impl DeepSeekClient {
|
||||
redact_model_bound_text(text, &self.model_bound_secret_values)
|
||||
}
|
||||
|
||||
/// Alternate models share this client's frozen endpoint and declarations.
|
||||
/// Resolution still owns protocol admission, including closed rosters.
|
||||
pub(crate) fn resolve_model_route(&self, model: &str) -> Result<ReadyRouteCandidate> {
|
||||
static RESOLVER: OnceLock<RouteResolver> = OnceLock::new();
|
||||
let resolver = RESOLVER.get_or_init(RouteResolver::new);
|
||||
let request = RouteRequest {
|
||||
explicit_provider: self.api_provider.kind(),
|
||||
model_selector: Some(LogicalModelRef::from(model)),
|
||||
saved_provider_model: None,
|
||||
base_url_override: Some(self.base_url.clone()),
|
||||
limit_overrides: Vec::new(),
|
||||
};
|
||||
if self.configured_models.is_empty() || self.api_provider == ApiProvider::OpenaiCodex {
|
||||
resolver.resolve(&request)
|
||||
} else {
|
||||
resolver
|
||||
.clone()
|
||||
.with_configured_models(
|
||||
&self.configured_models,
|
||||
&self.provider_identity,
|
||||
self.api_provider.kind().unwrap_or_default(),
|
||||
&self.base_url,
|
||||
)
|
||||
.resolve(&request)
|
||||
}
|
||||
.map_err(anyhow::Error::msg)
|
||||
}
|
||||
|
||||
fn declared_wire_model<'a>(&self, model: &'a str) -> Option<&'a str> {
|
||||
if self.api_provider == ApiProvider::OpenaiCodex
|
||||
|| !self.configured_models.iter().any(|row| {
|
||||
row.id == model && row.matches_route(&self.provider_identity, &self.base_url)
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// A declaration cannot admit a new model to a closed protocol roster,
|
||||
// or defeat a required protocol alias such as OpenCode Go's allowlist.
|
||||
self.resolve_model_route(model)
|
||||
.ok()
|
||||
.filter(|candidate| candidate.wire_model_id().as_str() == model)
|
||||
.map(|_| model)
|
||||
}
|
||||
|
||||
fn wire_model_for_route(&self, model: &str) -> String {
|
||||
self.declared_wire_model(model)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
wire_model_for_provider_route(self.api_provider, &self.base_url, model)
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve `model` through the central route resolver and rebuild this
|
||||
/// client whenever its exact wire identity, limits, or protocol differs
|
||||
/// from the route bound at construction (#5042). `Ok(None)` means the
|
||||
@@ -1693,17 +1748,7 @@ impl DeepSeekClient {
|
||||
if model == self.default_model {
|
||||
return Ok(None);
|
||||
}
|
||||
static RESOLVER: OnceLock<RouteResolver> = OnceLock::new();
|
||||
let candidate = RESOLVER
|
||||
.get_or_init(RouteResolver::new)
|
||||
.resolve(&RouteRequest {
|
||||
explicit_provider: self.api_provider.kind(),
|
||||
model_selector: Some(LogicalModelRef::from(model)),
|
||||
saved_provider_model: None,
|
||||
base_url_override: Some(self.base_url.clone()),
|
||||
limit_overrides: Vec::new(),
|
||||
})
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let candidate = self.resolve_model_route(model)?;
|
||||
let candidate_limits = crate::route_budget::known_route_limits(candidate.limits());
|
||||
if candidate.protocol() == self.wire_format
|
||||
&& candidate.wire_model_id().as_str() == self.default_model
|
||||
@@ -1731,7 +1776,9 @@ impl DeepSeekClient {
|
||||
self.wire_format
|
||||
)
|
||||
})?;
|
||||
Self::from_candidate(config, &candidate).map(Some)
|
||||
let mut rebound = Self::from_candidate(config, &candidate)?;
|
||||
rebound.configured_models = Arc::clone(&self.configured_models);
|
||||
Ok(Some(rebound))
|
||||
}
|
||||
|
||||
fn bind_request_to_protocol(
|
||||
@@ -1750,18 +1797,11 @@ impl DeepSeekClient {
|
||||
return Ok((request, self.route_limits));
|
||||
}
|
||||
|
||||
static RESOLVER: OnceLock<RouteResolver> = OnceLock::new();
|
||||
let candidate = match RESOLVER
|
||||
.get_or_init(RouteResolver::new)
|
||||
.resolve(&RouteRequest {
|
||||
explicit_provider: self.api_provider.kind(),
|
||||
model_selector: Some(LogicalModelRef::from(request.model.as_str())),
|
||||
saved_provider_model: None,
|
||||
base_url_override: Some(self.base_url.clone()),
|
||||
limit_overrides: Vec::new(),
|
||||
}) {
|
||||
let candidate = match self.resolve_model_route(&request.model) {
|
||||
Ok(candidate) => candidate,
|
||||
Err(error) if model_aware => return Err(anyhow::Error::msg(error)),
|
||||
Err(error) if model_aware || self.api_provider == ApiProvider::OpencodeGo => {
|
||||
return Err(error);
|
||||
}
|
||||
Err(_) => {
|
||||
// A fixed-protocol gateway may legitimately accept an id that
|
||||
// is newer than our offline catalog. Preserve the caller's
|
||||
@@ -2357,6 +2397,7 @@ impl DeepSeekClient {
|
||||
let (request, request_route_limits) =
|
||||
self.bind_request_to_protocol(self.prepare_model_bound_request(request))?;
|
||||
let mut request = clamp_output_cap(request, request_route_limits);
|
||||
let declared_wire_model = self.declared_wire_model(&request.model).map(str::to_string);
|
||||
if self.is_local_ds4_model(&request.model)
|
||||
&& let Some(tools) = request.tools.as_mut()
|
||||
{
|
||||
@@ -2380,6 +2421,10 @@ impl DeepSeekClient {
|
||||
&self.base_url,
|
||||
stream,
|
||||
)?;
|
||||
if let Some(model) = &declared_wire_model {
|
||||
wire.model.clone_from(model);
|
||||
wire.body["model"] = json!(model);
|
||||
}
|
||||
self.apply_provider_routing(&mut wire.body);
|
||||
let url = chat_completions_url(
|
||||
self.chat_transport_base_url(),
|
||||
@@ -2406,7 +2451,10 @@ impl DeepSeekClient {
|
||||
.with_omitted_tool_names(wire.omitted_tool_names))
|
||||
}
|
||||
WireFormat::AnthropicMessages => {
|
||||
let body = self.build_anthropic_body(&request, stream);
|
||||
let mut body = self.build_anthropic_body(&request, stream);
|
||||
if let Some(model) = &declared_wire_model {
|
||||
body["model"] = json!(model);
|
||||
}
|
||||
let url = anthropic::anthropic_messages_url(&self.base_url);
|
||||
let shape = if self.api_provider == ApiProvider::OpencodeZen {
|
||||
RouteShape::OpencodeZen
|
||||
@@ -2431,8 +2479,11 @@ impl DeepSeekClient {
|
||||
))
|
||||
}
|
||||
WireFormat::Responses => {
|
||||
let body =
|
||||
let mut body =
|
||||
responses::build_responses_body_for_provider(&request, self.api_provider);
|
||||
if let Some(model) = &declared_wire_model {
|
||||
body["model"] = json!(model);
|
||||
}
|
||||
let is_codex = self.api_provider == ApiProvider::OpenaiCodex;
|
||||
let url = if is_codex {
|
||||
format!("{}{}", self.base_url, responses::CODEX_RESPONSES_PATH)
|
||||
@@ -2507,16 +2558,7 @@ impl DeepSeekClient {
|
||||
let route_limits = if requested_model.trim() == self.default_model {
|
||||
self.route_limits
|
||||
} else {
|
||||
static RESOLVER: OnceLock<RouteResolver> = OnceLock::new();
|
||||
RESOLVER
|
||||
.get_or_init(RouteResolver::new)
|
||||
.resolve(&RouteRequest {
|
||||
explicit_provider: self.api_provider.kind(),
|
||||
model_selector: Some(LogicalModelRef::from(requested_model)),
|
||||
saved_provider_model: None,
|
||||
base_url_override: Some(self.base_url.clone()),
|
||||
limit_overrides: Vec::new(),
|
||||
})
|
||||
self.resolve_model_route(requested_model)
|
||||
.ok()
|
||||
.and_then(|candidate| crate::route_budget::known_route_limits(candidate.limits()))
|
||||
};
|
||||
@@ -2529,8 +2571,7 @@ impl DeepSeekClient {
|
||||
requested_model: &str,
|
||||
route_limits: Option<RouteLimits>,
|
||||
) -> u32 {
|
||||
let wire_model =
|
||||
wire_model_for_provider_route(self.api_provider, &self.base_url, requested_model);
|
||||
let wire_model = self.wire_model_for_route(requested_model);
|
||||
crate::route_budget::effective_max_output_tokens_for_route(
|
||||
self.api_provider,
|
||||
&wire_model,
|
||||
@@ -2571,19 +2612,28 @@ impl DeepSeekClient {
|
||||
requested_model: &str,
|
||||
dispatched_at: chrono::DateTime<chrono::Utc>,
|
||||
) -> crate::cost_status::EffectiveRouteEnvelope {
|
||||
let model =
|
||||
wire_model_for_provider_route(self.api_provider, &self.base_url, requested_model);
|
||||
let model = self.wire_model_for_route(requested_model);
|
||||
let endpoint_fingerprint = crate::cost_status::endpoint_fingerprint(&self.base_url);
|
||||
let provider_live_pricing = u64::try_from(dispatched_at.timestamp())
|
||||
.ok()
|
||||
.and_then(|at| {
|
||||
crate::provider_catalog_live::fresh_dispatch_pricing_quote_at(
|
||||
crate::provider_catalog_live::configured_dispatch_pricing_quote_at(
|
||||
&self.configured_models,
|
||||
self.api_provider,
|
||||
&self.provider_identity,
|
||||
&model,
|
||||
&self.base_url,
|
||||
at,
|
||||
)
|
||||
.or_else(|| {
|
||||
crate::provider_catalog_live::fresh_dispatch_pricing_quote_at(
|
||||
self.api_provider,
|
||||
&self.provider_identity,
|
||||
&model,
|
||||
&self.base_url,
|
||||
at,
|
||||
)
|
||||
})
|
||||
});
|
||||
crate::cost_status::EffectiveRouteEnvelope {
|
||||
openrouter_vendor: self.openrouter_vendor.clone(),
|
||||
@@ -2693,7 +2743,7 @@ impl DeepSeekClient {
|
||||
let route = self.effective_route_envelope(model, chrono::Utc::now());
|
||||
let _inference = self.acquire_remote_control_inference_permit().await;
|
||||
let _permit = self.acquire_provider_request_permit().await;
|
||||
let model = wire_model_for_provider_route(self.api_provider, &self.base_url, model);
|
||||
let model = self.wire_model_for_route(model);
|
||||
let max_tokens = self.effective_max_output_tokens(&model);
|
||||
if self.wire_format != WireFormat::ChatCompletions {
|
||||
// Non-Chat dialects reuse the prepared-request seam so translation
|
||||
@@ -3213,7 +3263,7 @@ impl DeepSeekClient {
|
||||
}
|
||||
|
||||
let audio_format = normalize_audio_format(&request.audio_format);
|
||||
let model = wire_model_for_provider_route(self.api_provider, &self.base_url, &model);
|
||||
let model = self.wire_model_for_route(&model);
|
||||
let model_lower = model.to_ascii_lowercase();
|
||||
let instruction = request
|
||||
.instruction
|
||||
@@ -4917,7 +4967,7 @@ impl DeepSeekClient {
|
||||
);
|
||||
}
|
||||
let url = api_url_with_suffix(&self.base_url, "beta/completions", None);
|
||||
let model = wire_model_for_provider_route(self.api_provider, &self.base_url, model);
|
||||
let model = self.wire_model_for_route(model);
|
||||
let max_tokens = max_tokens.min(self.effective_max_output_tokens(&model));
|
||||
let body = json!({
|
||||
"model": model,
|
||||
@@ -13612,6 +13662,143 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod configured_model_client_tests {
|
||||
use super::*;
|
||||
use crate::config::{ProviderConfig, ProvidersConfig};
|
||||
|
||||
fn config() -> Config {
|
||||
Config {
|
||||
provider: Some("deepseek".into()),
|
||||
providers: Some(ProvidersConfig {
|
||||
deepseek: ProviderConfig {
|
||||
api_key: Some("configured-model-local-fixture".into()),
|
||||
base_url: Some("https://api.deepseek.com".into()),
|
||||
..ProviderConfig::default()
|
||||
},
|
||||
..ProvidersConfig::default()
|
||||
}),
|
||||
custom_models: Some(vec![
|
||||
toml::from_str(
|
||||
r#"
|
||||
provider = "deepseek"
|
||||
base_url = "https://api.deepseek.com"
|
||||
id = "deepseek-v4pro"
|
||||
limit = { context = 1024, output = 32 }
|
||||
cost = { input = 1.0, output = 2.0 }
|
||||
"#,
|
||||
)
|
||||
.unwrap(),
|
||||
]),
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternate_declared_model_freezes_wire_limits_and_price() {
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let mut config = config();
|
||||
let client = DeepSeekClient::from_parts(
|
||||
"https://api.deepseek.com".into(),
|
||||
"initial-model".into(),
|
||||
WireFormat::ChatCompletions,
|
||||
None,
|
||||
&config,
|
||||
)
|
||||
.unwrap();
|
||||
let model = "deepseek-v4pro";
|
||||
config.providers.as_mut().unwrap().deepseek.model = Some(model.into());
|
||||
crate::config::normalize_model_config_for_test(&mut config);
|
||||
assert_eq!(config.default_model(), model);
|
||||
assert_eq!(
|
||||
config.providers.as_ref().unwrap().deepseek.model.as_deref(),
|
||||
Some(model)
|
||||
);
|
||||
config.custom_models.as_mut().unwrap()[0]
|
||||
.limit
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.output = Some(512);
|
||||
config.custom_models.as_mut().unwrap()[0]
|
||||
.cost
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.output = Some(99.0);
|
||||
|
||||
assert_eq!(client.effective_max_output_tokens(model), 32);
|
||||
let prepared = client
|
||||
.prepare_outbound_request(
|
||||
translation_message_request("hello", model.into(), "English", 4096),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(prepared.wire_model, model);
|
||||
assert_eq!(prepared.body["model"], model);
|
||||
assert_eq!(prepared.body["max_tokens"], 32);
|
||||
let rebound = client
|
||||
.rebound_for_model_protocol(Some(&config), model)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(rebound.default_model, model);
|
||||
assert_eq!(rebound.route_limits.unwrap().output_tokens, Some(32));
|
||||
let envelope = rebound.effective_route_envelope(model, chrono::Utc::now());
|
||||
assert_eq!(envelope.model, model);
|
||||
let quote = serde_json::to_value(envelope.provider_live_pricing.unwrap()).unwrap();
|
||||
assert_eq!(quote["output_per_million"], "2");
|
||||
|
||||
let mut wrong_endpoint = client.clone();
|
||||
wrong_endpoint.base_url = "https://other.example.test/v1".into();
|
||||
assert_eq!(wrong_endpoint.declared_wire_model(model), None);
|
||||
assert_ne!(wrong_endpoint.effective_max_output_tokens(model), 32);
|
||||
let mut wrong_identity = client;
|
||||
wrong_identity.provider_identity = "other-provider".into();
|
||||
assert_eq!(wrong_identity.declared_wire_model(model), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declaration_does_not_open_opencode_go_protocol_roster() {
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let mut config = config();
|
||||
config.provider = Some("opencode-go".into());
|
||||
config.providers.as_mut().unwrap().opencode_go = ProviderConfig {
|
||||
api_key: Some("configured-model-local-fixture".into()),
|
||||
..ProviderConfig::default()
|
||||
};
|
||||
let base_url = ApiProvider::OpencodeGo.default_base_url();
|
||||
let declaration = &mut config.custom_models.as_mut().unwrap()[0];
|
||||
declaration.provider = "opencode-go".into();
|
||||
declaration.base_url = base_url.into();
|
||||
declaration.id = "claude-sonnet-unproven".into();
|
||||
let client = DeepSeekClient::from_parts(
|
||||
base_url.into(),
|
||||
config.default_model(),
|
||||
WireFormat::ChatCompletions,
|
||||
None,
|
||||
&config,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(client.declared_wire_model("claude-sonnet-unproven"), None);
|
||||
assert!(
|
||||
client
|
||||
.rebound_for_model_protocol(None, "claude-sonnet-unproven")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.prepare_outbound_request(
|
||||
translation_message_request(
|
||||
"hello",
|
||||
"claude-sonnet-unproven".into(),
|
||||
"English",
|
||||
64
|
||||
),
|
||||
false,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod openrouter_vendor_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -452,7 +452,8 @@ fn context_window_source_label(
|
||||
tr(
|
||||
locale,
|
||||
match source {
|
||||
crate::route_runtime::ContextWindowSource::Configured => {
|
||||
crate::route_runtime::ContextWindowSource::Configured
|
||||
| crate::route_runtime::ContextWindowSource::UserDeclared => {
|
||||
MessageId::StatusContextSourceConfigured
|
||||
}
|
||||
crate::route_runtime::ContextWindowSource::ProviderReported => {
|
||||
|
||||
@@ -300,7 +300,21 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
|
||||
AppAction::UpdateCompaction(app.compaction_config()),
|
||||
);
|
||||
}
|
||||
let model_id = if app.accepts_custom_model_ids() {
|
||||
let declared = app.api_provider != ApiProvider::OpenaiCodex
|
||||
&& codewhale_config::catalog::configured::validate_configured_models(
|
||||
&app.configured_models,
|
||||
)
|
||||
.is_ok()
|
||||
&& app.configured_models.iter().any(|model| {
|
||||
model.id == name
|
||||
&& model.matches_route(
|
||||
app.provider_identity_for_persistence(),
|
||||
&app.active_route_base_url,
|
||||
)
|
||||
});
|
||||
let model_id = if declared {
|
||||
name.to_string()
|
||||
} else if app.accepts_custom_model_ids() {
|
||||
let Some(model_id) = normalize_custom_model_id(name) else {
|
||||
return CommandResult::error(format!(
|
||||
"Invalid model '{name}'. Expected a non-empty model ID."
|
||||
@@ -321,7 +335,19 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
|
||||
app.api_provider,
|
||||
ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::Zai
|
||||
);
|
||||
let route_resolution = if strict_direct_custom_endpoint {
|
||||
let route_resolution = if declared {
|
||||
match crate::route_runtime::resolve_declared_model_candidate(
|
||||
app.api_provider,
|
||||
app.provider_identity_for_persistence(),
|
||||
&model_id,
|
||||
&app.active_route_base_url,
|
||||
app.active_context_window_override,
|
||||
&app.configured_models,
|
||||
) {
|
||||
Ok(resolution) => Some(resolution),
|
||||
Err(reason) => return CommandResult::error(reason),
|
||||
}
|
||||
} else if strict_direct_custom_endpoint {
|
||||
None
|
||||
} else {
|
||||
// `/model` normally resolves against the active provider's
|
||||
|
||||
@@ -101,7 +101,7 @@ fn cache_write_summary(app: &App, locale: Locale) -> String {
|
||||
/// Show session cost breakdown.
|
||||
///
|
||||
/// The figure is an **estimate** computed from provider-reported usage and
|
||||
/// published rates; it is never an invoice. Turns whose route produced no
|
||||
/// the recorded pricing sources; it is never an invoice. Turns whose route produced no
|
||||
/// authoritative price are missing from it entirely, so the coverage of the
|
||||
/// number is reported alongside it rather than left implicit (#4318).
|
||||
pub fn cost(app: &mut App) -> CommandResult {
|
||||
@@ -116,7 +116,16 @@ pub fn cost(app: &mut App) -> CommandResult {
|
||||
} else {
|
||||
MessageId::CmdCostReport
|
||||
};
|
||||
let mut report = tr(locale, headline).replace("{cost}", &cost_report_amount(app, locale));
|
||||
let mut report = if has_declared_estimates(app) {
|
||||
// Like the diagnostic breakdown below, state the actual accounting
|
||||
// basis without the legacy templates' published-rate assertion.
|
||||
format!(
|
||||
"Session cost estimate (priced subtotal): {}",
|
||||
cost_report_amount(app, locale)
|
||||
)
|
||||
} else {
|
||||
tr(locale, headline).replace("{cost}", &cost_report_amount(app, locale))
|
||||
};
|
||||
if priced > 0 || has_saved_legacy_subtotal {
|
||||
report.push_str(&cost_breakdown_report(app));
|
||||
}
|
||||
@@ -268,6 +277,12 @@ fn joined(values: &std::collections::BTreeSet<String>) -> String {
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn has_declared_estimates(app: &App) -> bool {
|
||||
app.session
|
||||
.cost_pricing_provenances
|
||||
.contains("user_override")
|
||||
}
|
||||
|
||||
/// The honesty block appended to `/cost` and `/tokens`: what the estimate covers
|
||||
/// and what it cannot.
|
||||
///
|
||||
@@ -276,13 +291,23 @@ fn joined(values: &std::collections::BTreeSet<String>) -> String {
|
||||
pub(crate) fn cost_coverage_report(app: &App, locale: Locale) -> String {
|
||||
let (priced, unpriced) = cost_coverage_counts(app);
|
||||
let mut out = String::from("\n\n");
|
||||
out.push_str(&tr(locale, MessageId::CmdCostEstimateOnly));
|
||||
let declared = has_declared_estimates(app);
|
||||
if declared {
|
||||
out.push_str("Includes user-declared, unverified price estimates calculated from recorded usage. These amounts do not establish provider prices, billing mode, or an invoice.");
|
||||
} else {
|
||||
out.push_str(&tr(locale, MessageId::CmdCostEstimateOnly));
|
||||
}
|
||||
out.push('\n');
|
||||
if app.session.cost_coverage_unknown_legacy {
|
||||
// A restored pre-coverage session has real money and no evidence of what
|
||||
// it covers. Saying "0 of 0 priced" here would assert the total is
|
||||
// complete, so the unknown state is stated instead.
|
||||
out.push_str(&tr(locale, MessageId::CmdCostCoverageUnknownLegacy));
|
||||
} else if declared {
|
||||
out.push_str(&format!(
|
||||
"Coverage: {priced} of {} tracked turns priced or estimated.",
|
||||
priced.saturating_add(unpriced)
|
||||
));
|
||||
} else {
|
||||
out.push_str(
|
||||
&tr(locale, MessageId::CmdCostCoverage)
|
||||
@@ -296,8 +321,14 @@ pub(crate) fn cost_coverage_report(app: &App, locale: Locale) -> String {
|
||||
crate::pricing::CostCurrency::Cny => &app.session.cost_cny_unpriced_reasons,
|
||||
};
|
||||
out.push('\n');
|
||||
let excluded = if declared {
|
||||
"Excluded: {unpriced} turns have incomplete prices ({reasons}); their cost is unknown."
|
||||
.to_string()
|
||||
} else {
|
||||
tr(locale, MessageId::CmdCostUnpricedTurns).to_string()
|
||||
};
|
||||
out.push_str(
|
||||
&tr(locale, MessageId::CmdCostUnpricedTurns)
|
||||
&excluded
|
||||
.replace("{unpriced}", &unpriced.to_string())
|
||||
.replace(
|
||||
"{reasons}",
|
||||
@@ -488,6 +519,20 @@ mod cost_breakdown_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_model_cost_copy_does_not_claim_published_rates() {
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let mut app = test_app();
|
||||
app.session
|
||||
.cost_pricing_provenances
|
||||
.insert("user_override".into());
|
||||
app.session.cost_priced_turns = 1;
|
||||
let report = cost_coverage_report(&app, Locale::En);
|
||||
assert!(report.contains("user-declared, unverified price estimates"));
|
||||
assert!(!report.contains("published rates"));
|
||||
assert!(!report.contains("money-metered"));
|
||||
}
|
||||
|
||||
/// The decomposition's terms are exactly the headline's inputs, so their
|
||||
/// sum reproduces the headline — including when the #244 monotonic floor,
|
||||
/// not the live accumulators, is the number on display (#4939).
|
||||
|
||||
+71
-23
@@ -803,11 +803,10 @@ pub fn provider_capability_with_wire(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let is_v4_pro = model_lower.contains("v4-pro") || model_lower == "deepseek-v4pro";
|
||||
let is_v4_flash = model_lower.contains("v4-flash")
|
||||
|| model_lower == "deepseek-v4flash"
|
||||
|| model_lower == "deepseek-v4"
|
||||
|| alias_deprecation.is_some();
|
||||
let exact_deepseek = canonical_official_deepseek_model_id(&model_lower);
|
||||
let is_v4_pro = exact_deepseek == Some("deepseek-v4-pro");
|
||||
let is_v4_flash =
|
||||
exact_deepseek == Some(DEEPSEEK_ALIAS_REPLACEMENT) || alias_deprecation.is_some();
|
||||
let is_reasoner = matches!(provider, ApiProvider::WanjieArk)
|
||||
&& (model_lower.contains("reasoner") || model_lower.contains("r1"));
|
||||
|
||||
@@ -1057,23 +1056,7 @@ pub fn validate_route(provider: ApiProvider, model: &str) -> Result<(), String>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {
|
||||
match model.trim().to_ascii_lowercase().as_str() {
|
||||
"deepseek-v4-pro"
|
||||
| "deepseek-v4pro"
|
||||
| "deepseek-ai/deepseek-v4-pro"
|
||||
| "deepseek-ai/deepseek-v4pro"
|
||||
| "deepseek/deepseek-v4-pro"
|
||||
| "deepseek/deepseek-v4pro" => Some("deepseek-v4-pro"),
|
||||
"deepseek-v4-flash"
|
||||
| "deepseek-v4flash"
|
||||
| "deepseek-ai/deepseek-v4-flash"
|
||||
| "deepseek-ai/deepseek-v4flash"
|
||||
| "deepseek/deepseek-v4-flash"
|
||||
| "deepseek/deepseek-v4flash" => Some("deepseek-v4-flash"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
use crate::models::canonical_official_deepseek_model_id;
|
||||
|
||||
/// Resolve model names accepted by DeepSeek's first-party endpoints.
|
||||
///
|
||||
@@ -2822,6 +2805,12 @@ impl TranscriptConfig {
|
||||
/// Resolved CLI configuration, including defaults and environment overrides.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct Config {
|
||||
/// Persisted exact-route declarations, separate from provider credentials.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "codewhale_config::catalog::configured::deserialize_configured_models"
|
||||
)]
|
||||
pub custom_models: Option<Vec<codewhale_config::catalog::configured::ConfiguredModel>>,
|
||||
/// Single-token inputs that cancel the active turn before dispatch.
|
||||
#[serde(default)]
|
||||
pub stop_words: Option<Vec<String>>,
|
||||
@@ -4858,6 +4847,9 @@ impl Config {
|
||||
|
||||
/// Validate that critical config fields are present.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
codewhale_config::catalog::configured::validate_configured_models(
|
||||
self.custom_models.as_deref().unwrap_or_default(),
|
||||
)?;
|
||||
self.openrouter_vendor()?;
|
||||
if self
|
||||
.provider
|
||||
@@ -5842,6 +5834,7 @@ impl Config {
|
||||
/// controls. Provider tables carry their endpoint, auth, headers, TLS,
|
||||
/// model-passthrough, and per-route limits as one atomic registry.
|
||||
pub(crate) fn refresh_provider_routes_from(&mut self, fresh: &Self) {
|
||||
self.custom_models.clone_from(&fresh.custom_models);
|
||||
self.provider.clone_from(&fresh.provider);
|
||||
self.api_key.clone_from(&fresh.api_key);
|
||||
self.base_url.clone_from(&fresh.base_url);
|
||||
@@ -6002,11 +5995,22 @@ impl Config {
|
||||
#[must_use]
|
||||
pub fn default_model(&self) -> String {
|
||||
let provider = self.api_provider();
|
||||
let declared = |model: &str| {
|
||||
crate::provider_lake::configured_model_for_route(
|
||||
self,
|
||||
provider,
|
||||
&self.provider_identity_for(provider),
|
||||
&self.deepseek_base_url(),
|
||||
model,
|
||||
)
|
||||
.is_some()
|
||||
};
|
||||
if let Some(model) =
|
||||
self.provider_config_string_with_runtime_fallback(provider, |entry| entry.model.clone())
|
||||
{
|
||||
let model = model.trim();
|
||||
if provider_passes_model_through(provider)
|
||||
if declared(model)
|
||||
|| provider_passes_model_through(provider)
|
||||
|| self.active_provider_preserves_custom_base_url_model()
|
||||
{
|
||||
return model.to_string();
|
||||
@@ -6025,6 +6029,13 @@ impl Config {
|
||||
return model.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(model) = self
|
||||
.default_text_model
|
||||
.as_deref()
|
||||
.filter(|model| declared(model))
|
||||
{
|
||||
return model.to_string();
|
||||
}
|
||||
let moonshot_config = (provider == ApiProvider::Moonshot)
|
||||
.then(|| self.provider_config())
|
||||
.flatten();
|
||||
@@ -9623,12 +9634,34 @@ fn apply_env_overrides_unlocked(config: &mut Config, policy: ConfigEnvironmentPo
|
||||
fn normalize_model_config(config: &mut Config) {
|
||||
let provider = config.api_provider();
|
||||
let base_url = config.deepseek_base_url();
|
||||
let mut declared = Vec::new();
|
||||
for provider in ApiProvider::all()
|
||||
.iter()
|
||||
.copied()
|
||||
.chain([ApiProvider::DeepseekCN])
|
||||
{
|
||||
for model in config.custom_models.as_deref().unwrap_or_default() {
|
||||
if crate::provider_lake::configured_model_for_route(
|
||||
config,
|
||||
provider,
|
||||
&config.provider_identity_for(provider),
|
||||
&config.base_url_for_route(provider),
|
||||
&model.id,
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
declared.push((provider, model.id.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let is_declared = |provider, model: &str| declared.contains(&(provider, model.to_string()));
|
||||
config.migrated_deepseek_model_alias = if matches!(
|
||||
provider,
|
||||
ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic
|
||||
) {
|
||||
config
|
||||
.active_configured_model_id()
|
||||
.filter(|model| !is_declared(provider, model))
|
||||
.map(str::to_ascii_lowercase)
|
||||
.filter(|model| deepseek_alias_deprecation(model).is_some())
|
||||
.filter(|model| {
|
||||
@@ -9654,6 +9687,7 @@ fn normalize_model_config(config: &mut Config) {
|
||||
}
|
||||
|
||||
if let Some(model) = config.default_text_model.as_deref()
|
||||
&& !is_declared(provider, model)
|
||||
&& !provider_passes_model_through(config.api_provider())
|
||||
&& !config.active_provider_preserves_custom_base_url_model()
|
||||
&& let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)
|
||||
@@ -9664,12 +9698,14 @@ fn normalize_model_config(config: &mut Config) {
|
||||
if let Some(providers) = config.providers.as_mut() {
|
||||
if let Some(model) = providers.deepseek.model.as_deref()
|
||||
&& !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek)
|
||||
&& !is_declared(ApiProvider::Deepseek, model)
|
||||
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)
|
||||
{
|
||||
providers.deepseek.model = Some(normalized);
|
||||
}
|
||||
if let Some(model) = providers.deepseek_cn.model.as_deref()
|
||||
&& !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn)
|
||||
&& !is_declared(ApiProvider::DeepseekCN, model)
|
||||
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model)
|
||||
{
|
||||
providers.deepseek_cn.model = Some(normalized);
|
||||
@@ -9679,6 +9715,7 @@ fn normalize_model_config(config: &mut Config) {
|
||||
ApiProvider::DeepseekAnthropic,
|
||||
&providers.deepseek_anthropic,
|
||||
)
|
||||
&& !is_declared(ApiProvider::DeepseekAnthropic, model)
|
||||
&& let Some(normalized) =
|
||||
normalize_model_for_provider(ApiProvider::DeepseekAnthropic, model)
|
||||
{
|
||||
@@ -9686,24 +9723,28 @@ fn normalize_model_config(config: &mut Config) {
|
||||
}
|
||||
if let Some(model) = providers.nvidia_nim.model.as_deref()
|
||||
&& !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim)
|
||||
&& !is_declared(ApiProvider::NvidiaNim, model)
|
||||
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)
|
||||
{
|
||||
providers.nvidia_nim.model = Some(normalized);
|
||||
}
|
||||
if let Some(model) = providers.openrouter.model.as_deref()
|
||||
&& !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter)
|
||||
&& !is_declared(ApiProvider::Openrouter, model)
|
||||
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model)
|
||||
{
|
||||
providers.openrouter.model = Some(normalized);
|
||||
}
|
||||
if let Some(model) = providers.novita.model.as_deref()
|
||||
&& !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita)
|
||||
&& !is_declared(ApiProvider::Novita, model)
|
||||
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model)
|
||||
{
|
||||
providers.novita.model = Some(normalized);
|
||||
}
|
||||
if let Some(model) = providers.fireworks.model.as_deref()
|
||||
&& !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks)
|
||||
&& !is_declared(ApiProvider::Fireworks, model)
|
||||
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model)
|
||||
{
|
||||
providers.fireworks.model = Some(normalized);
|
||||
@@ -9713,6 +9754,7 @@ fn normalize_model_config(config: &mut Config) {
|
||||
ApiProvider::Siliconflow,
|
||||
&providers.siliconflow,
|
||||
)
|
||||
&& !is_declared(ApiProvider::Siliconflow, model)
|
||||
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Siliconflow, model)
|
||||
{
|
||||
providers.siliconflow.model = Some(normalized);
|
||||
@@ -9722,6 +9764,7 @@ fn normalize_model_config(config: &mut Config) {
|
||||
ApiProvider::SiliconflowCn,
|
||||
&providers.siliconflow_cn,
|
||||
)
|
||||
&& !is_declared(ApiProvider::SiliconflowCn, model)
|
||||
&& let Some(normalized) =
|
||||
normalize_model_for_provider(ApiProvider::SiliconflowCn, model)
|
||||
{
|
||||
@@ -9729,24 +9772,28 @@ fn normalize_model_config(config: &mut Config) {
|
||||
}
|
||||
if let Some(model) = providers.moonshot.model.as_deref()
|
||||
&& !provider_entry_uses_custom_base_url(ApiProvider::Moonshot, &providers.moonshot)
|
||||
&& !is_declared(ApiProvider::Moonshot, model)
|
||||
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Moonshot, model)
|
||||
{
|
||||
providers.moonshot.model = Some(normalized);
|
||||
}
|
||||
if let Some(model) = providers.sglang.model.as_deref()
|
||||
&& !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang)
|
||||
&& !is_declared(ApiProvider::Sglang, model)
|
||||
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model)
|
||||
{
|
||||
providers.sglang.model = Some(normalized);
|
||||
}
|
||||
if let Some(model) = providers.vllm.model.as_deref()
|
||||
&& !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm)
|
||||
&& !is_declared(ApiProvider::Vllm, model)
|
||||
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model)
|
||||
{
|
||||
providers.vllm.model = Some(normalized);
|
||||
}
|
||||
if let Some(model) = providers.deepinfra.model.as_deref()
|
||||
&& !provider_entry_uses_custom_base_url(ApiProvider::Deepinfra, &providers.deepinfra)
|
||||
&& !is_declared(ApiProvider::Deepinfra, model)
|
||||
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepinfra, model)
|
||||
{
|
||||
providers.deepinfra.model = Some(normalized);
|
||||
@@ -10678,6 +10725,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
|
||||
// Captured before the struct literal moves the field out of `override_cfg`.
|
||||
let override_defines_root_base_url = override_cfg.base_url.is_some();
|
||||
Config {
|
||||
custom_models: override_cfg.custom_models.or(base.custom_models),
|
||||
provider: override_cfg.provider.or(base.provider),
|
||||
telemetry: override_cfg.telemetry.or(base.telemetry),
|
||||
api_key: override_cfg.api_key.or(base.api_key),
|
||||
|
||||
@@ -194,13 +194,26 @@ impl EffectiveRouteEnvelope {
|
||||
u64::try_from(dispatched_at.timestamp())
|
||||
.ok()
|
||||
.and_then(|at| {
|
||||
crate::provider_catalog_live::fresh_dispatch_pricing_quote_at(
|
||||
provider,
|
||||
&provider_identity,
|
||||
&model,
|
||||
base_url,
|
||||
at,
|
||||
)
|
||||
config
|
||||
.and_then(|config| {
|
||||
crate::provider_catalog_live::configured_dispatch_pricing_quote_at(
|
||||
config.custom_models.as_deref().unwrap_or_default(),
|
||||
provider,
|
||||
&provider_identity,
|
||||
&model,
|
||||
base_url,
|
||||
at,
|
||||
)
|
||||
})
|
||||
.or_else(|| {
|
||||
crate::provider_catalog_live::fresh_dispatch_pricing_quote_at(
|
||||
provider,
|
||||
&provider_identity,
|
||||
&model,
|
||||
base_url,
|
||||
at,
|
||||
)
|
||||
})
|
||||
})
|
||||
});
|
||||
Self {
|
||||
@@ -232,11 +245,29 @@ impl EffectiveRouteEnvelope {
|
||||
Some(&self.provider_identity),
|
||||
self.endpoint_fingerprint.as_deref(),
|
||||
);
|
||||
let declared_estimate = self.provider_live_pricing.as_ref().is_some_and(|quote| {
|
||||
quote.provenance == codewhale_config::pricing::PricingProvenance::UserOverride
|
||||
&& self
|
||||
.endpoint_fingerprint
|
||||
.as_deref()
|
||||
.zip(u64::try_from(self.dispatched_at.timestamp()).ok())
|
||||
.is_some_and(|(fingerprint, at)| {
|
||||
quote
|
||||
.pricing_for_route(
|
||||
self.provider,
|
||||
&self.provider_identity,
|
||||
&self.model,
|
||||
fingerprint,
|
||||
at,
|
||||
)
|
||||
.is_some()
|
||||
})
|
||||
});
|
||||
match self.billing_mode {
|
||||
RouteBillingMode::Subscription | RouteBillingMode::Local => {
|
||||
return TurnCostAudit::unpriced(crate::pricing::UnpricedReason::NotMoneyMetered);
|
||||
}
|
||||
RouteBillingMode::Unknown if !reviewed_custom_metered => {
|
||||
RouteBillingMode::Unknown if !reviewed_custom_metered && !declared_estimate => {
|
||||
return TurnCostAudit::unpriced(
|
||||
crate::pricing::UnpricedReason::UnknownBillingBasis,
|
||||
);
|
||||
@@ -2100,6 +2131,119 @@ pub(crate) fn test_scope() -> TestCostScope {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn configured_fixture_receipt() -> (crate::config::Config, EffectiveRouteEnvelope, Usage) {
|
||||
let config = toml::from_str(include_str!(
|
||||
"../../config/tests/fixtures/custom_models.toml"
|
||||
))
|
||||
.unwrap();
|
||||
let receipt = EffectiveRouteEnvelope::capture(
|
||||
Some(&config),
|
||||
ApiProvider::Deepseek,
|
||||
"deepseek",
|
||||
"deepseek-v4.1-flash-expires-on-0910",
|
||||
Some("https://models.example.test/v1"),
|
||||
Utc::now(),
|
||||
);
|
||||
let usage = Usage {
|
||||
input_tokens: 1_000_000,
|
||||
output_tokens: 1_000_000,
|
||||
..Usage::default()
|
||||
};
|
||||
(config, receipt, usage)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_model_estimate_is_frozen_and_exactly_bound() {
|
||||
let (mut config, receipt, usage) = configured_fixture_receipt();
|
||||
let audit = receipt.audit(&usage);
|
||||
assert_eq!(
|
||||
audit.provenance,
|
||||
Some(codewhale_config::pricing::PricingProvenance::UserOverride)
|
||||
);
|
||||
assert!((audit.estimate.unwrap().usd - 2.0).abs() < 1e-12);
|
||||
let frozen: EffectiveRouteEnvelope =
|
||||
serde_json::from_str(&serde_json::to_string(&receipt).unwrap()).unwrap();
|
||||
config.custom_models.as_mut().unwrap()[0]
|
||||
.cost
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.input = Some(9.0);
|
||||
assert!((frozen.audit(&usage).estimate.unwrap().usd - 2.0).abs() < 1e-12);
|
||||
for (field, value) in [
|
||||
("model", "deepseek-v4.1-flash"),
|
||||
("identity", "other-provider"),
|
||||
("endpoint", "https://other.example.test/v1"),
|
||||
] {
|
||||
let mut wrong = frozen.clone();
|
||||
match field {
|
||||
"model" => wrong.model = value.into(),
|
||||
"identity" => wrong.provider_identity = value.into(),
|
||||
_ => wrong.endpoint_fingerprint = endpoint_fingerprint(value),
|
||||
}
|
||||
assert!(wrong.audit(&usage).estimate.is_none(), "{field}");
|
||||
}
|
||||
for billing in [RouteBillingMode::Local, RouteBillingMode::Subscription] {
|
||||
let mut nonmoney = frozen.clone();
|
||||
nonmoney.billing_mode = billing;
|
||||
assert_eq!(
|
||||
nonmoney.audit(&usage).unpriced_reason,
|
||||
Some(crate::pricing::UnpricedReason::NotMoneyMetered)
|
||||
);
|
||||
}
|
||||
let mut cached = usage.clone();
|
||||
cached.prompt_cache_write_tokens = Some(500);
|
||||
assert!(frozen.audit(&cached).estimate.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_model_missing_prices_stay_unknown_and_vendor_pin_still_wins() {
|
||||
let (mut config, receipt, usage) = configured_fixture_receipt();
|
||||
config.custom_models.as_mut().unwrap()[0].cost = None;
|
||||
let unknown = EffectiveRouteEnvelope::capture(
|
||||
Some(&config),
|
||||
receipt.provider,
|
||||
receipt.provider_identity.clone(),
|
||||
receipt.model.clone(),
|
||||
Some("https://models.example.test/v1"),
|
||||
receipt.dispatched_at,
|
||||
);
|
||||
assert!(unknown.provider_live_pricing.is_some());
|
||||
assert!(unknown.audit(&usage).estimate.is_none());
|
||||
let mut pinned = receipt;
|
||||
pinned.provider = ApiProvider::Openrouter;
|
||||
pinned.openrouter_vendor = Some("exact-upstream".into());
|
||||
pinned.billing_mode = RouteBillingMode::Metered;
|
||||
assert_eq!(
|
||||
pinned.audit(&usage).unpriced_reason,
|
||||
Some(crate::pricing::UnpricedReason::RoutingDependentPrice)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_model_client_keeps_its_metadata_snapshot_after_reload() {
|
||||
let (mut config, _, usage) = configured_fixture_receipt();
|
||||
config.api_key = Some("fixture-not-a-provider-credential".into());
|
||||
let id = "deepseek-v4.1-flash-expires-on-0910";
|
||||
let route =
|
||||
crate::route_runtime::resolve_runtime_route(&config, ApiProvider::Deepseek, Some(id))
|
||||
.unwrap();
|
||||
let client =
|
||||
crate::client::DeepSeekClient::from_candidate(&config, &route.candidate).unwrap();
|
||||
config.custom_models.as_mut().unwrap()[0]
|
||||
.cost
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.input = Some(9.0);
|
||||
let envelope = client.effective_route_envelope(id, Utc::now());
|
||||
assert!((envelope.audit(&usage).estimate.unwrap().usd - 2.0).abs() < 1e-12);
|
||||
assert_eq!(
|
||||
client
|
||||
.effective_route_envelope("other-model", Utc::now())
|
||||
.provider_live_pricing,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
struct ProviderCatalogTestReset;
|
||||
|
||||
impl Drop for ProviderCatalogTestReset {
|
||||
|
||||
@@ -102,6 +102,20 @@ impl ModelInventory {
|
||||
for model in models_for_provider(config, active_provider, provider) {
|
||||
push_model(&mut models, provider, &model);
|
||||
}
|
||||
for declaration in config.custom_models.as_deref().unwrap_or_default() {
|
||||
if crate::provider_lake::configured_model_for_route(
|
||||
config,
|
||||
provider,
|
||||
&config.provider_identity_for(provider),
|
||||
&config.base_url_for_route(provider),
|
||||
&declaration.id,
|
||||
)
|
||||
.is_some()
|
||||
&& !models.contains(&declaration.id)
|
||||
{
|
||||
models.push(declaration.id.clone());
|
||||
}
|
||||
}
|
||||
if models.is_empty() {
|
||||
push_model(&mut models, provider, &default_model);
|
||||
}
|
||||
@@ -122,7 +136,7 @@ impl ModelInventory {
|
||||
{
|
||||
if let Some(context_window) = route.candidate.limits().context_tokens {
|
||||
capability.context_window = context_window.min(u64::from(u32::MAX)) as u32;
|
||||
context_window_unverified = false;
|
||||
context_window_unverified = !route.context_window.source.is_verified();
|
||||
}
|
||||
// A concrete offering maximum is a stronger fact than the
|
||||
// static compatibility matrix — and is the only way a
|
||||
@@ -136,14 +150,36 @@ impl ModelInventory {
|
||||
{
|
||||
capability.max_output = Some(max_output);
|
||||
}
|
||||
let user_declared =
|
||||
route
|
||||
.candidate
|
||||
.applied_limit_overrides()
|
||||
.iter()
|
||||
.any(|entry| {
|
||||
entry.source
|
||||
== codewhale_config::route::OverrideSource::UserModelMetadata
|
||||
});
|
||||
if user_declared {
|
||||
context_window_unverified = !route.context_window.source.is_verified();
|
||||
capability.context_window = route.context_window.tokens;
|
||||
capability.max_output = route
|
||||
.candidate
|
||||
.limits()
|
||||
.output_tokens
|
||||
.and_then(|value| u32::try_from(value).ok());
|
||||
capability.thinking_supported = route.candidate.capabilities().reasoning
|
||||
== codewhale_config::route::CapabilityState::Supported;
|
||||
}
|
||||
// Do not promote bare `k3` into the global capability
|
||||
// catalog. Its thinking trace contract belongs only to
|
||||
// Kimi Code's exact membership-plan route.
|
||||
if crate::config::is_exact_kimi_code_k3_route(
|
||||
provider,
|
||||
&route.candidate.endpoint().base_url,
|
||||
route.candidate.wire_model_id().as_str(),
|
||||
) {
|
||||
if !user_declared
|
||||
&& crate::config::is_exact_kimi_code_k3_route(
|
||||
provider,
|
||||
&route.candidate.endpoint().base_url,
|
||||
route.candidate.wire_model_id().as_str(),
|
||||
)
|
||||
{
|
||||
capability.thinking_supported = true;
|
||||
}
|
||||
}
|
||||
@@ -162,8 +198,7 @@ impl ModelInventory {
|
||||
}
|
||||
// Unready routes stay visible (annotated) so an operator can
|
||||
// override explicitly, but they are never a silent default.
|
||||
let default_for_provider =
|
||||
readiness.can_attempt() && model.eq_ignore_ascii_case(&default_model);
|
||||
let default_for_provider = readiness.can_attempt() && model == default_model;
|
||||
if default_for_provider {
|
||||
tags.push("default");
|
||||
}
|
||||
@@ -251,9 +286,9 @@ impl ModelInventory {
|
||||
provider: ApiProvider,
|
||||
model: &str,
|
||||
) -> Option<&ModelRouteCandidate> {
|
||||
self.candidates.iter().find(|candidate| {
|
||||
candidate.provider == provider && candidate.model.eq_ignore_ascii_case(model.trim())
|
||||
})
|
||||
self.candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.provider == provider && candidate.model == model.trim())
|
||||
}
|
||||
|
||||
pub(crate) fn active_default(&self) -> Option<&ModelRouteCandidate> {
|
||||
|
||||
+35
-29
@@ -36,6 +36,24 @@ pub const DIRECT_KIMI_K3_MAX_OUTPUT_TOKENS: u32 = 1_048_576;
|
||||
/// models resolve to their own scaled value via
|
||||
/// `compaction_threshold_for_model` (#664).
|
||||
pub const DEFAULT_COMPACTION_TOKEN_THRESHOLD: usize = 102_400;
|
||||
pub(crate) fn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {
|
||||
match model.trim().to_ascii_lowercase().as_str() {
|
||||
"deepseek-v4-pro"
|
||||
| "deepseek-v4pro"
|
||||
| "deepseek-ai/deepseek-v4-pro"
|
||||
| "deepseek-ai/deepseek-v4pro"
|
||||
| "deepseek/deepseek-v4-pro"
|
||||
| "deepseek/deepseek-v4pro" => Some("deepseek-v4-pro"),
|
||||
"deepseek-v4-flash"
|
||||
| "deepseek-v4flash"
|
||||
| "deepseek-ai/deepseek-v4-flash"
|
||||
| "deepseek-ai/deepseek-v4flash"
|
||||
| "deepseek/deepseek-v4-flash"
|
||||
| "deepseek/deepseek-v4flash" => Some("deepseek-v4-flash"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
const COMPACTION_THRESHOLD_PERCENT: u32 = 80;
|
||||
|
||||
@@ -175,15 +193,12 @@ pub fn context_window_for_model(model: &str) -> Option<u32> {
|
||||
if let Some(window) = known_context_window_for_model(&lower) {
|
||||
return Some(window);
|
||||
}
|
||||
if canonical_official_deepseek_model_id(&lower).is_some() {
|
||||
return Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS);
|
||||
}
|
||||
if let Some(explicit_window) = explicit_context_window_hint(&lower) {
|
||||
return Some(explicit_window);
|
||||
}
|
||||
if lower.contains("deepseek") {
|
||||
if lower.contains("v4") {
|
||||
return Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS);
|
||||
}
|
||||
return Some(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS);
|
||||
}
|
||||
if is_openai_gpt_55_api_model(&lower) || is_openai_gpt_56_api_model(&lower) {
|
||||
return Some(1_050_000);
|
||||
}
|
||||
@@ -437,7 +452,7 @@ pub fn model_supports_reasoning(model: &str) -> bool {
|
||||
return supports_reasoning;
|
||||
}
|
||||
let lower = model.to_lowercase();
|
||||
if lower.contains("deepseek") && lower.contains("v4") {
|
||||
if canonical_official_deepseek_model_id(&lower).is_some() {
|
||||
return true;
|
||||
}
|
||||
// #3016 plus the 2026 Kimi Code K2.7 update: Moonshot-native Kimi IDs,
|
||||
@@ -860,28 +875,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_snapshots_preserve_context_window() {
|
||||
// v-series snapshots get 1M context since they contain "v4"
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-v4-flash-20260423"),
|
||||
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-v4-pro-20260423"),
|
||||
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_legacy_deepseek_models_map_to_128k_context_window() {
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-coder"),
|
||||
Some(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-v3.2-0324"),
|
||||
Some(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
fn unrecognized_deepseek_models_do_not_inherit_sibling_metadata() {
|
||||
for model in [
|
||||
"deepseek-v4-flash-20260423",
|
||||
"deepseek-v4-pro-20260423",
|
||||
"deepseek-coder",
|
||||
"deepseek-v3.2-0324",
|
||||
"deepseek-v4.1-flash-expires-on-0910",
|
||||
] {
|
||||
assert_eq!(context_window_for_model(model), None, "{model}");
|
||||
}
|
||||
assert!(!model_supports_reasoning(
|
||||
"deepseek-v4.1-flash-expires-on-0910"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1929,6 +1929,21 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_for_identity_at(
|
||||
usage: &Usage,
|
||||
recorded_at: DateTime<Utc>,
|
||||
) -> TurnCostAudit {
|
||||
let declared_pricing = match provider_live_pricing {
|
||||
Some(quote) if quote.provenance == PricingProvenance::UserOverride => {
|
||||
let pricing = provider_identity
|
||||
.zip(endpoint_fingerprint)
|
||||
.zip(u64::try_from(recorded_at.timestamp()).ok())
|
||||
.and_then(|((identity, fingerprint), at)| {
|
||||
quote.pricing_for_route(provider, identity, model, fingerprint, at)
|
||||
});
|
||||
let Some(pricing) = pricing else {
|
||||
return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing);
|
||||
};
|
||||
Some(pricing)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let reviewed_custom_metered =
|
||||
reviewed_custom_route_is_metered(provider, provider_identity, endpoint_fingerprint);
|
||||
let reviewed_provider_live =
|
||||
@@ -1940,7 +1955,11 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_for_identity_at(
|
||||
EndpointMetering::ExactSubscription | EndpointMetering::LocalNoBill => {
|
||||
return TurnCostAudit::unpriced(UnpricedReason::NotMoneyMetered);
|
||||
}
|
||||
EndpointMetering::Unknown if billing_surface.is_some() && !reviewed_custom_metered => {
|
||||
EndpointMetering::Unknown
|
||||
if billing_surface.is_some()
|
||||
&& !reviewed_custom_metered
|
||||
&& declared_pricing.is_none() =>
|
||||
{
|
||||
return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis);
|
||||
}
|
||||
EndpointMetering::Unknown | EndpointMetering::Money => {}
|
||||
@@ -1948,6 +1967,9 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_for_identity_at(
|
||||
if !usage_cache_partition_is_consistent(usage) {
|
||||
return TurnCostAudit::unpriced(UnpricedReason::InconsistentUsage);
|
||||
}
|
||||
if let Some(pricing) = declared_pricing {
|
||||
return audit_offering_pricing(pricing, usage);
|
||||
}
|
||||
if provider == ApiProvider::Stepfun {
|
||||
return match pricing_for_billing_surface(provider, model, billing_surface) {
|
||||
// StepFun's hand row publishes no cache-write rate, so a turn that
|
||||
|
||||
@@ -350,6 +350,7 @@ impl ProviderLivePricingQuote {
|
||||
return None;
|
||||
}
|
||||
match (&self.provenance, &self.cloud_facts) {
|
||||
(PricingProvenance::UserOverride, None) => {}
|
||||
(PricingProvenance::ProviderLive, None)
|
||||
if dispatched_at_unix.saturating_sub(self.catalog_fetched_at)
|
||||
< DEFAULT_PROVIDER_CATALOG_TTL_SECS
|
||||
@@ -388,13 +389,16 @@ impl ProviderLivePricingQuote {
|
||||
}
|
||||
// A reviewed per-token route needs both ordinary request classes. Cache
|
||||
// classes remain optional and fail closed later if a turn used them.
|
||||
if self.cloud_facts.is_none() && (cost.input.is_none() || cost.output.is_none()) {
|
||||
if self.provenance == PricingProvenance::ProviderLive
|
||||
&& (cost.input.is_none() || cost.output.is_none())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if cost.input.is_none()
|
||||
&& cost.output.is_none()
|
||||
&& cost.cache_read.is_none()
|
||||
&& cost.cache_write.is_none()
|
||||
&& self.provenance != PricingProvenance::UserOverride
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -1300,6 +1304,47 @@ fn cloud_pricing_scope(provider: ApiProvider, identity: &str, base_url: &str) ->
|
||||
/// Capture the effective mutable price authority once. Provider-owned live
|
||||
/// prices retain priority; signed cloud prices are admitted only on the exact
|
||||
/// canonical official route. The historical wire field name remains stable.
|
||||
pub(crate) fn configured_dispatch_pricing_quote_at(
|
||||
models: &[codewhale_config::catalog::configured::ConfiguredModel],
|
||||
provider: ApiProvider,
|
||||
identity: &str,
|
||||
model: &str,
|
||||
base_url: &str,
|
||||
dispatched_at: u64,
|
||||
) -> Option<ProviderLivePricingQuote> {
|
||||
if provider == ApiProvider::OpenaiCodex {
|
||||
return None;
|
||||
}
|
||||
codewhale_config::catalog::configured::validate_configured_models(models).ok()?;
|
||||
let declared = models
|
||||
.iter()
|
||||
.find(|row| row.id == model && row.matches_route(identity, base_url))?;
|
||||
let cost = declared.cost.clone().unwrap_or_default();
|
||||
let pricing = OfferingPricing {
|
||||
provider: identity.to_string(),
|
||||
wire_model_id: model.to_string(),
|
||||
canonical_model: None,
|
||||
currency: Currency::Usd,
|
||||
input_per_million: cost.input,
|
||||
output_per_million: cost.output,
|
||||
cache_read_per_million: cost.cache_read,
|
||||
cache_write_per_million: cost.cache_write,
|
||||
provenance: PricingProvenance::UserOverride,
|
||||
effective_at: None,
|
||||
endpoint_fingerprint: Some(base_url_fingerprint(base_url)),
|
||||
};
|
||||
// Freeze even an unpriced declaration: missing rates must not fall through
|
||||
// to a same-named bundled or subsequently refreshed price.
|
||||
ProviderLivePricingQuote::from_pricing(
|
||||
provider,
|
||||
identity,
|
||||
model,
|
||||
&base_url_fingerprint(base_url),
|
||||
dispatched_at,
|
||||
&pricing,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn fresh_dispatch_pricing_quote_at(
|
||||
provider: ApiProvider,
|
||||
provider_identity: &str,
|
||||
|
||||
@@ -981,6 +981,60 @@ pub(crate) fn catalog_offering_for_route(
|
||||
Some(offering)
|
||||
}
|
||||
|
||||
pub(crate) fn configured_model_for_route<'a>(
|
||||
config: &'a Config,
|
||||
provider: ApiProvider,
|
||||
identity: &str,
|
||||
base_url: &str,
|
||||
model: &str,
|
||||
) -> Option<&'a codewhale_config::catalog::configured::ConfiguredModel> {
|
||||
// Account-owned OAuth rosters retain their separate authority.
|
||||
if provider == ApiProvider::OpenaiCodex {
|
||||
return None;
|
||||
}
|
||||
let models = config.custom_models.as_deref()?;
|
||||
codewhale_config::catalog::configured::validate_configured_models(models).ok()?;
|
||||
models
|
||||
.iter()
|
||||
.find(|row| row.id == model && row.matches_route(identity, base_url))
|
||||
}
|
||||
|
||||
/// Config declarations take precedence only for the exact selected route.
|
||||
pub(crate) fn configured_catalog_offering_for_route(
|
||||
config: &Config,
|
||||
provider: ApiProvider,
|
||||
identity: &str,
|
||||
base_url: &str,
|
||||
model: &str,
|
||||
) -> Option<CatalogOffering> {
|
||||
configured_model_for_route(config, provider, identity, base_url, model)
|
||||
.map(|row| row.to_catalog_offering())
|
||||
.or_else(|| catalog_offering_for_route(provider, identity, base_url, model))
|
||||
}
|
||||
|
||||
pub(crate) fn configured_catalog_models_for_route(
|
||||
config: &Config,
|
||||
provider: ApiProvider,
|
||||
identity: &str,
|
||||
base_url: &str,
|
||||
) -> Vec<String> {
|
||||
let mut ids = catalog_models_for_route(provider, identity, base_url);
|
||||
if provider != ApiProvider::OpenaiCodex {
|
||||
let models = config.custom_models.as_deref().unwrap_or_default();
|
||||
if codewhale_config::catalog::configured::validate_configured_models(models).is_ok() {
|
||||
for model in models
|
||||
.iter()
|
||||
.filter(|row| row.matches_route(identity, base_url))
|
||||
{
|
||||
if !ids.contains(&model.id) {
|
||||
ids.push(model.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// Look up the **bundled-snapshot** offering for `(provider, wire_model_id)`,
|
||||
/// ignoring any live rows merged over it.
|
||||
///
|
||||
@@ -1034,7 +1088,8 @@ pub fn models_for_provider(
|
||||
provider: ApiProvider,
|
||||
) -> Vec<String> {
|
||||
if provider_is_configured_for_active(config, provider, active) {
|
||||
catalog_models_for_route(
|
||||
configured_catalog_models_for_route(
|
||||
config,
|
||||
provider,
|
||||
&config.provider_identity_for(provider),
|
||||
&config.base_url_for_route(provider),
|
||||
@@ -1515,7 +1570,8 @@ pub(crate) async fn run_models(
|
||||
let identity = &identities[0];
|
||||
let mut route_config = config.clone();
|
||||
route_config.scope_to_provider_identity(identity);
|
||||
let mut models = catalog_models_for_route(
|
||||
let mut models = configured_catalog_models_for_route(
|
||||
config,
|
||||
identity.provider,
|
||||
&identity.key,
|
||||
&route_config.deepseek_base_url(),
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::models::DIRECT_KIMI_K3_MAX_OUTPUT_TOKENS;
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum ContextWindowSource {
|
||||
Configured,
|
||||
UserDeclared,
|
||||
ProviderReported,
|
||||
StaticKimiCodeSafeFloor,
|
||||
Catalog,
|
||||
@@ -37,8 +38,9 @@ impl ContextWindowSource {
|
||||
/// Every rung, in precedence order. The name-suffix hint sits between
|
||||
/// catalog data and the conservative fallback: any concrete fact about
|
||||
/// the route beats a naming convention.
|
||||
pub(crate) const ALL: [Self; 6] = [
|
||||
pub(crate) const ALL: [Self; 7] = [
|
||||
Self::Configured,
|
||||
Self::UserDeclared,
|
||||
Self::ProviderReported,
|
||||
Self::StaticKimiCodeSafeFloor,
|
||||
Self::Catalog,
|
||||
@@ -50,6 +52,7 @@ impl ContextWindowSource {
|
||||
pub(crate) const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Configured => "configured",
|
||||
Self::UserDeclared => "user declared",
|
||||
Self::ProviderReported => "provider-reported",
|
||||
Self::StaticKimiCodeSafeFloor => "static Kimi Code safe floor",
|
||||
Self::Catalog => "catalog",
|
||||
@@ -73,7 +76,10 @@ impl ContextWindowSource {
|
||||
/// #5441).
|
||||
#[must_use]
|
||||
pub(crate) const fn is_verified(self) -> bool {
|
||||
!matches!(self, Self::NameSuffixHint | Self::Fallback)
|
||||
!matches!(
|
||||
self,
|
||||
Self::NameSuffixHint | Self::Fallback | Self::UserDeclared
|
||||
)
|
||||
}
|
||||
|
||||
/// Suffix every rendered window carries: verified rungs stay bare,
|
||||
@@ -398,6 +404,7 @@ pub(crate) fn validate_unpinned_model_provider(
|
||||
/// Resolve a provider-less fixed model to the provider's exact wire id before
|
||||
/// child admission. This shares the runtime resolver used by Fleet receipts,
|
||||
/// including aggregator alias translation, without making a live request.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn resolve_unpinned_model_candidate(
|
||||
provider: ApiProvider,
|
||||
model: &str,
|
||||
@@ -418,6 +425,35 @@ pub(crate) fn resolve_unpinned_model_candidate(
|
||||
/// Code bare-K3 endpoint, only at the documented 1M entitlement, and only
|
||||
/// while fresh; this prevents generic Moonshot or stale metadata from being
|
||||
/// inherited by a membership-plan route.
|
||||
/// Resolve a manual selection from the App's loaded, non-secret metadata
|
||||
/// snapshot. This shares the same scoped resolver and limit precedence as
|
||||
/// config-backed runtime selection, without loading credentials while typing.
|
||||
pub(crate) fn resolve_declared_model_candidate(
|
||||
provider: ApiProvider,
|
||||
identity: &str,
|
||||
model: &str,
|
||||
base_url: &str,
|
||||
context_window: Option<u32>,
|
||||
models: &[codewhale_config::catalog::configured::ConfiguredModel],
|
||||
) -> Result<RouteCandidateResolution, String> {
|
||||
let resolver = RouteResolver::new().with_configured_models(
|
||||
models,
|
||||
identity,
|
||||
provider.kind().unwrap_or_default(),
|
||||
base_url,
|
||||
);
|
||||
resolve_route_candidate_with_catalog_resolver(
|
||||
provider,
|
||||
Some(model),
|
||||
None,
|
||||
Some(base_url.into()),
|
||||
context_window,
|
||||
None,
|
||||
&resolver,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_route_candidate_with_context_metadata(
|
||||
provider: ApiProvider,
|
||||
model_selector: Option<&str>,
|
||||
@@ -518,13 +554,23 @@ fn plan_limit_overrides(
|
||||
provider_reported_context: Option<ProviderReportedKimiCodeContext>,
|
||||
) -> LimitOverridePlan {
|
||||
let mut overrides = Vec::new();
|
||||
let declared_field = |field| {
|
||||
resolved
|
||||
.applied_limit_overrides()
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|entry| entry.field == field)
|
||||
.is_some_and(|entry| entry.source == OverrideSource::UserModelMetadata)
|
||||
};
|
||||
let configured = context_window_override.filter(|window| *window > 0);
|
||||
let mut effective_context = resolved.limits().context_tokens;
|
||||
if is_exact_direct_moonshot_k3_route(
|
||||
provider,
|
||||
&resolved.endpoint().base_url,
|
||||
resolved.wire_model_id().as_str(),
|
||||
) {
|
||||
if !declared_field(LimitField::OutputTokens)
|
||||
&& is_exact_direct_moonshot_k3_route(
|
||||
provider,
|
||||
&resolved.endpoint().base_url,
|
||||
resolved.wire_model_id().as_str(),
|
||||
)
|
||||
{
|
||||
overrides.push(SourcedLimitOverride {
|
||||
field: LimitField::OutputTokens,
|
||||
value: Some(u64::from(DIRECT_KIMI_K3_MAX_OUTPUT_TOKENS)),
|
||||
@@ -579,6 +625,25 @@ fn plan_limit_overrides(
|
||||
};
|
||||
}
|
||||
|
||||
// Exact operator metadata wins over inferred/catalog/provider-family facts.
|
||||
// A missing field remains unknown, with only the conservative budget floor.
|
||||
if declared_field(LimitField::ContextTokens) {
|
||||
let context_window = effective_context
|
||||
.and_then(|tokens| u32::try_from(tokens).ok())
|
||||
.map(|tokens| ContextWindowResolution {
|
||||
tokens,
|
||||
source: ContextWindowSource::UserDeclared,
|
||||
})
|
||||
.unwrap_or(ContextWindowResolution {
|
||||
tokens: 128_000,
|
||||
source: ContextWindowSource::Fallback,
|
||||
});
|
||||
return LimitOverridePlan {
|
||||
overrides,
|
||||
context_window,
|
||||
};
|
||||
}
|
||||
|
||||
let is_exact_kimi_code_k3 = is_exact_kimi_code_bare_k3_route(
|
||||
provider,
|
||||
&resolved.endpoint().base_url,
|
||||
@@ -700,12 +765,18 @@ pub(crate) fn resolve_runtime_route_for_identity(
|
||||
let resolution = if provider != ApiProvider::OpenaiCodex {
|
||||
let status =
|
||||
crate::provider_catalog_live::status_for_route(provider, &identity.key, &base_url);
|
||||
let catalog = crate::provider_lake::runtime_catalog_resolver_for_identity(
|
||||
let mut catalog = crate::provider_lake::runtime_catalog_resolver_for_identity(
|
||||
provider,
|
||||
Some(&identity.key),
|
||||
&base_url,
|
||||
status,
|
||||
);
|
||||
catalog.resolver = catalog.resolver.with_configured_models(
|
||||
route_config.custom_models.as_deref().unwrap_or_default(),
|
||||
&identity.key,
|
||||
provider.kind().unwrap_or_default(),
|
||||
&base_url,
|
||||
);
|
||||
// Local Ollama's placeholder is never an executable model. Resolve
|
||||
// an unset/auto/placeholder selection from this endpoint's fresh roster,
|
||||
// while preserving an explicit or saved real tag verbatim.
|
||||
@@ -840,6 +911,57 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::config::{DEFAULT_TEXT_MODEL, DEFAULT_ZAI_MODEL, ProviderConfig, ProvidersConfig};
|
||||
|
||||
#[test]
|
||||
fn configured_model_limits_precede_provider_defaults() {
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let _catalog = crate::provider_lake::lock_live_snapshot();
|
||||
for (provider, identity, base, model) in [
|
||||
(
|
||||
ApiProvider::Moonshot,
|
||||
"moonshot",
|
||||
"https://api.moonshot.ai/v1",
|
||||
"kimi-k3",
|
||||
),
|
||||
(
|
||||
ApiProvider::Moonshot,
|
||||
"moonshot",
|
||||
"https://api.kimi.com/coding/v1",
|
||||
"k3",
|
||||
),
|
||||
(
|
||||
ApiProvider::DeepseekCN,
|
||||
"deepseek-cn",
|
||||
"https://models.example.test/v1",
|
||||
"deepseek-v4.1-flash-expires-on-0910",
|
||||
),
|
||||
] {
|
||||
let mut config: Config = toml::from_str(include_str!(
|
||||
"../../config/tests/fixtures/custom_models.toml"
|
||||
))
|
||||
.unwrap();
|
||||
config.provider = Some(identity.into());
|
||||
config.base_url = Some(base.into());
|
||||
config.providers = None;
|
||||
let declaration = &mut config.custom_models.as_mut().unwrap()[0];
|
||||
declaration.provider = identity.into();
|
||||
declaration.base_url = base.into();
|
||||
declaration.id = model.into();
|
||||
let route = resolve_runtime_route(&config, provider, Some(model)).unwrap();
|
||||
assert_eq!(route.model, model);
|
||||
assert_eq!(route.candidate.limits().context_tokens, Some(96000));
|
||||
assert_eq!(route.candidate.limits().output_tokens, Some(8000));
|
||||
assert_eq!(
|
||||
route.context_window.source,
|
||||
ContextWindowSource::UserDeclared
|
||||
);
|
||||
config.custom_models.as_mut().unwrap()[0].limit = None;
|
||||
let unknown = resolve_runtime_route(&config, provider, Some(model)).unwrap();
|
||||
assert_eq!(unknown.candidate.limits().context_tokens, None);
|
||||
assert_eq!(unknown.candidate.limits().output_tokens, None);
|
||||
assert_eq!(unknown.context_window.source, ContextWindowSource::Fallback);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every rung keeps its own label and round-trips through it, and only
|
||||
/// the guesses read as unverified. Two rungs sharing a label would let a
|
||||
/// guess be displayed as evidence.
|
||||
@@ -859,7 +981,9 @@ mod tests {
|
||||
source.is_verified(),
|
||||
!matches!(
|
||||
source,
|
||||
ContextWindowSource::Fallback | ContextWindowSource::NameSuffixHint
|
||||
ContextWindowSource::Fallback
|
||||
| ContextWindowSource::NameSuffixHint
|
||||
| ContextWindowSource::UserDeclared
|
||||
),
|
||||
"{source:?} misreports whether its window rests on route evidence"
|
||||
);
|
||||
|
||||
@@ -6320,6 +6320,20 @@ fn provider_models_for_api(
|
||||
push_unique_model(&mut models, &model);
|
||||
}
|
||||
}
|
||||
for model in config.custom_models.as_deref().unwrap_or_default() {
|
||||
if crate::provider_lake::configured_model_for_route(
|
||||
config,
|
||||
provider,
|
||||
&config.provider_identity_for(provider),
|
||||
&config.base_url_for_route(provider),
|
||||
&model.id,
|
||||
)
|
||||
.is_some()
|
||||
&& !models.contains(&model.id)
|
||||
{
|
||||
models.push(model.id.clone());
|
||||
}
|
||||
}
|
||||
if provider == ApiProvider::Ollama {
|
||||
models.retain(|model| !crate::config::is_unresolved_local_ollama_model(model));
|
||||
}
|
||||
@@ -6538,9 +6552,13 @@ async fn list_providers(
|
||||
let default_model = provider_default_model_for_api(&config, active_provider, api_provider);
|
||||
let identity = config.provider_identity_for(api_provider);
|
||||
let base_url = config.base_url_for_route_identity(api_provider, &identity);
|
||||
let has_model_catalog =
|
||||
!crate::provider_lake::catalog_models_for_route(api_provider, &identity, &base_url)
|
||||
.is_empty();
|
||||
let has_model_catalog = !crate::provider_lake::configured_catalog_models_for_route(
|
||||
&config,
|
||||
api_provider,
|
||||
&identity,
|
||||
&base_url,
|
||||
)
|
||||
.is_empty();
|
||||
providers.push(ProviderEntry {
|
||||
id: api_provider.as_str().to_string(),
|
||||
model_provider_id: (api_provider == active_provider)
|
||||
|
||||
@@ -13400,13 +13400,7 @@ fn enforce_fleet_member_route_requirements(
|
||||
}
|
||||
let member_id = crate::fleet::identity::FleetMemberIdentity::from_member(member).member_id;
|
||||
|
||||
let candidate = crate::route_runtime::resolve_route_candidate(
|
||||
runtime.client.api_provider(),
|
||||
Some(model),
|
||||
None,
|
||||
Some(runtime.client.base_url().to_string()),
|
||||
None,
|
||||
)
|
||||
let candidate = runtime.client.resolve_model_route(model)
|
||||
.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Fleet member '{member_id}' requirements could not be checked against its exact child route: {}",
|
||||
@@ -13964,7 +13958,15 @@ fn resolve_fixed_spawn_model_route(
|
||||
return Ok(());
|
||||
};
|
||||
let provider = runtime.client.api_provider();
|
||||
let candidate = runtime.client.resolve_model_route(model);
|
||||
let declared = candidate.as_ref().is_ok_and(|candidate| {
|
||||
candidate
|
||||
.applied_limit_overrides()
|
||||
.iter()
|
||||
.any(|entry| entry.source == codewhale_config::route::OverrideSource::UserModelMetadata)
|
||||
});
|
||||
if providerless
|
||||
&& !declared
|
||||
&& let Err(reason) = crate::route_runtime::validate_unpinned_model_provider(
|
||||
provider,
|
||||
model,
|
||||
@@ -13990,22 +13992,7 @@ fn resolve_fixed_spawn_model_route(
|
||||
selection.source = SpawnRouteSource::RunModel;
|
||||
return Ok(());
|
||||
}
|
||||
let candidate = if providerless {
|
||||
crate::route_runtime::resolve_unpinned_model_candidate(
|
||||
provider,
|
||||
model,
|
||||
runtime.client.base_url(),
|
||||
)
|
||||
} else {
|
||||
crate::route_runtime::resolve_route_candidate(
|
||||
provider,
|
||||
Some(model),
|
||||
None,
|
||||
Some(runtime.client.base_url().to_string()),
|
||||
None,
|
||||
)
|
||||
}
|
||||
.map_err(ToolError::invalid_input)?;
|
||||
let candidate = candidate.map_err(|error| ToolError::invalid_input(error.to_string()))?;
|
||||
selection.model_route = ModelRoute::Fixed(candidate.wire_model_id().as_str().to_string());
|
||||
Ok(())
|
||||
}
|
||||
@@ -16797,3 +16784,70 @@ mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use tests::kimi_general_child_request_tools_fixture;
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn configured_model_subagent_keeps_exact_id_and_negative_capability() {
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let mut runtime = tests::stub_runtime();
|
||||
let mut config = crate::config::Config {
|
||||
provider: Some("deepseek".into()),
|
||||
api_key: Some("configured-model-local-fixture".into()),
|
||||
custom_models: Some(vec![
|
||||
toml::from_str(
|
||||
r#"
|
||||
provider = "deepseek"
|
||||
base_url = "https://api.deepseek.com"
|
||||
id = "deepseek-v4pro"
|
||||
modalities = { input = ["text"], output = ["text"] }
|
||||
tool_call = false
|
||||
limit = { context = 4096, output = 64 }
|
||||
"#,
|
||||
)
|
||||
.unwrap(),
|
||||
]),
|
||||
..crate::config::Config::default()
|
||||
};
|
||||
runtime.client = DeepSeekClient::new(&config).unwrap();
|
||||
config.custom_models.as_mut().unwrap()[0]
|
||||
.modalities
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.input
|
||||
.push("image".into());
|
||||
runtime.api_config = Some(std::sync::Arc::new(config));
|
||||
let mut selection = SpawnModelSelection {
|
||||
model_route: ModelRoute::Fixed("deepseek-v4pro".into()),
|
||||
source: SpawnRouteSource::TaskModel,
|
||||
};
|
||||
resolve_fixed_spawn_model_route(&runtime, &mut selection, true).unwrap();
|
||||
assert_eq!(
|
||||
selection.model_route,
|
||||
ModelRoute::Fixed("deepseek-v4pro".into())
|
||||
);
|
||||
let candidate = runtime
|
||||
.client
|
||||
.resolve_model_route("deepseek-v4pro")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
candidate.capabilities().image_input,
|
||||
codewhale_config::route::CapabilityState::Unsupported
|
||||
);
|
||||
assert_eq!(
|
||||
candidate.capabilities().native_tool_calls,
|
||||
codewhale_config::route::CapabilityState::Unsupported
|
||||
);
|
||||
let member = crate::fleet::profile::AgentProfile {
|
||||
id: "metadata-fixture".into(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
requires: vec!["vision".into()],
|
||||
profile: codewhale_config::FleetProfile::default(),
|
||||
source: std::path::PathBuf::new(),
|
||||
origin: crate::fleet::profile::ProfileOrigin::Config,
|
||||
plugin_authority: None,
|
||||
};
|
||||
let error = enforce_fleet_member_route_requirements(Some(&member), &runtime, "deepseek-v4pro")
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("unsupported"));
|
||||
}
|
||||
@@ -1561,6 +1561,9 @@ pub struct App {
|
||||
/// The catalog remains separately discoverable and selecting from it adds
|
||||
/// to this set rather than replacing earlier enabled choices.
|
||||
pub enabled_provider_models: HashMap<String, Vec<String>>,
|
||||
/// Non-secret declarations from the loaded config snapshot. Completion
|
||||
/// reads this snapshot without reloading credentials on each keystroke.
|
||||
pub configured_models: Vec<codewhale_config::catalog::configured::ConfiguredModel>,
|
||||
/// Exact provider/model pins loaded from settings, in user order.
|
||||
pub pinned_models: Vec<crate::settings::PinnedModel>,
|
||||
/// When true, the model is auto-selected based on request complexity
|
||||
|
||||
@@ -398,16 +398,10 @@ impl App {
|
||||
},
|
||||
)
|
||||
} else {
|
||||
let saved_provider_model = config
|
||||
.provider_config_for(provider)
|
||||
.and_then(|provider| provider.model.as_deref());
|
||||
crate::route_runtime::resolve_route_candidate_with_context_metadata(
|
||||
crate::route_runtime::resolve_runtime_route(
|
||||
&effective_auth_config,
|
||||
provider,
|
||||
Some(&model),
|
||||
saved_provider_model,
|
||||
Some(configured_route_base_url.clone()),
|
||||
active_context_window_override,
|
||||
None,
|
||||
)
|
||||
.map(|resolution| {
|
||||
(
|
||||
@@ -802,6 +796,7 @@ impl App {
|
||||
model,
|
||||
provider_models,
|
||||
enabled_provider_models,
|
||||
configured_models: config.custom_models.clone().unwrap_or_default(),
|
||||
pinned_models: settings.pinned_models.clone(),
|
||||
auto_model,
|
||||
last_effective_model: None,
|
||||
|
||||
@@ -305,6 +305,8 @@ struct ModelPickerRow {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
struct EffectivePickerMetadata {
|
||||
display_name: Option<String>,
|
||||
declared_input_price: Option<String>,
|
||||
context_window: Option<u32>,
|
||||
/// The context window came through the legacy provider fallback rather
|
||||
/// than an offering, catalog row, roster, or operator override — shown,
|
||||
@@ -317,6 +319,7 @@ struct EffectivePickerMetadata {
|
||||
max_output_unverified: bool,
|
||||
tool_calls: Option<bool>,
|
||||
reasoning: bool,
|
||||
reasoning_unknown: bool,
|
||||
vision: SupportState,
|
||||
pricing: PickerPricing,
|
||||
source: Option<CatalogSource>,
|
||||
@@ -544,7 +547,12 @@ impl ModelPickerView {
|
||||
let mut rows: Vec<_> = visible
|
||||
.iter()
|
||||
.map(|row| PaneRow {
|
||||
primary: row.id.clone(),
|
||||
primary: row
|
||||
.metadata
|
||||
.display_name
|
||||
.as_ref()
|
||||
.map(|label| format!("{label} ({})", row.id))
|
||||
.unwrap_or_else(|| row.id.clone()),
|
||||
route: row
|
||||
.provider
|
||||
.map(|provider| {
|
||||
@@ -1597,6 +1605,14 @@ fn picker_model_rows_for_app(app: &App, config: &Config) -> Vec<ModelPickerRow>
|
||||
} else {
|
||||
provider_scoped_model_ids_for_app(app, false)
|
||||
};
|
||||
if let Some(enabled) = app
|
||||
.enabled_provider_models
|
||||
.get(app.provider_identity_for_persistence())
|
||||
{
|
||||
for id in enabled {
|
||||
push_model_id(&mut active_model_ids, id);
|
||||
}
|
||||
}
|
||||
push_configured_provider_model(&mut active_model_ids, config, app.api_provider);
|
||||
push_provider_model_rows(
|
||||
&mut rows,
|
||||
@@ -1637,6 +1653,11 @@ fn picker_model_rows_for_app(app: &App, config: &Config) -> Vec<ModelPickerRow>
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Some(enabled) = app.enabled_provider_models.get(provider.as_str()) {
|
||||
for id in enabled {
|
||||
push_model_id(&mut model_ids, id);
|
||||
}
|
||||
}
|
||||
push_configured_provider_model(&mut model_ids, config, provider);
|
||||
push_provider_model_rows(
|
||||
&mut rows,
|
||||
@@ -1704,6 +1725,9 @@ fn picker_model_rows_for_app(app: &App, config: &Config) -> Vec<ModelPickerRow>
|
||||
}
|
||||
|
||||
fn model_row_enabled_for_app(app: &App, config: &Config, row: &ModelPickerRow) -> bool {
|
||||
if matches!(row.metadata.source, Some(CatalogSource::ConfigOverride)) {
|
||||
return true;
|
||||
}
|
||||
let Some(provider) = row.provider else {
|
||||
return true;
|
||||
};
|
||||
@@ -1761,12 +1785,23 @@ fn push_provider_model_rows(
|
||||
rows: &mut Vec<ModelPickerRow>,
|
||||
provider: ApiProvider,
|
||||
provider_identity: Option<&str>,
|
||||
model_ids: Vec<String>,
|
||||
mut model_ids: Vec<String>,
|
||||
active_provider: ApiProvider,
|
||||
config: &Config,
|
||||
codex_roster: &CodexModelRoster,
|
||||
provider_health: &crate::provider_readiness::ProviderReadinessSnapshot,
|
||||
) {
|
||||
let identity = provider_identity
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| config.provider_identity_for(provider));
|
||||
let base_url = config.base_url_for_route_identity(provider, &identity);
|
||||
for id in crate::provider_lake::configured_catalog_models_for_route(
|
||||
config, provider, &identity, &base_url,
|
||||
) {
|
||||
if !model_ids.contains(&id) {
|
||||
model_ids.push(id);
|
||||
}
|
||||
}
|
||||
for id in model_ids {
|
||||
if id == "auto" {
|
||||
continue;
|
||||
@@ -1806,6 +1841,9 @@ fn push_provider_model_rows(
|
||||
codex_freshness,
|
||||
provider_catalog_receipt.as_ref(),
|
||||
);
|
||||
if metadata.display_name.is_some() {
|
||||
hint = format!("{id} · {hint}");
|
||||
}
|
||||
hint = format!("{readiness_label} · {hint}");
|
||||
if provider != active_provider {
|
||||
hint = format!("switch route · {hint}");
|
||||
@@ -1965,6 +2003,22 @@ fn provider_scoped_model_ids_for_app(app: &App, include_current_model: bool) ->
|
||||
push_model_id(&mut models, &id);
|
||||
}
|
||||
|
||||
if app.api_provider != ApiProvider::OpenaiCodex
|
||||
&& codewhale_config::catalog::configured::validate_configured_models(&app.configured_models)
|
||||
.is_ok()
|
||||
{
|
||||
for declaration in app.configured_models.iter().filter(|declaration| {
|
||||
declaration.matches_route(
|
||||
app.provider_identity_for_persistence(),
|
||||
&app.active_route_base_url,
|
||||
)
|
||||
}) {
|
||||
if !models.contains(&declaration.id) {
|
||||
models.push(declaration.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(model) = app
|
||||
.provider_models
|
||||
.get(app.provider_identity_for_persistence())
|
||||
@@ -2119,6 +2173,7 @@ fn model_row_matches_query(
|
||||
|| matches(provider.display_name())
|
||||
});
|
||||
provider_matches
|
||||
|| row.metadata.display_name.as_deref().is_some_and(matches)
|
||||
|| matches(&row.id)
|
||||
|| ((row.provider.is_none() || row.provider == Some(initial_provider))
|
||||
&& matches(&row.hint))
|
||||
@@ -2252,6 +2307,45 @@ fn catalog_family_for_identity(
|
||||
}
|
||||
|
||||
fn model_row_meta_chips(row: &ModelPickerRow) -> Vec<String> {
|
||||
if row.metadata.source == Some(CatalogSource::ConfigOverride) {
|
||||
// The qualifier comes first so compact rows cannot shed it while
|
||||
// keeping an unverified declaration visible as a provider fact.
|
||||
return vec![
|
||||
"user declared (unverified)".to_string(),
|
||||
row.metadata
|
||||
.context_window
|
||||
.map(|value| format_picker_context_window(u64::from(value)))
|
||||
.unwrap_or_else(|| "context unknown".into()),
|
||||
row.metadata
|
||||
.max_output
|
||||
.map(|value| format!("{value} out"))
|
||||
.unwrap_or_else(|| "output unknown".into()),
|
||||
match &row.metadata.pricing {
|
||||
PickerPricing::Known(price) => format!("estimate {price}"),
|
||||
_ => "price unknown".into(),
|
||||
},
|
||||
if row.metadata.reasoning_unknown {
|
||||
"reasoning unknown"
|
||||
} else if row.metadata.reasoning {
|
||||
"reasoning"
|
||||
} else {
|
||||
"no reasoning"
|
||||
}
|
||||
.into(),
|
||||
match row.metadata.tool_calls {
|
||||
Some(true) => "tools declared",
|
||||
Some(false) => "no tools",
|
||||
None => "tools unknown",
|
||||
}
|
||||
.into(),
|
||||
match row.metadata.vision {
|
||||
SupportState::Supported => "vision declared",
|
||||
SupportState::Unsupported => "text only",
|
||||
SupportState::Unknown => "vision unknown",
|
||||
}
|
||||
.into(),
|
||||
];
|
||||
}
|
||||
let mut chips = Vec::new();
|
||||
if let Some(context_window) = row.metadata.context_window {
|
||||
chips.push(format_picker_context_window(u64::from(context_window)));
|
||||
@@ -2557,6 +2651,13 @@ fn context_tokens(row: &ModelPickerRow) -> u64 {
|
||||
}
|
||||
|
||||
fn input_price_per_million(row: &ModelPickerRow) -> Option<f64> {
|
||||
if matches!(row.metadata.source, Some(CatalogSource::ConfigOverride)) {
|
||||
return row
|
||||
.metadata
|
||||
.declared_input_price
|
||||
.as_ref()
|
||||
.and_then(|price| price.parse().ok());
|
||||
}
|
||||
if matches!(row.metadata.pricing, PickerPricing::Unavailable) {
|
||||
return None;
|
||||
}
|
||||
@@ -2620,7 +2721,9 @@ fn effective_picker_metadata_with_codex(
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| config.provider_identity_for(provider));
|
||||
let base_url = config.base_url_for_route_identity(provider, &identity);
|
||||
crate::provider_lake::catalog_offering_for_route(provider, &identity, &base_url, id)
|
||||
crate::provider_lake::configured_catalog_offering_for_route(
|
||||
config, provider, &identity, &base_url, id,
|
||||
)
|
||||
});
|
||||
let card = offering.as_ref().map(ModelReferenceCard::from_offering);
|
||||
let registry = model_registry::lookup(id);
|
||||
@@ -2641,6 +2744,9 @@ fn effective_picker_metadata_with_codex(
|
||||
} else {
|
||||
PickerPricing::Unknown
|
||||
},
|
||||
display_name: None,
|
||||
reasoning_unknown: false,
|
||||
declared_input_price: None,
|
||||
source: None,
|
||||
};
|
||||
};
|
||||
@@ -2659,6 +2765,42 @@ fn effective_picker_metadata_with_codex(
|
||||
config.context_window_for_provider_config(provider)
|
||||
};
|
||||
let base_url = config.base_url_for_route_identity(provider, &identity);
|
||||
if let Some(declared) =
|
||||
crate::provider_lake::configured_model_for_route(config, provider, &identity, &base_url, id)
|
||||
{
|
||||
return EffectivePickerMetadata {
|
||||
display_name: declared.display_name.clone(),
|
||||
declared_input_price: declared
|
||||
.cost
|
||||
.as_ref()
|
||||
.and_then(|cost| cost.input)
|
||||
.map(|price| price.to_string()),
|
||||
context_window: context_override.or_else(|| {
|
||||
declared
|
||||
.limit
|
||||
.as_ref()
|
||||
.and_then(|limit| limit.context)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
}),
|
||||
max_output: declared
|
||||
.limit
|
||||
.as_ref()
|
||||
.and_then(|limit| limit.output)
|
||||
.and_then(|value| u32::try_from(value).ok()),
|
||||
tool_calls: declared.tool_call,
|
||||
reasoning: declared.reasoning.unwrap_or(false),
|
||||
reasoning_unknown: declared.reasoning.is_none(),
|
||||
vision: codewhale_config::models_dev::image_input_support(declared.modalities.as_ref()),
|
||||
pricing: card
|
||||
.as_ref()
|
||||
.filter(|card| card.price_label() != "unknown")
|
||||
.map_or(PickerPricing::Unknown, |card| {
|
||||
PickerPricing::Known(card.price_label())
|
||||
}),
|
||||
source: Some(CatalogSource::ConfigOverride),
|
||||
..EffectivePickerMetadata::default()
|
||||
};
|
||||
}
|
||||
if offering.is_none()
|
||||
&& provider != ApiProvider::OpenaiCodex
|
||||
&& provider.kind().is_none_or(|kind| {
|
||||
@@ -2787,6 +2929,9 @@ fn effective_picker_metadata_with_codex(
|
||||
reasoning,
|
||||
vision,
|
||||
pricing,
|
||||
display_name: None,
|
||||
reasoning_unknown: false,
|
||||
declared_input_price: None,
|
||||
source: card.map(|card| card.source),
|
||||
}
|
||||
}
|
||||
@@ -2897,7 +3042,7 @@ fn render_picker_model_hint(
|
||||
}
|
||||
Some(CatalogSource::CloudFacts { .. }) => parts.push("signed facts".to_string()),
|
||||
Some(CatalogSource::ConfigOverride | CatalogSource::UserOverride) => {
|
||||
parts.push("override".to_string())
|
||||
parts.push("user declared (unverified)".to_string())
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
@@ -3645,6 +3790,91 @@ fn default_picker_effort_idx(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn configured_model_picker_and_runtime_share_exact_persisted_metadata() {
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let _catalog = crate::provider_lake::lock_live_snapshot();
|
||||
let mut config: Config = toml::from_str(include_str!(
|
||||
"../../../config/tests/fixtures/custom_models.toml"
|
||||
))
|
||||
.unwrap();
|
||||
let id = "deepseek-v4.1-flash-expires-on-0910";
|
||||
let base = "https://models.example.test/v1";
|
||||
let metadata = effective_picker_metadata(&config, Some(ApiProvider::Deepseek), id);
|
||||
assert_eq!(metadata.display_name.as_deref(), Some("Temporary preview"));
|
||||
assert_eq!(metadata.context_window, Some(96000));
|
||||
assert_eq!(metadata.max_output, Some(8000));
|
||||
assert_eq!(metadata.tool_calls, Some(true));
|
||||
assert_eq!(metadata.source, Some(CatalogSource::ConfigOverride));
|
||||
let mut row = model_row(ApiProvider::Deepseek, true);
|
||||
row.id = id.into();
|
||||
row.metadata = metadata.clone();
|
||||
let chips = model_row_meta_chips(&row);
|
||||
assert_eq!(chips[0], "user declared (unverified)");
|
||||
assert!(chips.iter().any(|chip| chip.starts_with("estimate ")));
|
||||
assert!(fit_meta_chips(&chips, 30).starts_with("user declared"));
|
||||
assert!(
|
||||
render_picker_model_hint(id, Some(ApiProvider::Deepseek), &metadata, None, None)
|
||||
.contains("user declared")
|
||||
);
|
||||
assert!(
|
||||
crate::provider_lake::configured_catalog_models_for_route(
|
||||
&config,
|
||||
ApiProvider::Deepseek,
|
||||
"deepseek",
|
||||
base
|
||||
)
|
||||
.iter()
|
||||
.any(|model| model == id)
|
||||
);
|
||||
let route =
|
||||
crate::route_runtime::resolve_runtime_route(&config, ApiProvider::Deepseek, Some(id))
|
||||
.unwrap();
|
||||
assert_eq!(route.model, id);
|
||||
assert_eq!(Some(route.context_window.tokens), metadata.context_window);
|
||||
assert_eq!(
|
||||
route.context_window.source,
|
||||
crate::route_runtime::ContextWindowSource::UserDeclared
|
||||
);
|
||||
assert!(!route.context_window.source.is_verified());
|
||||
assert_eq!(
|
||||
route.candidate.capabilities().image_input,
|
||||
SupportState::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
route.candidate.capabilities().native_tool_calls,
|
||||
SupportState::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
crate::route_budget::route_output_limit_tokens(Some(route.candidate.limits())),
|
||||
metadata.max_output
|
||||
);
|
||||
// /load replaces the same metadata alongside the provider route.
|
||||
let mut reloaded = config.clone();
|
||||
reloaded.custom_models.as_mut().unwrap()[0].reasoning = None;
|
||||
reloaded.custom_models.as_mut().unwrap()[0].limit = None;
|
||||
reloaded.custom_models.as_mut().unwrap()[0].cost = None;
|
||||
reloaded.custom_models.as_mut().unwrap()[0].tool_call = None;
|
||||
reloaded.custom_models.as_mut().unwrap()[0].modalities = None;
|
||||
config.refresh_provider_routes_from(&reloaded);
|
||||
let unknown = effective_picker_metadata(&config, Some(ApiProvider::Deepseek), id);
|
||||
assert_eq!(unknown.context_window, None);
|
||||
assert_eq!(unknown.max_output, None);
|
||||
assert_eq!(unknown.tool_calls, None);
|
||||
assert_eq!(unknown.vision, SupportState::Unknown);
|
||||
assert_eq!(unknown.pricing, PickerPricing::Unknown);
|
||||
row.metadata = unknown;
|
||||
assert!(model_row_meta_chips(&row).contains(&"reasoning unknown".to_string()));
|
||||
assert_eq!(
|
||||
crate::route_budget::effective_max_output_tokens_for_route(
|
||||
ApiProvider::Deepseek,
|
||||
id,
|
||||
None
|
||||
),
|
||||
8192
|
||||
);
|
||||
}
|
||||
|
||||
fn model_row(provider: ApiProvider, enabled: bool) -> ModelPickerRow {
|
||||
ModelPickerRow {
|
||||
id: "model".to_string(),
|
||||
|
||||
@@ -179,6 +179,7 @@ enum ProviderListView {
|
||||
}
|
||||
|
||||
pub struct ProviderPickerView {
|
||||
route_config: Config,
|
||||
rows: Vec<ProviderDashboardRow>,
|
||||
selected_idx: usize,
|
||||
stage: Stage,
|
||||
@@ -685,18 +686,10 @@ impl ProviderDashboardRow {
|
||||
// particular, Kimi Code's bare K3 model has a conservative 262K
|
||||
// membership-plan baseline (or an explicit configured override), not
|
||||
// the generic catalog's unknown-model fallback.
|
||||
let route = crate::route_runtime::resolve_route_candidate_with_context_metadata(
|
||||
let route = crate::route_runtime::resolve_runtime_route(
|
||||
config,
|
||||
provider,
|
||||
configured_model.as_deref(),
|
||||
None,
|
||||
// The legacy CN alias shares DeepSeek's strict model contract.
|
||||
// Passing its endpoint as a generic override would classify the
|
||||
// route as custom and accidentally accept foreign model ids.
|
||||
(provider != ApiProvider::DeepseekCN)
|
||||
.then(|| configured_base_url.clone())
|
||||
.flatten(),
|
||||
config.context_window_for_provider_config(provider),
|
||||
None,
|
||||
);
|
||||
let (
|
||||
base_url,
|
||||
@@ -784,6 +777,34 @@ impl ProviderDashboardRow {
|
||||
capabilities.context_window = Some(context_window);
|
||||
}
|
||||
capabilities.context_window_source = route_context_window_source;
|
||||
if let Some(declared) = crate::provider_lake::configured_model_for_route(
|
||||
config,
|
||||
provider,
|
||||
&provider_id,
|
||||
&base_url,
|
||||
&default_route.wire_model,
|
||||
) {
|
||||
capabilities = ProviderCapabilityBadges::unknown();
|
||||
capabilities.context_window = declared
|
||||
.limit
|
||||
.as_ref()
|
||||
.and_then(|limit| limit.context)
|
||||
.and_then(|value| u32::try_from(value).ok());
|
||||
capabilities.max_output = declared
|
||||
.limit
|
||||
.as_ref()
|
||||
.and_then(|limit| limit.output)
|
||||
.and_then(|value| u32::try_from(value).ok());
|
||||
capabilities.context_window_source = Some("user declared (unverified)".into());
|
||||
messages.push("Model metadata is user declared; availability and capabilities have not been verified.".into());
|
||||
}
|
||||
let available_model_count = crate::provider_lake::configured_catalog_models_for_route(
|
||||
config,
|
||||
provider,
|
||||
&provider_id,
|
||||
&base_url,
|
||||
)
|
||||
.len();
|
||||
// #5772: the status projection needs an ambient candidate path to
|
||||
// report `ambient_path_changed`, so resolving it for a provider the
|
||||
// user never consented to would derive (and then render) another CLI's
|
||||
@@ -1443,6 +1464,33 @@ fn readiness_for(
|
||||
/// Provider-agnostic fallbacks keep the label truthful when the catalog has
|
||||
/// no row: self-hosted routes are local, Codex rides OAuth quota, and
|
||||
/// everything else is honestly unknown.
|
||||
fn configured_model_cost_label(config: &Config, row: &ProviderDashboardRow, model: &str) -> String {
|
||||
let mut scoped = config.clone();
|
||||
if let Ok(identity) = config
|
||||
.resolve_persisted_provider_identity(Some(row.provider.as_str()), Some(&row.provider_id))
|
||||
{
|
||||
scoped.scope_to_provider_identity(&identity);
|
||||
}
|
||||
match crate::route_billing::for_route(&scoped, row.provider) {
|
||||
crate::route_billing::BillingPresentation::Subscription(label) => return label.to_string(),
|
||||
crate::route_billing::BillingPresentation::Local => return "local".to_string(),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(declared) = crate::provider_lake::configured_model_for_route(
|
||||
config,
|
||||
row.provider,
|
||||
&row.provider_id,
|
||||
&row.base_url,
|
||||
model,
|
||||
) {
|
||||
let card = codewhale_config::model_reference::ModelReferenceCard::from_offering(
|
||||
&declared.to_catalog_offering(),
|
||||
);
|
||||
return format!("{} · user estimate", card.price_label());
|
||||
}
|
||||
model_cost_label(row.provider, model)
|
||||
}
|
||||
|
||||
fn model_cost_label(provider: ApiProvider, model: &str) -> String {
|
||||
// OpenCode Go spends a subscription allowance, not per-token dollars, so
|
||||
// a catalog token price would misreport it as metered spend (#4526).
|
||||
@@ -1459,8 +1507,13 @@ fn model_cost_label(provider: ApiProvider, model: &str) -> String {
|
||||
/// model sorts first so the eye lands on what Enter would use; the rest are
|
||||
/// alphabetical. Falls back to the default route when the catalog has no rows
|
||||
/// for the provider, so the pane never renders empty.
|
||||
fn provider_pane_models(row: &ProviderDashboardRow, limit: usize) -> Vec<(String, String, bool)> {
|
||||
let mut models = crate::provider_lake::catalog_models_for_route(
|
||||
fn provider_pane_models(
|
||||
config: &Config,
|
||||
row: &ProviderDashboardRow,
|
||||
limit: usize,
|
||||
) -> Vec<(String, String, bool)> {
|
||||
let mut models = crate::provider_lake::configured_catalog_models_for_route(
|
||||
config,
|
||||
row.provider,
|
||||
&row.provider_id,
|
||||
&row.base_url,
|
||||
@@ -1481,7 +1534,7 @@ fn provider_pane_models(row: &ProviderDashboardRow, limit: usize) -> Vec<(String
|
||||
.map(|model| {
|
||||
let is_default =
|
||||
model.eq_ignore_ascii_case(&default) || model.eq_ignore_ascii_case(&wire);
|
||||
let price = model_cost_label(row.provider, &model);
|
||||
let price = configured_model_cost_label(config, row, &model);
|
||||
(model, price, is_default)
|
||||
})
|
||||
.collect()
|
||||
@@ -1649,6 +1702,7 @@ impl ProviderPickerView {
|
||||
ProviderListView::Catalog
|
||||
};
|
||||
let mut picker = Self {
|
||||
route_config: config.clone(),
|
||||
rows,
|
||||
selected_idx,
|
||||
stage: Stage::List,
|
||||
@@ -2184,7 +2238,8 @@ impl ProviderPickerView {
|
||||
route.logical_model.clone()
|
||||
};
|
||||
let row = &self.rows[self.selected_idx];
|
||||
let mut models = crate::provider_lake::catalog_models_for_route(
|
||||
let mut models = crate::provider_lake::configured_catalog_models_for_route(
|
||||
&self.route_config,
|
||||
provider,
|
||||
&row.provider_id,
|
||||
&row.base_url,
|
||||
@@ -2931,7 +2986,7 @@ impl ProviderPickerView {
|
||||
.fg(palette::TEXT_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
let pane_models = provider_pane_models(row, 8);
|
||||
let pane_models = provider_pane_models(&self.route_config, row, 8);
|
||||
let name_budget = usize::from(inner.width).saturating_sub(22).max(8);
|
||||
for (model, price, is_default) in &pane_models {
|
||||
let name = crate::tui::ui_text::truncate_line_to_width(model, name_budget);
|
||||
@@ -2951,7 +3006,8 @@ impl ProviderPickerView {
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
let total_models = crate::provider_lake::catalog_models_for_route(
|
||||
let total_models = crate::provider_lake::configured_catalog_models_for_route(
|
||||
&self.route_config,
|
||||
row.provider,
|
||||
&row.provider_id,
|
||||
&row.base_url,
|
||||
@@ -3510,7 +3566,6 @@ impl ProviderPickerView {
|
||||
// and every visible row is clickable, so record this frame's geometry
|
||||
// for hover + click handling.
|
||||
self.model_row_hitboxes.borrow_mut().clear();
|
||||
let model_provider = self.rows[self.selected_idx].provider;
|
||||
let mut lines: Vec<Line> = Vec::with_capacity(visible_rows);
|
||||
for (idx, model) in self
|
||||
.model_options
|
||||
@@ -3539,7 +3594,11 @@ impl ProviderPickerView {
|
||||
""
|
||||
};
|
||||
// Slice D: cost moved off the provider level down to the model.
|
||||
let price = model_cost_label(model_provider, model);
|
||||
let price = configured_model_cost_label(
|
||||
&self.route_config,
|
||||
&self.rows[self.selected_idx],
|
||||
model,
|
||||
);
|
||||
let mut spans = vec![
|
||||
Span::styled(format!(" {arrow} {model}"), label_style),
|
||||
Span::styled(
|
||||
@@ -8238,7 +8297,7 @@ mod tests {
|
||||
.iter()
|
||||
.find(|row| row.provider == ApiProvider::Deepseek)
|
||||
.expect("DeepSeek has a picker row");
|
||||
let models = provider_pane_models(row, 8);
|
||||
let models = provider_pane_models(&config, row, 8);
|
||||
assert!(!models.is_empty(), "models pane must never render empty");
|
||||
assert!(models.len() <= 8);
|
||||
let (first, _, first_default) = &models[0];
|
||||
|
||||
@@ -928,17 +928,7 @@ pub(crate) async fn apply_model_picker_choice(
|
||||
let mut resolved_model = model.clone();
|
||||
let mut route_base_url = config.deepseek_base_url();
|
||||
if !model_is_auto {
|
||||
let saved_provider_model = config
|
||||
.provider_config_for(app.api_provider)
|
||||
.and_then(|provider| provider.model.as_deref());
|
||||
match crate::route_runtime::resolve_route_candidate_with_context_metadata(
|
||||
app.api_provider,
|
||||
Some(&model),
|
||||
saved_provider_model,
|
||||
Some(config.deepseek_base_url()),
|
||||
config.context_window_for_provider_config(app.api_provider),
|
||||
None,
|
||||
) {
|
||||
match crate::route_runtime::resolve_runtime_route(config, app.api_provider, Some(&model)) {
|
||||
Ok(resolution) => {
|
||||
resolved_model = resolution.candidate.wire_model_id().as_str().to_string();
|
||||
route_base_url = resolution.candidate.endpoint().base_url.clone();
|
||||
@@ -3785,6 +3775,7 @@ pub(crate) fn apply_loaded_session_config_snapshot(
|
||||
&previous_workspace,
|
||||
);
|
||||
*config = next_config;
|
||||
app.configured_models = config.custom_models.clone().unwrap_or_default();
|
||||
crate::initialize_cloud_facts(config);
|
||||
app.refresh_notification_settings(config);
|
||||
Ok(respawn)
|
||||
|
||||
@@ -1013,17 +1013,7 @@ pub(crate) fn resolve_loaded_session_route(app: &mut App, config: &Config) {
|
||||
return;
|
||||
}
|
||||
|
||||
let saved_provider_model = config
|
||||
.provider_config_for(app.api_provider)
|
||||
.and_then(|provider| provider.model.as_deref());
|
||||
match crate::route_runtime::resolve_route_candidate_with_context_metadata(
|
||||
app.api_provider,
|
||||
Some(&app.model),
|
||||
saved_provider_model,
|
||||
Some(config.deepseek_base_url()),
|
||||
context_override,
|
||||
None,
|
||||
) {
|
||||
match crate::route_runtime::resolve_runtime_route(config, app.api_provider, Some(&app.model)) {
|
||||
Ok(resolution) => {
|
||||
app.set_active_route_resolution(
|
||||
resolution.candidate.endpoint().base_url.clone(),
|
||||
|
||||
Reference in new issue
Block a user