merge: provider-truth billing harvest
Composes the #4797 cost-truth repair (HEAD) with the provider-truth harvest (codex/v092-ptruth-harvest). Both lanes close truth-critical blockers; where they touched the same seam the rule applied was: one receipt type per job, classification computed from the dispatched receipt, and the fail-closed answer wherever the two disagreed. route_billing.rs - Kept the harvest's single `classify(provider, identity, base_url, product)` and its `capture_product`/`RouteProduct` credential truth. It fully subsumes the cost lane's `minimax_billing`, which only read `mode`; the harvest reads the same mode plus non-secret key-shape provenance and never opens the keyring. Dropped `minimax_billing`, `stepfun_billing`, `uses_zai_coding_plan`. - Added `subscription_plan` to the MiniMax plan modes so the cost lane's documented spelling is not silently discarded as unprovable. - `for_endpoint_without_config` is now a thin wrapper over `classify` with no identity and an unproven product, instead of a second copy of the endpoint rules. Same fail-closed contract, one implementation. - Moved the cost lane's endpoint gate into `classify`'s catch-all (`endpoint_shaped_payg_billing`): a first-party or aggregator provider on an unrecognized host is Unknown, not metered-by-provider-name (#4318). This also fixes the harvest's noted hole where an empty endpoint fell through to metered. - `billing_surface_for_dispatch` kept as-is (three live callers) and now benefits from the harvest's `for_route`. core/events.rs, core/engine.rs - `TurnRoute` carries both layers, documented at the definition because they are captured at different instants and answer different questions: `base_url`/`billing_product`/`provider_identity` are the DispatchedReceipt frozen at client-freeze (readable from TurnStarted), while `billing: Option<RouteBillingEnvelope>` is the wire-boundary envelope that must be structurally absent for an undispatched route. - The envelope's `billing_mode` is now classified from that same frozen receipt via `for_dispatched_receipt` rather than a second ambient `for_route` read, so the two halves cannot disagree. subagent mailbox/mod, subagent_routing - Kept the cost lane's `source_id` + `route: EffectiveRouteEnvelope` on `MailboxMessage::TokenUsage` and dropped the harvest's parallel `billing: Option<ChildBillingProvenance>` field. The envelope is the child's dispatch receipt: the client it ran on froze provider, identity, endpoint fingerprint, billing surface and billing mode at construction, and `RouteBillingMode` has the same variant set as `ChildBillingProvenance` plus strictly more evidence. Child provenance still wins; it just travels on the richer receipt. - The turn-end mailbox barrier (seal/drain/await before TurnComplete) and its exactly-once detached-child accounting are unchanged. tui/tool_routing.rs - Kept the cost lane's path: bill from the child's own `EffectiveRouteEnvelope`, rehydrated from the complete `child_*` metadata emitted by all three real producers (review, verify, rlm). The harvest's reader was explicitly unwired ("no tool producer emits the keys yet") and its parent-inheritance fallback is contradicted by the cost lane's tested contract (`legacy_child_usage_metadata_fails_closed_without_parent_route_fallback`), which is the stronger, fail-closed one: incomplete child metadata is Unknown and reported as missing spend, never inherited. - Consequently the harvest's `ActiveTurnMetadata` receipt mirror (`billing_identity`/`billing_product`/`billing_base_url` and `dispatched_receipt()`) had no production consumer and was removed; `TurnRoute::cost_envelope()` is the same receipt one layer down and is already consumed. Dead code after composition (no -D warnings, nothing silently kept) - `ChildBillingProvenance`, `static_subscription_label`, `for_child_route_receipt`, `ChildParentRoute`, `ChildRouteClaim` are now `#[cfg(test)]`-gated with a note at each definition explaining that the wired child receipt is `EffectiveRouteEnvelope`. Their tests are kept as the executable record of the serialization and identity-comparison contracts. Tests changed, and why - `child_route_billing_fails_closed_for_every_ambiguous_provider`: the cost lane expected Metered for PAYG aggregators and an exact subscription label for OpenaiCodex/OpencodeGo children. The harvest's `for_child_route` returns Unknown for every non-local cross-provider child without provenance. The harvest's contract is stronger — a provider name is not evidence of what a turn billed, and Unknown (unlike a subscription label) keeps the turn in `/cost`'s money coverage denominator — so the weaker expectations were updated, and a same-provider inheritance case was added. - `minimax_requires_an_explicit_saved_billing_mode`: expected label changed from the generic "MiniMax subscription plan" to the harvest's "MiniMax Token Plan quota", which names the actual product. docs/PROVIDERS.md: kept the cost lane's StepFun billing-route setup row and the harvest's MiniMax product-split row; the harvest's Moonshot product-split and K3-clamp rows auto-merged. Verification: route_billing 48, pricing 64, cost 87, receipt 157, subagent 507, subagent_routing 18, tool_routing 11, prompt_suggestion 23, engine 461, model_inventory 17, tui::app 383, ui::tests 653, config:: 458, mailbox 34 — all 0 failures. `cargo fmt --all -- --check` clean; CI clippy (--workspace --all-features --locked, five -A allows) clean; check-tui-locale-parity.py PASS. Note: `failed_paused_dispatch_preserves_app_checkpoint_state_and_engine_gate` overflows the default 2 MiB test stack under batch parallelism and passes with RUST_MIN_STACK=32M. It is an unmodified HEAD test and a pre-existing stack-depth papercut, not a merge regression.
This commit is contained in:
@@ -53,6 +53,7 @@ base_url = "https://api.deepseek.com/beta"
|
||||
# kimi-k2.7-code — default direct Moonshot/Kimi K2.7 model ID
|
||||
# k3 — Kimi Code membership K3 API model ID
|
||||
# kimi-for-coding — Kimi Code membership K2.7 compatibility ID
|
||||
# kimi-for-coding-highspeed — Kimi Code membership high-speed roster ID
|
||||
# gpt-4.1 — default generic OpenAI-compatible model ID
|
||||
# deepseek-ai/deepseek-v4-flash — default AtlasCloud model ID
|
||||
# deepseek-reasoner — default Wanjie Ark model ID
|
||||
|
||||
+545
-102
@@ -30,7 +30,10 @@ use subagent_limits::{resolve_subagent_api_timeout_secs, resolve_subagent_heartb
|
||||
mod models;
|
||||
pub use models::*;
|
||||
|
||||
const API_KEYRING_SENTINEL: &str = "__KEYRING__";
|
||||
/// Legacy placeholder written into `api_key` when the real credential lives in
|
||||
/// the secret store. It is not a credential and must never be treated as one —
|
||||
/// including by billing classification, which reads credential *shape* only.
|
||||
pub(crate) const API_KEYRING_SENTINEL: &str = "__KEYRING__";
|
||||
pub const DEFAULT_ZAI_PROVIDER_MAX_CONCURRENCY: usize = 3;
|
||||
pub const MAX_PROVIDER_REQUEST_CONCURRENCY: usize = 64;
|
||||
|
||||
@@ -435,13 +438,22 @@ pub struct ProviderCapability {
|
||||
pub resolved_model: String,
|
||||
/// Context window in tokens (the maximum input the model can accept).
|
||||
pub context_window: u32,
|
||||
/// Known output ceiling for this provider/model metadata path.
|
||||
/// Known output ceiling for this provider/model metadata path, when one is
|
||||
/// actually known.
|
||||
///
|
||||
/// This may be a documented exact-route maximum or a conservative/default
|
||||
/// ceiling when the route does not publish a maximum. It is metadata for
|
||||
/// diagnostics and CI policy; normal turns use a separate, more
|
||||
/// conservative request cap in the engine.
|
||||
pub max_output: u32,
|
||||
/// `None` means "this route publishes no output maximum we can stand
|
||||
/// behind" — for example the Kimi Code membership ids, whose limits live in
|
||||
/// the membership catalog rather than the static model catalogue. Unknown
|
||||
/// must stay unknown: callers may **not** substitute a placeholder ceiling,
|
||||
/// and in particular [`crate::route_budget`] does not clamp a requested
|
||||
/// `max_tokens` against an unknown compatibility cap.
|
||||
///
|
||||
/// When `Some`, the value is a documented exact-route maximum or a
|
||||
/// deliberately conservative provider ceiling (Anthropic's 64K floor, the
|
||||
/// Codex OAuth route). It is metadata for diagnostics and CI policy; normal
|
||||
/// turns use a separate, more conservative request cap in the engine.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_output: Option<u32>,
|
||||
/// Whether the provider+model supports thinking/reasoning mode.
|
||||
pub thinking_supported: bool,
|
||||
/// Whether the provider returns prompt-cache telemetry fields.
|
||||
@@ -496,8 +508,11 @@ pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> Provi
|
||||
// their 1M windows from models.rs rows (#3014).
|
||||
context_window: crate::models::context_window_for_model(resolved_model)
|
||||
.unwrap_or(200_000),
|
||||
max_output: crate::models::max_output_tokens_for_model(resolved_model)
|
||||
.unwrap_or(64_000),
|
||||
// 64K is the documented Anthropic Messages floor, so it stays a
|
||||
// known cap rather than an unknown.
|
||||
max_output: Some(
|
||||
crate::models::max_output_tokens_for_model(resolved_model).unwrap_or(64_000),
|
||||
),
|
||||
thinking_supported: crate::models::model_supports_reasoning(resolved_model),
|
||||
cache_telemetry_supported: matches!(provider, ApiProvider::Anthropic),
|
||||
request_payload_mode: RequestPayloadMode::AnthropicMessages,
|
||||
@@ -510,10 +525,11 @@ pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> Provi
|
||||
provider,
|
||||
resolved_model: resolved_model.to_string(),
|
||||
context_window: OPENAI_CODEX_EFFECTIVE_CONTEXT_WINDOW_TOKENS,
|
||||
// The OAuth cache does not publish an output ceiling. Keep the
|
||||
// compatibility capability conservative instead of inheriting the
|
||||
// public API model's output limit.
|
||||
max_output: 4096,
|
||||
// The OAuth cache does not publish an output ceiling. This 4K is a
|
||||
// deliberate, long-standing product decision for the Codex route
|
||||
// (not a fallback): keep the compatibility capability conservative
|
||||
// instead of inheriting the public API model's output limit.
|
||||
max_output: Some(4096),
|
||||
thinking_supported: true,
|
||||
cache_telemetry_supported: false,
|
||||
request_payload_mode: RequestPayloadMode::Responses,
|
||||
@@ -532,7 +548,9 @@ pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> Provi
|
||||
resolved_model: resolved_model.to_string(),
|
||||
context_window: crate::models::context_window_for_model(resolved_model)
|
||||
.unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS),
|
||||
max_output: crate::models::max_output_tokens_for_model(resolved_model).unwrap_or(4096),
|
||||
// No documented output maximum for these routes: stay unknown so
|
||||
// no compatibility clamp is applied downstream.
|
||||
max_output: crate::models::max_output_tokens_for_model(resolved_model),
|
||||
thinking_supported: crate::models::model_supports_reasoning(resolved_model),
|
||||
cache_telemetry_supported: false,
|
||||
request_payload_mode: RequestPayloadMode::ChatCompletions,
|
||||
@@ -546,7 +564,9 @@ pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> Provi
|
||||
resolved_model: resolved_model.to_string(),
|
||||
context_window: crate::models::context_window_for_model(resolved_model)
|
||||
.unwrap_or(crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS),
|
||||
max_output: crate::models::max_output_tokens_for_model(resolved_model).unwrap_or(4096),
|
||||
// No documented output maximum for these routes: stay unknown so
|
||||
// no compatibility clamp is applied downstream.
|
||||
max_output: crate::models::max_output_tokens_for_model(resolved_model),
|
||||
thinking_supported: crate::models::model_supports_reasoning(resolved_model),
|
||||
cache_telemetry_supported: false,
|
||||
request_payload_mode: RequestPayloadMode::ChatCompletions,
|
||||
@@ -586,10 +606,17 @@ pub fn provider_capability(provider: ApiProvider, resolved_model: &str) -> Provi
|
||||
|
||||
// Max output tokens: official DeepSeek V4 API metadata lists 384K;
|
||||
// runtime request caps remain separate and more conservative.
|
||||
//
|
||||
// Everything else answers from the static model catalogue, and answers
|
||||
// `None` when the catalogue has no row. That is the truthful state for
|
||||
// membership routes such as the `kimi-for-coding` family, whose ceilings
|
||||
// are owned by the membership catalog. It must not become a placeholder
|
||||
// number: a fabricated 4K here silently clamped offline membership routes
|
||||
// to 4K output via `route_budget`.
|
||||
let max_output = if is_v4_pro || is_v4_flash {
|
||||
384_000
|
||||
Some(384_000)
|
||||
} else {
|
||||
crate::models::max_output_tokens_for_model(resolved_model).unwrap_or(4096)
|
||||
crate::models::max_output_tokens_for_model(resolved_model)
|
||||
};
|
||||
|
||||
// Thinking support: V4 models support thinking on all providers, but
|
||||
@@ -2422,17 +2449,67 @@ pub struct Config {
|
||||
#[serde(skip)]
|
||||
pub exec_policy_engine: ExecPolicyEngine,
|
||||
|
||||
/// Whether the active provider endpoint was replaced by an environment
|
||||
/// override during [`Config::load`].
|
||||
/// Receipt describing what the environment layer did to this config's
|
||||
/// effective base URL.
|
||||
///
|
||||
/// This provenance cannot be reconstructed from the merged provider table:
|
||||
/// environment overrides are written into the same `base_url` field as
|
||||
/// file-owned routes. Keep the receipt so a saved provider/root key (or a
|
||||
/// configured `api_key_env`) cannot silently follow an env-selected custom
|
||||
/// host. Directly constructed configs and file-owned endpoints retain the
|
||||
/// established route-bound credential behavior.
|
||||
/// host, and so a cross-provider child cannot borrow an ambient generic
|
||||
/// host that was never addressed to it.
|
||||
#[serde(skip)]
|
||||
pub(crate) active_base_url_env_route: Option<(ApiProvider, String)>,
|
||||
pub(crate) base_url_env_receipt: BaseUrlEnvReceipt,
|
||||
|
||||
/// Who owns the legacy root `base_url` field.
|
||||
///
|
||||
/// `Deepseek` and `DeepseekCN` are two identities that share one legacy
|
||||
/// root field, so the field alone cannot say whether it is a user's
|
||||
/// file-owned endpoint (shared by both, as it always has been) or a
|
||||
/// `CODEWHALE_BASE_URL`/`DEEPSEEK_BASE_URL` value that
|
||||
/// [`apply_env_overrides`] addressed to exactly one of them.
|
||||
///
|
||||
/// [`BaseUrlEnvReceipt::Unrecorded`] is the file-owned case and keeps the
|
||||
/// legacy shared behavior.
|
||||
#[serde(skip)]
|
||||
pub(crate) root_base_url_owner: BaseUrlEnvReceipt,
|
||||
}
|
||||
|
||||
/// What the environment layer decided about the generic
|
||||
/// `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL` override.
|
||||
///
|
||||
/// The distinction that matters is between "no receipt" and "a receipt saying
|
||||
/// nobody owns it". They are not the same state and must not collapse: a
|
||||
/// missing receipt is a config that never passed through the environment
|
||||
/// layer, while [`BaseUrlEnvReceipt::NoOwner`] is a positive statement that a
|
||||
/// higher-precedence layer took the endpoint away from the environment.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) enum BaseUrlEnvReceipt {
|
||||
/// The environment layer never ran for this config — directly constructed
|
||||
/// configs, embedded profiles, and unit-test fixtures. These keep the
|
||||
/// established global fallback: the generic override applies to whatever
|
||||
/// route is asked about.
|
||||
#[default]
|
||||
Unrecorded,
|
||||
/// The environment layer ran and no route owns the generic override —
|
||||
/// either it was absent, or a higher-precedence file layer (a managed
|
||||
/// overlay) supplied/reselected the effective route's endpoint. No route,
|
||||
/// active or pinned, may borrow the ambient generic host.
|
||||
NoOwner,
|
||||
/// The environment layer ran and addressed the override to exactly this
|
||||
/// `(provider, identity)`. Only that route resolves it; every other route
|
||||
/// falls through to its own default.
|
||||
Route(ApiProvider, String),
|
||||
}
|
||||
|
||||
impl BaseUrlEnvReceipt {
|
||||
/// Whether `(provider, identity)` is the route this receipt names.
|
||||
fn owns(&self, provider: ApiProvider, identity: &str) -> bool {
|
||||
match self {
|
||||
Self::Route(owner, owner_identity) => *owner == provider && owner_identity == identity,
|
||||
Self::Unrecorded | Self::NoOwner => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
@@ -4337,6 +4414,21 @@ impl Config {
|
||||
self.selects_literal_custom_provider() && !self.has_literal_custom_provider_table()
|
||||
}
|
||||
|
||||
/// Whether `identity` names a custom route that this config can resolve.
|
||||
///
|
||||
/// Either an exact `[providers.<name>]` custom table, or the legacy
|
||||
/// root-field literal `custom` route. Anything else — an empty key, a
|
||||
/// removed table, a built-in provider name — is an unresolvable custom
|
||||
/// identity and endpoint resolution must fail closed on it.
|
||||
///
|
||||
/// The predicate that pins that contract for the regression suite; the
|
||||
/// resolver itself fails closed without consulting it.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn custom_identity_is_resolvable(&self, identity: &str) -> bool {
|
||||
self.custom_provider_entry_for_identity(identity).is_some()
|
||||
|| (identity_is_literal_custom(identity) && self.uses_legacy_literal_custom_route())
|
||||
}
|
||||
|
||||
pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {
|
||||
let providers = self.providers.as_ref()?;
|
||||
// The custom provider's config lives in the flatten map, keyed by the
|
||||
@@ -4528,8 +4620,10 @@ impl Config {
|
||||
.clone_from(&fresh.fallback_providers);
|
||||
self.retry.clone_from(&fresh.retry);
|
||||
self.providers.clone_from(&fresh.providers);
|
||||
self.active_base_url_env_route
|
||||
.clone_from(&fresh.active_base_url_env_route);
|
||||
self.base_url_env_receipt
|
||||
.clone_from(&fresh.base_url_env_receipt);
|
||||
self.root_base_url_owner
|
||||
.clone_from(&fresh.root_base_url_owner);
|
||||
self.reasoning_effort_inferred_from_legacy_alias =
|
||||
fresh.reasoning_effort_inferred_from_legacy_alias;
|
||||
self.migrated_deepseek_model_alias
|
||||
@@ -4798,24 +4892,70 @@ impl Config {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Return the configured API base URL (normalized).
|
||||
/// Return the configured API base URL (normalized) for the selected route.
|
||||
#[must_use]
|
||||
pub fn deepseek_base_url(&self) -> String {
|
||||
let provider = self.api_provider();
|
||||
let provider_base = self
|
||||
.provider_config_string_with_runtime_fallback(provider, |entry| entry.base_url.clone());
|
||||
self.base_url_for_route(self.api_provider())
|
||||
}
|
||||
|
||||
/// Resolve `provider`'s endpoint from the layers that provider actually
|
||||
/// owns, in precedence order:
|
||||
///
|
||||
/// 1. its own `[providers.<table>]` entry (including in-memory runtime
|
||||
/// overrides), plus the legacy root `base_url` where that field still
|
||||
/// belongs to the route;
|
||||
/// 2. its provider-specific environment contract (`MOONSHOT_BASE_URL`,
|
||||
/// `OPENAI_BASE_URL`, ...), which names exactly one provider and is
|
||||
/// therefore sound to read for a route that is not the session's;
|
||||
/// 3. the generic `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL` override, but
|
||||
/// only when this config is still the route that override selected;
|
||||
/// 4. the provider's canonical default endpoint.
|
||||
///
|
||||
/// Step 3 is why this is identity-aware instead of a bare env read.
|
||||
/// `CODEWHALE_BASE_URL` is documented as "base URL for the active
|
||||
/// provider", and [`apply_env_overrides`] writes it onto exactly one
|
||||
/// provider entry. Every cross-provider construction seam — a pinned
|
||||
/// subagent/fleet child, the per-turn auto-router, tool routing, a picker
|
||||
/// preview — works by cloning the session config and re-pointing
|
||||
/// `provider`, so without the ownership check a Moonshot/Z.ai/MiniMax
|
||||
/// child in a DeepSeek session would silently inherit the DeepSeek host
|
||||
/// and dispatch a pinned model to the wrong vendor.
|
||||
pub(crate) fn base_url_for_route(&self, provider: ApiProvider) -> String {
|
||||
self.base_url_for_route_identity(provider, &self.provider_identity_for(provider))
|
||||
}
|
||||
|
||||
/// [`Config::base_url_for_route`] for an explicitly named identity.
|
||||
///
|
||||
/// Named custom routes are resolved by this `identity` — the
|
||||
/// `[providers.<name>]` table key — and never by whichever custom route
|
||||
/// the session happens to be on. An identity that names no custom table
|
||||
/// fails closed to the descriptor placeholder rather than borrowing the
|
||||
/// active custom host.
|
||||
pub(crate) fn base_url_for_route_identity(
|
||||
&self,
|
||||
provider: ApiProvider,
|
||||
identity: &str,
|
||||
) -> String {
|
||||
let provider_base = if provider == ApiProvider::Custom {
|
||||
self.custom_provider_entry_for_identity(identity)
|
||||
.and_then(|entry| entry.base_url.clone())
|
||||
} else {
|
||||
self.provider_config_string_with_runtime_fallback(provider, |entry| {
|
||||
entry.base_url.clone()
|
||||
})
|
||||
};
|
||||
// Root `base_url` is normally the legacy DeepSeek field. NvidiaNim has
|
||||
// a back-compat sniff (integrate.api.nvidia.com), and the literal
|
||||
// `provider = "custom"` legacy shape retains its root endpoint. Named
|
||||
// custom providers always read their own `[providers.<name>]` table.
|
||||
let root_base = match provider {
|
||||
ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),
|
||||
ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
|
||||
self.route_owned_root_base_url(provider, identity)
|
||||
}
|
||||
ApiProvider::DeepseekAnthropic => None,
|
||||
ApiProvider::NvidiaNim => self
|
||||
.base_url
|
||||
.as_ref()
|
||||
.filter(|base| base.contains("integrate.api.nvidia.com"))
|
||||
.cloned(),
|
||||
.route_owned_root_base_url(provider, identity)
|
||||
.filter(|base| base.contains("integrate.api.nvidia.com")),
|
||||
ApiProvider::Openai
|
||||
| ApiProvider::Anthropic
|
||||
| ApiProvider::Openmodel
|
||||
@@ -4849,25 +4989,40 @@ impl Config {
|
||||
| ApiProvider::Meta
|
||||
| ApiProvider::Xai
|
||||
| ApiProvider::Telecomjs => None,
|
||||
ApiProvider::Custom if self.uses_legacy_literal_custom_route() => self.base_url.clone(),
|
||||
// The legacy root endpoint belongs to the literal `custom`
|
||||
// identity only. A named custom child asking about its own table
|
||||
// must not inherit it.
|
||||
ApiProvider::Custom
|
||||
if identity_is_literal_custom(identity)
|
||||
&& self.uses_legacy_literal_custom_route() =>
|
||||
{
|
||||
self.route_owned_root_base_url(provider, identity)
|
||||
}
|
||||
// Named custom routes read their base URL from `provider_base`.
|
||||
ApiProvider::Custom => None,
|
||||
};
|
||||
let configured_base_url = provider_base.or(root_base);
|
||||
// A provider-scoped endpoint variable names exactly one provider, so it
|
||||
// resolves for the selected identity whether or not that identity is
|
||||
// the session route. `apply_env_overrides` only merges these into the
|
||||
// active provider's table, which is why a non-active route has to read
|
||||
// them here instead of relying on the merged config.
|
||||
let configured_base_url = provider_base
|
||||
.or(root_base)
|
||||
.or_else(|| provider_env_base_url_override(provider));
|
||||
let base = if provider == ApiProvider::XiaomiMimo {
|
||||
let config_api_key = self
|
||||
.provider_config_for(provider)
|
||||
.and_then(|provider| provider.api_key.as_deref());
|
||||
.and_then(|entry| entry.api_key.as_deref());
|
||||
let mode = self
|
||||
.provider_config_for(provider)
|
||||
.and_then(|provider| provider.mode.as_deref());
|
||||
.and_then(|entry| entry.mode.as_deref());
|
||||
let env_api_key =
|
||||
xiaomi_mimo_env_api_key_for_runtime(mode, configured_base_url.as_deref());
|
||||
let api_key = config_api_key.or(env_api_key.as_deref());
|
||||
resolve_xiaomi_mimo_base_url(configured_base_url, api_key, mode)
|
||||
} else {
|
||||
configured_base_url
|
||||
.or_else(env_base_url_override)
|
||||
.or_else(|| self.route_owned_generic_env_base_url(provider, identity))
|
||||
.unwrap_or_else(|| {
|
||||
match provider {
|
||||
ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
|
||||
@@ -4886,7 +5041,7 @@ impl Config {
|
||||
ApiProvider::Arcee => DEFAULT_ARCEE_BASE_URL,
|
||||
ApiProvider::Moonshot => {
|
||||
if self
|
||||
.provider_config()
|
||||
.provider_config_for(provider)
|
||||
.is_some_and(provider_config_uses_kimi_imported_token)
|
||||
{
|
||||
DEFAULT_KIMI_CODE_BASE_URL
|
||||
@@ -4929,20 +5084,82 @@ impl Config {
|
||||
normalize_base_url(&base)
|
||||
}
|
||||
|
||||
/// The generic `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL` override, but
|
||||
/// only for the route that override actually selected.
|
||||
///
|
||||
/// [`apply_env_overrides`] records the owning `(provider, identity)` in
|
||||
/// [`Config::base_url_env_receipt`] at load time and writes the value onto
|
||||
/// that provider's own entry. A config later re-pointed at another identity
|
||||
/// is a different route: it must fall through to that provider's own
|
||||
/// default rather than borrow the session host.
|
||||
fn route_owned_generic_env_base_url(
|
||||
&self,
|
||||
provider: ApiProvider,
|
||||
identity: &str,
|
||||
) -> Option<String> {
|
||||
match &self.base_url_env_receipt {
|
||||
// Never went through the environment layer: keep the established
|
||||
// global fallback so directly constructed configs are unaffected.
|
||||
BaseUrlEnvReceipt::Unrecorded => env_base_url_override(),
|
||||
// A positive "nobody owns it" — a managed overlay took the
|
||||
// endpoint. No route may borrow the ambient generic host.
|
||||
BaseUrlEnvReceipt::NoOwner => None,
|
||||
BaseUrlEnvReceipt::Route(..) => self
|
||||
.base_url_env_receipt
|
||||
.owns(provider, identity)
|
||||
.then(env_base_url_override)
|
||||
.flatten(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The legacy root `base_url`, unless an environment write addressed it to
|
||||
/// a different route.
|
||||
///
|
||||
/// `Deepseek` and `DeepseekCN` share this one field. A user who writes
|
||||
/// `base_url` in their config file still means it for both identities —
|
||||
/// that legacy compatibility is preserved by `None` ownership. But when
|
||||
/// [`apply_env_overrides`] wrote the value, it wrote it for exactly the
|
||||
/// identity that was active, and a pinned child of the sibling identity
|
||||
/// must not inherit it.
|
||||
fn route_owned_root_base_url(&self, provider: ApiProvider, identity: &str) -> Option<String> {
|
||||
let root = self.base_url.clone()?;
|
||||
match &self.root_base_url_owner {
|
||||
// File-owned legacy root: shared by every route that reads it, as
|
||||
// it always has been.
|
||||
BaseUrlEnvReceipt::Unrecorded => Some(root),
|
||||
// An environment write that a higher-precedence layer has since
|
||||
// taken authority over. It belongs to no route.
|
||||
BaseUrlEnvReceipt::NoOwner => None,
|
||||
BaseUrlEnvReceipt::Route(..) => self
|
||||
.root_base_url_owner
|
||||
.owns(provider, identity)
|
||||
.then_some(root),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a named custom provider's table by explicit identity.
|
||||
///
|
||||
/// Fails closed: an empty identity, or one that names no
|
||||
/// `[providers.<name>]` custom table, resolves to nothing instead of
|
||||
/// falling back to whichever custom route the session is currently on.
|
||||
fn custom_provider_entry_for_identity(&self, identity: &str) -> Option<&ProviderConfig> {
|
||||
let key = identity.trim();
|
||||
if key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.providers.as_ref()?.custom_provider_config(key)
|
||||
}
|
||||
|
||||
fn active_provider_preserves_custom_base_url_model(&self) -> bool {
|
||||
self.provider_uses_custom_endpoint(self.api_provider())
|
||||
}
|
||||
|
||||
/// Whether `provider`'s effective endpoint is a custom host rather than its
|
||||
/// shipped one. Resolved through the same identity-aware resolver the
|
||||
/// client is built from, so this predicate cannot disagree with the URL the
|
||||
/// request will actually be sent to.
|
||||
pub(crate) fn provider_uses_custom_endpoint(&self, provider: ApiProvider) -> bool {
|
||||
let base_url = if provider == self.api_provider() {
|
||||
self.deepseek_base_url()
|
||||
} else {
|
||||
self.provider_config_string_with_runtime_fallback(provider, |entry| {
|
||||
entry.base_url.clone()
|
||||
})
|
||||
.unwrap_or_else(|| default_base_url_for_provider(provider).to_string())
|
||||
};
|
||||
provider_preserves_custom_base_url_model(provider, &base_url)
|
||||
provider_preserves_custom_base_url_model(provider, &self.base_url_for_route(provider))
|
||||
}
|
||||
|
||||
/// Whether file-owned credential slots are bound to `provider`'s
|
||||
@@ -4968,36 +5185,46 @@ impl Config {
|
||||
return false;
|
||||
}
|
||||
let identity = self.provider_identity_for(provider);
|
||||
if self
|
||||
.active_base_url_env_route
|
||||
.as_ref()
|
||||
.is_some_and(|(owner, owner_id)| *owner == provider && owner_id == &identity)
|
||||
{
|
||||
if self.base_url_env_receipt.owns(provider, &identity) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// A generic forwarded base URL remains the runtime fallback after an
|
||||
// in-session provider switch. It owns the new route only when that
|
||||
// provider has no explicit file/in-memory endpoint of its own.
|
||||
env_base_url_override().is_some()
|
||||
&& self.configured_base_url_for_provider(provider).is_none()
|
||||
// Below the receipt, the environment can still supply the endpoint for
|
||||
// a route that has none of its own. A provider-scoped variable names
|
||||
// exactly one provider, so it always owns that route's endpoint. The
|
||||
// generic variable only does so while no receipt has said otherwise —
|
||||
// once a receipt exists and does not name this route,
|
||||
// `route_owned_generic_env_base_url` refuses it, so claiming env
|
||||
// ownership here would contradict the URL actually resolved.
|
||||
if self.configured_base_url_for_provider(provider).is_some() {
|
||||
return false;
|
||||
}
|
||||
provider_env_base_url_override(provider).is_some()
|
||||
|| (matches!(self.base_url_env_receipt, BaseUrlEnvReceipt::Unrecorded)
|
||||
&& env_base_url_override().is_some())
|
||||
}
|
||||
|
||||
/// The endpoint `provider` owns through a file or in-memory layer, before
|
||||
/// the environment layer is consulted.
|
||||
///
|
||||
/// The legacy root field is read through
|
||||
/// [`Config::route_owned_root_base_url`] so an environment write addressed
|
||||
/// to one identity is not mistaken for the sibling identity's configured
|
||||
/// endpoint.
|
||||
fn configured_base_url_for_provider(&self, provider: ApiProvider) -> Option<String> {
|
||||
let identity = self.provider_identity_for(provider);
|
||||
let provider_base = self
|
||||
.provider_config_string_with_runtime_fallback(provider, |entry| entry.base_url.clone());
|
||||
match provider {
|
||||
ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
|
||||
provider_base.or_else(|| self.base_url.clone())
|
||||
provider_base.or_else(|| self.route_owned_root_base_url(provider, &identity))
|
||||
}
|
||||
ApiProvider::NvidiaNim => provider_base.or_else(|| {
|
||||
self.base_url
|
||||
.as_ref()
|
||||
self.route_owned_root_base_url(provider, &identity)
|
||||
.filter(|base| base.contains("integrate.api.nvidia.com"))
|
||||
.cloned()
|
||||
}),
|
||||
ApiProvider::Custom if self.uses_legacy_literal_custom_route() => {
|
||||
provider_base.or_else(|| self.base_url.clone())
|
||||
provider_base.or_else(|| self.route_owned_root_base_url(provider, &identity))
|
||||
}
|
||||
_ => provider_base,
|
||||
}
|
||||
@@ -5006,19 +5233,14 @@ impl Config {
|
||||
|
||||
/// Whether model ids for `provider` belong to the configured endpoint.
|
||||
///
|
||||
/// The active provider uses the fully resolved URL (including legacy root
|
||||
/// fields and environment overrides). Inactive picker routes can only own
|
||||
/// a custom namespace through their provider-scoped `base_url`.
|
||||
/// Every route — active or pinned — is judged on the endpoint it will
|
||||
/// actually be dispatched to, so a pinned child cannot canonicalize model
|
||||
/// ids for a host that owns its own namespace (or pass through ids on a
|
||||
/// route that resolves to a canonical endpoint). The resolver behind
|
||||
/// [`Config::provider_uses_custom_endpoint`] is identity-aware, so this no
|
||||
/// longer risks attributing the session's endpoint to another provider.
|
||||
pub(crate) fn model_ids_pass_through_for_provider(&self, provider: ApiProvider) -> bool {
|
||||
if provider_passes_model_through(provider) {
|
||||
return true;
|
||||
}
|
||||
if provider == self.api_provider() {
|
||||
return self.active_provider_preserves_custom_base_url_model();
|
||||
}
|
||||
self.provider_config_for(provider)
|
||||
.and_then(|entry| entry.base_url.as_deref())
|
||||
.is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))
|
||||
provider_passes_model_through(provider) || self.provider_uses_custom_endpoint(provider)
|
||||
}
|
||||
|
||||
pub(crate) fn model_ids_pass_through(&self) -> bool {
|
||||
@@ -6383,7 +6605,18 @@ fn apply_env_overrides_unlocked(config: &mut Config) {
|
||||
if let Ok(value) = codewhale_env_var("CODEWHALE_BASE_URL", "DEEPSEEK_BASE_URL") {
|
||||
match config.api_provider() {
|
||||
ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
|
||||
// DeepSeek and DeepSeek-CN share this one legacy root field.
|
||||
// Record which of them the environment addressed so the
|
||||
// sibling identity cannot inherit the value, while a
|
||||
// file-owned root (no owner recorded) stays shared.
|
||||
config.base_url = Some(value);
|
||||
// Resolve the owner *after* the write: the root value is one
|
||||
// of the inputs `api_provider()` sniffs, so the effective
|
||||
// identity is the post-write one, matching the receipt
|
||||
// recorded at the end of this function.
|
||||
let owner = config.api_provider();
|
||||
config.root_base_url_owner =
|
||||
BaseUrlEnvReceipt::Route(owner, config.provider_identity_for(owner));
|
||||
}
|
||||
ApiProvider::DeepseekAnthropic => {
|
||||
config
|
||||
@@ -7295,10 +7528,16 @@ fn apply_env_overrides_unlocked(config: &mut Config) {
|
||||
{
|
||||
config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));
|
||||
}
|
||||
config.active_base_url_env_route = active_base_url_from_env.then(|| {
|
||||
// Always leave a receipt: "the environment layer ran and nobody owns the
|
||||
// base URL" is a different, stronger statement than "no receipt", and only
|
||||
// the explicit form stops a pinned cross-provider child from treating the
|
||||
// ambient generic host as a global fallback.
|
||||
config.base_url_env_receipt = if active_base_url_from_env {
|
||||
let provider = config.api_provider();
|
||||
(provider, config.provider_identity_for(provider))
|
||||
});
|
||||
BaseUrlEnvReceipt::Route(provider, config.provider_identity_for(provider))
|
||||
} else {
|
||||
BaseUrlEnvReceipt::NoOwner
|
||||
};
|
||||
}
|
||||
|
||||
fn normalize_model_config(config: &mut Config) {
|
||||
@@ -7474,6 +7713,13 @@ pub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a provider identity key is the historical literal `custom`.
|
||||
fn identity_is_literal_custom(identity: &str) -> bool {
|
||||
identity
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(ApiProvider::Custom.as_str())
|
||||
}
|
||||
|
||||
fn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool {
|
||||
entry
|
||||
.base_url
|
||||
@@ -7481,10 +7727,6 @@ fn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderCo
|
||||
.is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url))
|
||||
}
|
||||
|
||||
fn default_base_url_for_provider(provider: ApiProvider) -> &'static str {
|
||||
provider.default_base_url()
|
||||
}
|
||||
|
||||
fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
|
||||
let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
|
||||
if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
|
||||
@@ -7729,6 +7971,23 @@ pub(crate) fn is_exact_known_zai_reasoning_route(
|
||||
&& model.trim().eq_ignore_ascii_case(ZAI_GLM_5_1_MODEL))
|
||||
}
|
||||
|
||||
/// MiniMax's own hosted routes, for both wire dialects.
|
||||
///
|
||||
/// Kept as a pure string predicate so a dispatch receipt can be judged without
|
||||
/// a `Config`, and shared with billing classification so a MiniMax-compatible
|
||||
/// gateway cannot inherit the first-party PAYG/Token Plan duality. Both the
|
||||
/// `.io` and `.com` hosts are first-party; anything else is a gateway.
|
||||
#[must_use]
|
||||
pub(crate) fn minimax_base_url_is_supported_direct(base_url: &str) -> bool {
|
||||
codewhale_config::provider::is_exact_minimax_chat_route(
|
||||
codewhale_config::ProviderKind::Minimax,
|
||||
base_url,
|
||||
) || codewhale_config::provider::is_exact_minimax_anthropic_route(
|
||||
codewhale_config::ProviderKind::MinimaxAnthropic,
|
||||
base_url,
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a route is exactly MiniMax-M3 on the first-party OpenAI-compatible
|
||||
/// Chat API. Compatible gateways and the Anthropic Messages route retain
|
||||
/// their own token-limit dialects.
|
||||
@@ -7772,11 +8031,52 @@ pub(crate) fn minimax_m3_route_uses_max_completion_tokens(
|
||||
is_exact_minimax_m3_route(provider, base_url, model)
|
||||
}
|
||||
|
||||
/// Fail closed on known-bad K3 model/endpoint pairings (#4687).
|
||||
/// The Kimi Code membership roster, as one fact.
|
||||
///
|
||||
/// - Reject Claude Code's `k3[1m]` context hint as a Kimi Code API model id.
|
||||
/// - Reject `kimi-k3` on the exact Kimi Code membership endpoint (use `k3`).
|
||||
/// - Reject bare `k3` on the exact Moonshot direct platform endpoint (use `kimi-k3`).
|
||||
/// The picker offers these ids, `validate_kimi_code_api_model_id` accepts them
|
||||
/// on the membership endpoint and rejects them on the direct platform, and the
|
||||
/// model picker labels them as plan routes. Those sites previously kept
|
||||
/// independent literal lists and had already drifted (`kimi-for-coding` was
|
||||
/// missing from the picker label), so the roster lives here and nowhere else.
|
||||
pub(crate) const KIMI_CODE_MEMBERSHIP_MODELS: [&str; 3] = [
|
||||
KIMI_CODE_K3_MODEL,
|
||||
DEFAULT_KIMI_CODE_MODEL,
|
||||
KIMI_CODE_HIGHSPEED_MODEL,
|
||||
];
|
||||
|
||||
/// Whether `model` is a Kimi Code membership model id.
|
||||
///
|
||||
/// The single membership-roster predicate. Callers that need to name the
|
||||
/// product — output-ceiling provenance, picker rosters, setup validation, and
|
||||
/// the model picker's route label — must use this rather than re-listing ids.
|
||||
#[must_use]
|
||||
pub(crate) fn is_kimi_code_membership_model(model: &str) -> bool {
|
||||
let model = model.trim();
|
||||
KIMI_CODE_MEMBERSHIP_MODELS
|
||||
.iter()
|
||||
.any(|id| model.eq_ignore_ascii_case(id))
|
||||
}
|
||||
|
||||
/// The Moonshot direct-platform roster, as one fact. Mirror of
|
||||
/// [`KIMI_CODE_MEMBERSHIP_MODELS`] for the pay-as-you-go product.
|
||||
pub(crate) const MOONSHOT_DIRECT_PLATFORM_MODELS: [&str; 3] = [
|
||||
MOONSHOT_KIMI_K3_MODEL,
|
||||
DEFAULT_MOONSHOT_MODEL,
|
||||
MOONSHOT_KIMI_K2_6_MODEL,
|
||||
];
|
||||
|
||||
/// Fail closed on known-bad model/endpoint pairings (#4687).
|
||||
///
|
||||
/// The two canonical endpoints each enforce their explicit model set:
|
||||
///
|
||||
/// - Exact Kimi Code membership endpoint (api.kimi.com/coding/v1): reject
|
||||
/// Claude Code's `k3[1m]` context hint and the known direct-platform ids
|
||||
/// (`kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`); the managed roster (`k3`,
|
||||
/// `kimi-for-coding`, `kimi-for-coding-highspeed`) is accepted.
|
||||
/// - Exact Moonshot direct platform endpoint (api.moonshot.ai/v1): reject the
|
||||
/// membership-only ids (`k3`, `kimi-for-coding`,
|
||||
/// `kimi-for-coding-highspeed`); they are membership products, not
|
||||
/// direct-platform catalog models.
|
||||
///
|
||||
/// Custom Moonshot-compatible gateways are left alone: only the two canonical
|
||||
/// endpoints enforce the documented model IDs.
|
||||
@@ -7800,27 +8100,142 @@ pub(crate) fn validate_kimi_code_api_model_id(
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if model.eq_ignore_ascii_case(MOONSHOT_KIMI_K3_MODEL) {
|
||||
return Err(
|
||||
"Kimi Code membership route (api.kimi.com/coding/v1) does not accept model = \"kimi-k3\". Use model = \"k3\" for this base_url. Direct Moonshot pay-as-you-go uses base_url = \"https://api.moonshot.ai/v1\" with model = \"kimi-k3\"."
|
||||
.to_string(),
|
||||
);
|
||||
for direct_id in MOONSHOT_DIRECT_PLATFORM_MODELS {
|
||||
if model.eq_ignore_ascii_case(direct_id) {
|
||||
return Err(format!(
|
||||
"Kimi Code membership route (api.kimi.com/coding/v1) does not accept model = \"{model}\": it is a direct Moonshot platform id. Use a Kimi Code membership model (\"k3\", \"kimi-for-coding\", or \"kimi-for-coding-highspeed\") for this base_url. Direct Moonshot pay-as-you-go uses base_url = \"https://api.moonshot.ai/v1\" with model = \"{direct_id}\"."
|
||||
));
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if moonshot_base_url_is_exact_direct_platform(base_url)
|
||||
&& model.eq_ignore_ascii_case(KIMI_CODE_K3_MODEL)
|
||||
{
|
||||
return Err(
|
||||
"Moonshot direct route (api.moonshot.ai/v1) does not accept bare model = \"k3\". Use model = \"kimi-k3\" for this base_url. Kimi Code membership uses base_url = \"https://api.kimi.com/coding/v1\" with model = \"k3\"."
|
||||
.to_string(),
|
||||
);
|
||||
if moonshot_base_url_is_exact_direct_platform(base_url) {
|
||||
for membership_id in KIMI_CODE_MEMBERSHIP_MODELS {
|
||||
if model.eq_ignore_ascii_case(membership_id) {
|
||||
return Err(format!(
|
||||
"Moonshot direct route (api.moonshot.ai/v1) does not accept model = \"{model}\": it is a Kimi Code membership model id, not a direct-platform catalog model. Kimi Code membership uses base_url = \"https://api.kimi.com/coding/v1\" with model = \"{membership_id}\"; direct Moonshot pay-as-you-go K3 uses model = \"kimi-k3\"."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod kimi_code_pairing_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn membership_roster_passes_on_kimi_code_endpoint() {
|
||||
for model in [
|
||||
KIMI_CODE_K3_MODEL,
|
||||
DEFAULT_KIMI_CODE_MODEL,
|
||||
KIMI_CODE_HIGHSPEED_MODEL,
|
||||
] {
|
||||
assert!(
|
||||
validate_kimi_code_api_model_id(
|
||||
ApiProvider::Moonshot,
|
||||
DEFAULT_KIMI_CODE_BASE_URL,
|
||||
model,
|
||||
)
|
||||
.is_ok(),
|
||||
"{model} must be accepted on the exact Kimi Code membership endpoint"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_platform_ids_fail_on_kimi_code_endpoint() {
|
||||
for model in [
|
||||
MOONSHOT_KIMI_K3_MODEL,
|
||||
DEFAULT_MOONSHOT_MODEL,
|
||||
MOONSHOT_KIMI_K2_6_MODEL,
|
||||
] {
|
||||
let err = validate_kimi_code_api_model_id(
|
||||
ApiProvider::Moonshot,
|
||||
DEFAULT_KIMI_CODE_BASE_URL,
|
||||
model,
|
||||
)
|
||||
.expect_err("direct-platform ids are not Kimi Code membership roster models");
|
||||
assert!(err.contains(model), "{err}");
|
||||
assert!(err.contains("api.moonshot.ai/v1"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_ids_fail_on_direct_moonshot_endpoint() {
|
||||
for model in [
|
||||
KIMI_CODE_K3_MODEL,
|
||||
DEFAULT_KIMI_CODE_MODEL,
|
||||
KIMI_CODE_HIGHSPEED_MODEL,
|
||||
] {
|
||||
let err = validate_kimi_code_api_model_id(
|
||||
ApiProvider::Moonshot,
|
||||
DEFAULT_MOONSHOT_BASE_URL,
|
||||
model,
|
||||
)
|
||||
.expect_err("membership ids are not direct-platform catalog models");
|
||||
assert!(err.contains(model), "{err}");
|
||||
assert!(err.contains("api.kimi.com/coding/v1"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_pairs_pass_and_custom_gateways_are_untouched() {
|
||||
// Canonical pairs pass on both endpoints.
|
||||
for (base_url, model) in [
|
||||
(DEFAULT_KIMI_CODE_BASE_URL, KIMI_CODE_K3_MODEL),
|
||||
(DEFAULT_KIMI_CODE_BASE_URL, DEFAULT_KIMI_CODE_MODEL),
|
||||
(DEFAULT_KIMI_CODE_BASE_URL, KIMI_CODE_HIGHSPEED_MODEL),
|
||||
(DEFAULT_MOONSHOT_BASE_URL, MOONSHOT_KIMI_K3_MODEL),
|
||||
(DEFAULT_MOONSHOT_BASE_URL, DEFAULT_MOONSHOT_MODEL),
|
||||
(DEFAULT_MOONSHOT_BASE_URL, MOONSHOT_KIMI_K2_6_MODEL),
|
||||
] {
|
||||
assert!(
|
||||
validate_kimi_code_api_model_id(ApiProvider::Moonshot, base_url, model).is_ok(),
|
||||
"{base_url} / {model}"
|
||||
);
|
||||
}
|
||||
// The pre-existing cross-pairings still fail closed.
|
||||
assert!(
|
||||
validate_kimi_code_api_model_id(
|
||||
ApiProvider::Moonshot,
|
||||
DEFAULT_KIMI_CODE_BASE_URL,
|
||||
MOONSHOT_KIMI_K3_MODEL,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_kimi_code_api_model_id(
|
||||
ApiProvider::Moonshot,
|
||||
DEFAULT_MOONSHOT_BASE_URL,
|
||||
KIMI_CODE_K3_MODEL,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
// Custom gateways keep their own wire contract, membership ids
|
||||
// included: only the two canonical endpoints enforce pairings.
|
||||
for model in [
|
||||
KIMI_CODE_K3_MODEL,
|
||||
DEFAULT_KIMI_CODE_MODEL,
|
||||
KIMI_CODE_HIGHSPEED_MODEL,
|
||||
MOONSHOT_KIMI_K3_MODEL,
|
||||
] {
|
||||
assert!(
|
||||
validate_kimi_code_api_model_id(
|
||||
ApiProvider::Moonshot,
|
||||
"https://proxy.example/v1",
|
||||
model,
|
||||
)
|
||||
.is_ok(),
|
||||
"{model} on a custom gateway"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Short route label for header/diagnostics without credentials (#4687).
|
||||
pub(crate) fn moonshot_k3_route_display_name(base_url: &str, model: &str) -> Option<&'static str> {
|
||||
if is_exact_kimi_code_k3_route(ApiProvider::Moonshot, base_url, model) {
|
||||
@@ -8048,6 +8463,8 @@ fn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result<Config> {
|
||||
}
|
||||
|
||||
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 {
|
||||
provider: override_cfg.provider.or(base.provider),
|
||||
api_key: override_cfg.api_key.or(base.api_key),
|
||||
@@ -8147,9 +8564,20 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
|
||||
runtime_api: override_cfg.runtime_api.or(base.runtime_api),
|
||||
workshop: override_cfg.workshop.or(base.workshop),
|
||||
exec_policy_engine: override_cfg.exec_policy_engine,
|
||||
active_base_url_env_route: override_cfg
|
||||
.active_base_url_env_route
|
||||
.or(base.active_base_url_env_route),
|
||||
base_url_env_receipt: match override_cfg.base_url_env_receipt {
|
||||
BaseUrlEnvReceipt::Unrecorded => base.base_url_env_receipt,
|
||||
recorded => recorded,
|
||||
},
|
||||
// A layer that supplies its own root `base_url` replaces the
|
||||
// environment's write, so that layer's ownership wins outright.
|
||||
root_base_url_owner: if override_defines_root_base_url {
|
||||
override_cfg.root_base_url_owner
|
||||
} else {
|
||||
match override_cfg.root_base_url_owner {
|
||||
BaseUrlEnvReceipt::Unrecorded => base.root_base_url_owner,
|
||||
recorded => recorded,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8387,7 +8815,22 @@ fn apply_managed_overrides(config: &mut Config) -> Result<()> {
|
||||
// Managed configuration is a higher-precedence file layer. If it
|
||||
// selects a different route or supplies that route's endpoint, the
|
||||
// lower environment layer no longer owns the effective base URL.
|
||||
merged.active_base_url_env_route = None;
|
||||
//
|
||||
// Record that as an explicit "nobody owns it" rather than clearing the
|
||||
// receipt. Clearing it would read as "this config never met the
|
||||
// environment layer", which re-enables the generic
|
||||
// `CODEWHALE_BASE_URL` fallback for every route — including pinned
|
||||
// cross-provider children, which would then borrow an ambient host
|
||||
// that managed routing had just taken authority over.
|
||||
merged.base_url_env_receipt = BaseUrlEnvReceipt::NoOwner;
|
||||
// The shared legacy root field is the same ambient host by another
|
||||
// name. If the environment wrote it, managed authority takes it from
|
||||
// every route rather than leaving it addressed to the identity that
|
||||
// was active before the overlay. A *file*-owned root is left alone:
|
||||
// managed did not override it, so it stays the user's value.
|
||||
if matches!(merged.root_base_url_owner, BaseUrlEnvReceipt::Route(..)) {
|
||||
merged.root_base_url_owner = BaseUrlEnvReceipt::NoOwner;
|
||||
}
|
||||
}
|
||||
*config = merged;
|
||||
Ok(())
|
||||
|
||||
@@ -109,6 +109,9 @@ pub const KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL: &str =
|
||||
/// Official Kimi Code route model id. It is deliberately distinct from
|
||||
/// Moonshot's pay-as-you-go `kimi-k3` catalog id.
|
||||
pub const KIMI_CODE_K3_MODEL: &str = "k3";
|
||||
/// Kimi Code membership high-speed wire model id. Membership route only, not
|
||||
/// a direct Moonshot platform catalog model.
|
||||
pub const KIMI_CODE_HIGHSPEED_MODEL: &str = "kimi-for-coding-highspeed";
|
||||
// The K3 contract constants (`KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS`,
|
||||
// `KIMI_K3_CONTEXT_WINDOW_TOKENS`, and the distinct default/direct output
|
||||
// limits) live in `crate::models` — the model-facts table, which also compiles
|
||||
|
||||
+551
-34
@@ -3841,6 +3841,134 @@ model = "private-openrouter-model"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A session-scoped `[providers.*]` fixture with credentials but no endpoints,
|
||||
/// so every base URL in these tests comes from the resolver rather than a file.
|
||||
const CROSS_PROVIDER_ROUTE_FIXTURE: &str = r#"api_key = "session-deepseek-key"
|
||||
default_text_model = "deepseek-chat"
|
||||
|
||||
[providers.moonshot]
|
||||
api_key = "moonshot-route-key"
|
||||
|
||||
[providers.zai]
|
||||
api_key = "zai-route-key"
|
||||
|
||||
[providers.minimax]
|
||||
api_key = "minimax-route-key"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn generic_base_url_override_never_reaches_pinned_child_routes() -> Result<()> {
|
||||
// #4093-class routing truth: every cross-provider seam (pinned subagent /
|
||||
// fleet child, per-turn auto-router, tool routing, picker preview) clones
|
||||
// the session config and re-points `provider`. The generic endpoint
|
||||
// override belongs to the DeepSeek session that set it and must never
|
||||
// follow a child to another vendor's route.
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
fs::write(&config_path, CROSS_PROVIDER_ROUTE_FIXTURE)?;
|
||||
|
||||
for env_name in ["CODEWHALE_BASE_URL", "DEEPSEEK_BASE_URL"] {
|
||||
let session_host = "https://session-gateway.example.test/v1";
|
||||
let _base = EnvVarGuard::set(env_name, session_host);
|
||||
let config = Config::load(Some(config_path.clone()), None)?;
|
||||
|
||||
// Documented behavior for the active DeepSeek route is unchanged.
|
||||
assert_eq!(config.api_provider(), ApiProvider::Deepseek);
|
||||
assert_eq!(config.deepseek_base_url(), session_host);
|
||||
assert!(config.provider_uses_custom_endpoint(ApiProvider::Deepseek));
|
||||
|
||||
for (provider, expected) in [
|
||||
(ApiProvider::Moonshot, DEFAULT_MOONSHOT_BASE_URL),
|
||||
(ApiProvider::Zai, DEFAULT_ZAI_BASE_URL),
|
||||
(ApiProvider::Minimax, DEFAULT_MINIMAX_BASE_URL),
|
||||
] {
|
||||
assert_eq!(
|
||||
config.base_url_for_route(provider),
|
||||
expected,
|
||||
"{env_name}: {provider:?} must resolve from its own identity table"
|
||||
);
|
||||
assert!(
|
||||
!config.provider_uses_custom_endpoint(provider),
|
||||
"{env_name}: {provider:?} is on its canonical host, not a custom one"
|
||||
);
|
||||
|
||||
let route = crate::route_runtime::resolve_runtime_route(&config, provider, None)
|
||||
.unwrap_or_else(|err| panic!("{env_name}: {provider:?} child route: {err}"));
|
||||
// The scoped config and the executable candidate must agree, and
|
||||
// neither may name the session host.
|
||||
assert_eq!(route.config.deepseek_base_url(), expected);
|
||||
assert_eq!(route.candidate.endpoint().base_url, expected);
|
||||
assert_ne!(route.candidate.endpoint().base_url, session_host);
|
||||
}
|
||||
|
||||
// An unknown/custom identity fails closed on the loopback placeholder
|
||||
// instead of borrowing the DeepSeek session route.
|
||||
let custom_placeholder = normalize_base_url(
|
||||
codewhale_config::ProviderKind::Custom
|
||||
.provider()
|
||||
.default_base_url(),
|
||||
);
|
||||
assert_eq!(
|
||||
config.base_url_for_route(ApiProvider::Custom),
|
||||
custom_placeholder
|
||||
);
|
||||
assert_ne!(config.base_url_for_route(ApiProvider::Custom), session_host);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_scoped_base_url_env_applies_only_to_its_own_route() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
fs::write(&config_path, CROSS_PROVIDER_ROUTE_FIXTURE)?;
|
||||
|
||||
let moonshot_host = "https://moonshot-gateway.example.test/v1";
|
||||
let _moonshot = EnvVarGuard::set("MOONSHOT_BASE_URL", moonshot_host);
|
||||
|
||||
// Without a generic override the active DeepSeek route keeps its default:
|
||||
// a provider-scoped variable names exactly one provider.
|
||||
let config = Config::load(Some(config_path.clone()), None)?;
|
||||
assert_eq!(config.deepseek_base_url(), DEFAULT_DEEPSEEK_BASE_URL);
|
||||
assert_eq!(
|
||||
config.base_url_for_route(ApiProvider::Zai),
|
||||
DEFAULT_ZAI_BASE_URL
|
||||
);
|
||||
assert_eq!(
|
||||
config.base_url_for_route(ApiProvider::Moonshot),
|
||||
moonshot_host
|
||||
);
|
||||
let route = crate::route_runtime::resolve_runtime_route(&config, ApiProvider::Moonshot, None)
|
||||
.expect("Moonshot child route");
|
||||
assert_eq!(route.candidate.endpoint().base_url, moonshot_host);
|
||||
|
||||
// With both set, each override stays on its own route.
|
||||
let session_host = "https://session-gateway.example.test/v1";
|
||||
let _base = EnvVarGuard::set("CODEWHALE_BASE_URL", session_host);
|
||||
let config = Config::load(Some(config_path), None)?;
|
||||
assert_eq!(config.deepseek_base_url(), session_host);
|
||||
assert_eq!(
|
||||
config.base_url_for_route(ApiProvider::Moonshot),
|
||||
moonshot_host
|
||||
);
|
||||
assert_eq!(
|
||||
config.base_url_for_route(ApiProvider::Zai),
|
||||
DEFAULT_ZAI_BASE_URL
|
||||
);
|
||||
let route = crate::route_runtime::resolve_runtime_route(&config, ApiProvider::Moonshot, None)
|
||||
.expect("Moonshot child route");
|
||||
assert_eq!(route.config.deepseek_base_url(), moonshot_host);
|
||||
assert_eq!(route.candidate.endpoint().base_url, moonshot_host);
|
||||
assert_ne!(route.candidate.endpoint().base_url, session_host);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_marked_cli_key_can_follow_cli_forwarded_custom_base_url() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
@@ -4057,12 +4185,22 @@ model = "claude-sonnet-5"
|
||||
);
|
||||
assert_eq!(config.deepseek_api_key()?, "file-openai-key");
|
||||
|
||||
// Anthropic was never the route the environment addressed. Under the
|
||||
// endpoint-ownership receipt the generic override does not follow a
|
||||
// re-pointed config onto another vendor's route — that is the same
|
||||
// mechanism a pinned cross-provider child is resolved through, and it must
|
||||
// not be able to dispatch Anthropic traffic at the DeepSeek session's
|
||||
// gateway. Anthropic therefore resolves its own canonical endpoint, and
|
||||
// because it is no longer on an env-selected host its file-owned key is a
|
||||
// legitimate route-bound credential rather than one following a foreign
|
||||
// host.
|
||||
config.provider = Some("anthropic".to_string());
|
||||
assert_eq!(
|
||||
assert_eq!(config.deepseek_base_url(), DEFAULT_ANTHROPIC_BASE_URL);
|
||||
assert_ne!(
|
||||
config.deepseek_base_url(),
|
||||
"https://env-gateway.example.test/v1"
|
||||
);
|
||||
assert!(config.deepseek_api_key().is_err());
|
||||
assert_eq!(config.deepseek_api_key()?, "stale-anthropic-file-key");
|
||||
|
||||
config.provider = Some("openrouter".to_string());
|
||||
assert!(
|
||||
@@ -8706,7 +8844,7 @@ fn provider_capability_deepseek_v4_pro_has_1m_window_and_thinking() {
|
||||
cap.context_window,
|
||||
crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
|
||||
);
|
||||
assert_eq!(cap.max_output, 384_000);
|
||||
assert_eq!(cap.max_output, Some(384_000));
|
||||
assert!(cap.thinking_supported);
|
||||
assert!(cap.cache_telemetry_supported);
|
||||
assert_eq!(
|
||||
@@ -8725,7 +8863,7 @@ fn provider_capability_deepseek_anthropic_uses_messages_payload() {
|
||||
cap.context_window,
|
||||
crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
|
||||
);
|
||||
assert_eq!(cap.max_output, 384_000);
|
||||
assert_eq!(cap.max_output, Some(384_000));
|
||||
assert!(cap.thinking_supported);
|
||||
assert!(!cap.cache_telemetry_supported);
|
||||
assert_eq!(
|
||||
@@ -8745,7 +8883,7 @@ fn provider_capability_openmodel_uses_messages_payload() {
|
||||
);
|
||||
assert_eq!(
|
||||
cap.max_output,
|
||||
crate::models::max_output_tokens_for_model(DEFAULT_OPENMODEL_MODEL).unwrap_or(64_000)
|
||||
Some(crate::models::max_output_tokens_for_model(DEFAULT_OPENMODEL_MODEL).unwrap_or(64_000))
|
||||
);
|
||||
assert!(!cap.cache_telemetry_supported);
|
||||
assert_eq!(
|
||||
@@ -8762,7 +8900,7 @@ fn provider_capability_deepseek_v4_flash_has_1m_window_and_thinking() {
|
||||
cap.context_window,
|
||||
crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
|
||||
);
|
||||
assert_eq!(cap.max_output, 384_000);
|
||||
assert_eq!(cap.max_output, Some(384_000));
|
||||
assert!(cap.thinking_supported);
|
||||
assert!(cap.cache_telemetry_supported);
|
||||
}
|
||||
@@ -8774,7 +8912,7 @@ fn provider_capability_deepseek_chat_alias_has_v4_flash_caps_and_metadata() {
|
||||
cap.context_window,
|
||||
crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
|
||||
);
|
||||
assert_eq!(cap.max_output, 384_000);
|
||||
assert_eq!(cap.max_output, Some(384_000));
|
||||
assert!(cap.thinking_supported);
|
||||
assert!(cap.cache_telemetry_supported);
|
||||
|
||||
@@ -8795,7 +8933,7 @@ fn provider_capability_deepseek_reasoner_alias_has_v4_flash_caps_and_metadata()
|
||||
cap.context_window,
|
||||
crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
|
||||
);
|
||||
assert_eq!(cap.max_output, 384_000);
|
||||
assert_eq!(cap.max_output, Some(384_000));
|
||||
assert!(cap.thinking_supported);
|
||||
assert!(cap.cache_telemetry_supported);
|
||||
|
||||
@@ -8820,7 +8958,7 @@ fn provider_capability_nvidia_nim_v4_pro_maps_correctly() {
|
||||
cap.context_window,
|
||||
crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
|
||||
);
|
||||
assert_eq!(cap.max_output, 384_000);
|
||||
assert_eq!(cap.max_output, Some(384_000));
|
||||
assert!(cap.thinking_supported);
|
||||
assert!(cap.cache_telemetry_supported);
|
||||
assert_eq!(
|
||||
@@ -8836,7 +8974,7 @@ fn provider_capability_nvidia_nim_v4_flash_maps_correctly() {
|
||||
cap.context_window,
|
||||
crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
|
||||
);
|
||||
assert_eq!(cap.max_output, 384_000);
|
||||
assert_eq!(cap.max_output, Some(384_000));
|
||||
assert!(cap.thinking_supported);
|
||||
assert!(cap.cache_telemetry_supported);
|
||||
}
|
||||
@@ -8848,7 +8986,7 @@ fn provider_capability_openrouter_v4_pro_has_thinking_no_cache() {
|
||||
cap.context_window,
|
||||
crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
|
||||
);
|
||||
assert_eq!(cap.max_output, 384_000);
|
||||
assert_eq!(cap.max_output, Some(384_000));
|
||||
assert!(cap.thinking_supported);
|
||||
// OpenRouter does not return DeepSeek prompt-cache telemetry.
|
||||
assert!(!cap.cache_telemetry_supported);
|
||||
@@ -8867,7 +9005,7 @@ fn provider_capability_openai_codex_uses_responses_payload() {
|
||||
cap.context_window,
|
||||
OPENAI_CODEX_EFFECTIVE_CONTEXT_WINDOW_TOKENS
|
||||
);
|
||||
assert_eq!(cap.max_output, 4096);
|
||||
assert_eq!(cap.max_output, Some(4096));
|
||||
assert!(cap.thinking_supported);
|
||||
assert!(!cap.cache_telemetry_supported);
|
||||
assert_eq!(cap.request_payload_mode, RequestPayloadMode::Responses);
|
||||
@@ -8912,7 +9050,7 @@ fn provider_capability_openrouter_recent_large_models_are_reasoning_aware() {
|
||||
let cap = provider_capability(ApiProvider::Openrouter, model);
|
||||
|
||||
assert_eq!(cap.context_window, expected_window);
|
||||
assert_eq!(cap.max_output, expected_output);
|
||||
assert_eq!(cap.max_output, Some(expected_output));
|
||||
assert!(cap.thinking_supported);
|
||||
assert!(!cap.cache_telemetry_supported);
|
||||
assert_eq!(
|
||||
@@ -8946,7 +9084,7 @@ fn openrouter_nemotron_ultra_aliases_resolve_to_live_id() {
|
||||
fn provider_capability_arcee_direct_models_use_api_docs_shape() {
|
||||
let thinking_cap = provider_capability(ApiProvider::Arcee, DEFAULT_ARCEE_MODEL);
|
||||
assert_eq!(thinking_cap.context_window, 262_144);
|
||||
assert_eq!(thinking_cap.max_output, 262_144);
|
||||
assert_eq!(thinking_cap.max_output, Some(262_144));
|
||||
assert!(thinking_cap.thinking_supported);
|
||||
assert!(!thinking_cap.cache_telemetry_supported);
|
||||
assert_eq!(
|
||||
@@ -8956,14 +9094,14 @@ fn provider_capability_arcee_direct_models_use_api_docs_shape() {
|
||||
|
||||
let preview = provider_capability(ApiProvider::Arcee, ARCEE_TRINITY_LARGE_PREVIEW_MODEL);
|
||||
assert_eq!(preview.context_window, 262_144);
|
||||
assert_eq!(preview.max_output, 4096);
|
||||
assert_eq!(preview.max_output, None);
|
||||
assert!(!preview.thinking_supported);
|
||||
|
||||
let mini = provider_capability(ApiProvider::Arcee, ARCEE_TRINITY_MINI_MODEL);
|
||||
assert_eq!(mini.context_window, 128_000);
|
||||
// ProviderCapability carries a mandatory request fallback; model metadata
|
||||
// itself deliberately keeps Trinity Mini's upstream output limit unknown.
|
||||
assert_eq!(mini.max_output, 4096);
|
||||
// Trinity Mini's upstream output limit is unknown, and ProviderCapability
|
||||
// now says so instead of fabricating a 4K request fallback.
|
||||
assert_eq!(mini.max_output, None);
|
||||
assert_eq!(
|
||||
crate::models::max_output_tokens_for_model(ARCEE_TRINITY_MINI_MODEL),
|
||||
None
|
||||
@@ -8994,7 +9132,7 @@ fn provider_capability_marks_exact_inkling_route_as_reasoning() {
|
||||
fn provider_capability_xiaomi_mimo_has_thinking_no_cache() {
|
||||
let cap = provider_capability(ApiProvider::XiaomiMimo, DEFAULT_XIAOMI_MIMO_MODEL);
|
||||
assert_eq!(cap.context_window, 1_000_000);
|
||||
assert_eq!(cap.max_output, 131_072);
|
||||
assert_eq!(cap.max_output, Some(131_072));
|
||||
assert!(cap.thinking_supported);
|
||||
assert!(!cap.cache_telemetry_supported);
|
||||
assert_eq!(
|
||||
@@ -9004,7 +9142,7 @@ fn provider_capability_xiaomi_mimo_has_thinking_no_cache() {
|
||||
|
||||
let omni = provider_capability(ApiProvider::XiaomiMimo, XIAOMI_MIMO_V2_5_OMNI_MODEL);
|
||||
assert_eq!(omni.context_window, 1_000_000);
|
||||
assert_eq!(omni.max_output, 131_072);
|
||||
assert_eq!(omni.max_output, Some(131_072));
|
||||
assert!(omni.thinking_supported);
|
||||
assert!(!omni.cache_telemetry_supported);
|
||||
}
|
||||
@@ -9016,7 +9154,7 @@ fn provider_capability_novita_v4_pro_has_thinking_no_cache() {
|
||||
cap.context_window,
|
||||
crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
|
||||
);
|
||||
assert_eq!(cap.max_output, 384_000);
|
||||
assert_eq!(cap.max_output, Some(384_000));
|
||||
assert!(cap.thinking_supported);
|
||||
assert!(!cap.cache_telemetry_supported);
|
||||
}
|
||||
@@ -9028,7 +9166,7 @@ fn provider_capability_fireworks_v4_pro_has_thinking_no_cache() {
|
||||
cap.context_window,
|
||||
crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
|
||||
);
|
||||
assert_eq!(cap.max_output, 384_000);
|
||||
assert_eq!(cap.max_output, Some(384_000));
|
||||
assert!(cap.thinking_supported);
|
||||
assert!(!cap.cache_telemetry_supported);
|
||||
}
|
||||
@@ -9040,7 +9178,7 @@ fn provider_capability_siliconflow_v4_pro_has_thinking_no_cache() {
|
||||
cap.context_window,
|
||||
crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
|
||||
);
|
||||
assert_eq!(cap.max_output, 384_000);
|
||||
assert_eq!(cap.max_output, Some(384_000));
|
||||
assert!(cap.thinking_supported);
|
||||
assert!(!cap.cache_telemetry_supported);
|
||||
assert_eq!(
|
||||
@@ -9056,7 +9194,7 @@ fn provider_capability_sglang_v4_pro_has_thinking_no_cache() {
|
||||
cap.context_window,
|
||||
crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
|
||||
);
|
||||
assert_eq!(cap.max_output, 384_000);
|
||||
assert_eq!(cap.max_output, Some(384_000));
|
||||
assert!(cap.thinking_supported);
|
||||
assert!(!cap.cache_telemetry_supported);
|
||||
}
|
||||
@@ -9068,7 +9206,7 @@ fn provider_capability_openai_custom_model_is_chat_completions_without_thinking(
|
||||
cap.context_window,
|
||||
crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS
|
||||
);
|
||||
assert_eq!(cap.max_output, 4096);
|
||||
assert_eq!(cap.max_output, None);
|
||||
assert!(!cap.thinking_supported);
|
||||
assert!(!cap.cache_telemetry_supported);
|
||||
assert_eq!(
|
||||
@@ -9087,7 +9225,7 @@ fn provider_capability_atlascloud_v4_model_resolves_model_metadata() {
|
||||
cap.context_window,
|
||||
crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
|
||||
);
|
||||
assert_eq!(cap.max_output, 384_000);
|
||||
assert_eq!(cap.max_output, Some(384_000));
|
||||
assert!(cap.thinking_supported);
|
||||
assert!(!cap.cache_telemetry_supported);
|
||||
assert_eq!(
|
||||
@@ -9100,7 +9238,7 @@ fn provider_capability_atlascloud_v4_model_resolves_model_metadata() {
|
||||
fn provider_capability_moonshot_default_model_resolves_kimi_metadata() {
|
||||
let cap = provider_capability(ApiProvider::Moonshot, DEFAULT_MOONSHOT_MODEL);
|
||||
assert_eq!(cap.context_window, 262_144);
|
||||
assert_eq!(cap.max_output, 32_768);
|
||||
assert_eq!(cap.max_output, Some(32_768));
|
||||
assert!(cap.thinking_supported);
|
||||
assert!(!cap.cache_telemetry_supported);
|
||||
assert_eq!(
|
||||
@@ -9109,6 +9247,36 @@ fn provider_capability_moonshot_default_model_resolves_kimi_metadata() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_capability_kimi_membership_ids_report_unknown_output_ceiling() {
|
||||
// The `kimi-for-coding` family is membership-only: the membership catalog
|
||||
// owns its output limits, so the static matrix must say "unknown" rather
|
||||
// than fabricating a ceiling. A placeholder here is not cosmetic — it
|
||||
// becomes a hard request clamp in `route_budget`.
|
||||
for model in ["kimi-for-coding", "kimi-for-coding-highspeed"] {
|
||||
let cap = provider_capability(ApiProvider::Moonshot, model);
|
||||
assert_eq!(cap.context_window, 262_144, "{model}");
|
||||
assert_eq!(cap.max_output, None, "{model}");
|
||||
assert!(cap.thinking_supported, "{model}");
|
||||
|
||||
// Unknown is *omitted* on the wire, never serialized as a number.
|
||||
let json = serde_json::to_value(&cap).expect("capability serializes");
|
||||
assert!(
|
||||
json.get("max_output").is_none(),
|
||||
"{model}: unknown output ceiling must not be serialized: {json}"
|
||||
);
|
||||
let round_tripped: ProviderCapability =
|
||||
serde_json::from_value(json).expect("capability round-trips with an absent max_output");
|
||||
assert_eq!(round_tripped, cap, "{model}");
|
||||
}
|
||||
|
||||
// The direct-platform K2.7 Code route does publish 32K, and keeps it.
|
||||
assert_eq!(
|
||||
provider_capability(ApiProvider::Moonshot, "kimi-k2.7-code").max_output,
|
||||
Some(32_768)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_capability_zai_defaults_to_5_2_and_tracks_5_1_and_turbo() {
|
||||
// GLM-5.2 is now the default direct Z.AI model (1M context window).
|
||||
@@ -9116,7 +9284,7 @@ fn provider_capability_zai_defaults_to_5_2_and_tracks_5_1_and_turbo() {
|
||||
assert_eq!(default.resolved_model, DEFAULT_ZAI_MODEL);
|
||||
assert_eq!(default.resolved_model, ZAI_GLM_5_2_MODEL);
|
||||
assert_eq!(default.context_window, 1_000_000);
|
||||
assert_eq!(default.max_output, 131_072);
|
||||
assert_eq!(default.max_output, Some(131_072));
|
||||
assert!(default.thinking_supported);
|
||||
assert!(!default.cache_telemetry_supported);
|
||||
|
||||
@@ -9124,7 +9292,7 @@ fn provider_capability_zai_defaults_to_5_2_and_tracks_5_1_and_turbo() {
|
||||
let v51 = provider_capability(ApiProvider::Zai, ZAI_GLM_5_1_MODEL);
|
||||
assert_eq!(v51.resolved_model, ZAI_GLM_5_1_MODEL);
|
||||
assert_eq!(v51.context_window, 202_752);
|
||||
assert_eq!(v51.max_output, 131_072);
|
||||
assert_eq!(v51.max_output, Some(131_072));
|
||||
assert!(v51.thinking_supported);
|
||||
|
||||
// GLM-5-Turbo is the faster sub-agent sibling.
|
||||
@@ -9136,7 +9304,7 @@ fn provider_capability_zai_defaults_to_5_2_and_tracks_5_1_and_turbo() {
|
||||
fn provider_capability_minimax_direct_models_use_api_docs_shape() {
|
||||
let m3 = provider_capability(ApiProvider::Minimax, DEFAULT_MINIMAX_MODEL);
|
||||
assert_eq!(m3.context_window, 1_000_000);
|
||||
assert_eq!(m3.max_output, 524_288);
|
||||
assert_eq!(m3.max_output, Some(524_288));
|
||||
assert!(m3.thinking_supported);
|
||||
assert!(!m3.cache_telemetry_supported);
|
||||
assert_eq!(m3.request_payload_mode, RequestPayloadMode::ChatCompletions);
|
||||
@@ -9181,7 +9349,7 @@ fn provider_capability_wanjie_ark_reasoner_has_thinking_no_cache() {
|
||||
cap.context_window,
|
||||
crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS
|
||||
);
|
||||
assert_eq!(cap.max_output, 4096);
|
||||
assert_eq!(cap.max_output, None);
|
||||
assert!(cap.thinking_supported);
|
||||
assert!(!cap.cache_telemetry_supported);
|
||||
assert_eq!(
|
||||
@@ -9199,7 +9367,7 @@ fn provider_capability_ollama_deepseek_tag_uses_deepseek_heuristic() {
|
||||
cap.context_window,
|
||||
crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS
|
||||
);
|
||||
assert_eq!(cap.max_output, 4096);
|
||||
assert_eq!(cap.max_output, None);
|
||||
assert!(!cap.thinking_supported);
|
||||
assert!(!cap.cache_telemetry_supported);
|
||||
assert_eq!(
|
||||
@@ -9212,7 +9380,7 @@ fn provider_capability_ollama_deepseek_tag_uses_deepseek_heuristic() {
|
||||
fn provider_capability_ollama_unknown_model_falls_back_to_8192() {
|
||||
let cap = provider_capability(ApiProvider::Ollama, "llama3.2:3b");
|
||||
assert_eq!(cap.context_window, 8192);
|
||||
assert_eq!(cap.max_output, 4096);
|
||||
assert_eq!(cap.max_output, None);
|
||||
assert!(!cap.thinking_supported);
|
||||
assert!(!cap.cache_telemetry_supported);
|
||||
assert_eq!(
|
||||
@@ -9228,7 +9396,7 @@ fn provider_capability_non_v4_model_has_smaller_window() {
|
||||
cap.context_window,
|
||||
crate::models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS
|
||||
);
|
||||
assert_eq!(cap.max_output, 4096);
|
||||
assert_eq!(cap.max_output, None);
|
||||
assert!(!cap.thinking_supported);
|
||||
}
|
||||
|
||||
@@ -10337,6 +10505,263 @@ fn cli_model_flag_selects_kimi_k3_on_the_moonshot_platform_route() -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// === Identity-owned endpoint resolution (provider-truth regressions) ===
|
||||
//
|
||||
// Every test here is offline and env-locked. No credential is invented and no
|
||||
// provider is contacted: the assertions are about which host string a route
|
||||
// resolves to, and about the classifications derived from it.
|
||||
|
||||
/// A managed-config guard pointing at a path that does not exist, so an
|
||||
/// operator-installed managed file on the developer's machine cannot leak into
|
||||
/// these route assertions.
|
||||
fn no_managed_config(root: &std::path::Path) -> EnvVarGuard {
|
||||
EnvVarGuard::set(
|
||||
"DEEPSEEK_MANAGED_CONFIG_PATH",
|
||||
root.join("absent-managed.toml"),
|
||||
)
|
||||
}
|
||||
|
||||
fn custom_placeholder_base_url() -> String {
|
||||
normalize_base_url(
|
||||
codewhale_config::ProviderKind::Custom
|
||||
.provider()
|
||||
.default_base_url(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_owned_deepseek_root_base_url_does_not_reach_the_deepseek_cn_sibling() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let _managed = no_managed_config(temp_root.path());
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
fs::write(&config_path, "provider = \"deepseek\"\n")?;
|
||||
let _base = EnvVarGuard::set("CODEWHALE_BASE_URL", "https://env-gateway.example.test/v1");
|
||||
|
||||
let config = Config::load(Some(config_path), None)?;
|
||||
|
||||
// The env override owns the route it was addressed to.
|
||||
assert_eq!(config.api_provider(), ApiProvider::Deepseek);
|
||||
assert_eq!(
|
||||
config.deepseek_base_url(),
|
||||
"https://env-gateway.example.test/v1"
|
||||
);
|
||||
assert!(config.provider_uses_custom_endpoint(ApiProvider::Deepseek));
|
||||
|
||||
// The sibling identity shares the same legacy root field but is a
|
||||
// different route: it must fall through to its own canonical endpoint.
|
||||
assert_eq!(
|
||||
config.base_url_for_route(ApiProvider::DeepseekCN),
|
||||
DEFAULT_DEEPSEEKCN_BASE_URL
|
||||
);
|
||||
assert!(!config.provider_uses_custom_endpoint(ApiProvider::DeepseekCN));
|
||||
assert!(!config.model_ids_pass_through_for_provider(ApiProvider::DeepseekCN));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_owned_deepseek_cn_root_base_url_does_not_reach_the_deepseek_sibling() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let _managed = no_managed_config(temp_root.path());
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
fs::write(&config_path, "provider = \"deepseek-cn\"\n")?;
|
||||
let _base = EnvVarGuard::set(
|
||||
"DEEPSEEK_BASE_URL",
|
||||
"https://cn-env-gateway.example.test/v1",
|
||||
);
|
||||
|
||||
let config = Config::load(Some(config_path), None)?;
|
||||
|
||||
assert_eq!(config.api_provider(), ApiProvider::DeepseekCN);
|
||||
assert_eq!(
|
||||
config.deepseek_base_url(),
|
||||
"https://cn-env-gateway.example.test/v1"
|
||||
);
|
||||
assert!(config.provider_uses_custom_endpoint(ApiProvider::DeepseekCN));
|
||||
|
||||
assert_eq!(
|
||||
config.base_url_for_route(ApiProvider::Deepseek),
|
||||
DEFAULT_DEEPSEEK_BASE_URL
|
||||
);
|
||||
assert!(!config.provider_uses_custom_endpoint(ApiProvider::Deepseek));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_owned_legacy_root_base_url_stays_shared_by_both_deepseek_identities() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let _managed = no_managed_config(temp_root.path());
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
"provider = \"deepseek\"\nbase_url = \"https://file-gateway.example.test/v1\"\n",
|
||||
)?;
|
||||
|
||||
let config = Config::load(Some(config_path), None)?;
|
||||
|
||||
// No environment write, so the root field is the user's own. Both
|
||||
// identities keep reading it, exactly as they always have.
|
||||
for provider in [ApiProvider::Deepseek, ApiProvider::DeepseekCN] {
|
||||
assert_eq!(
|
||||
config.base_url_for_route(provider),
|
||||
"https://file-gateway.example.test/v1",
|
||||
"{provider:?} must keep the file-owned legacy root endpoint"
|
||||
);
|
||||
assert!(config.provider_uses_custom_endpoint(provider));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_overlay_keeps_pinned_children_off_the_ambient_generic_host() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
let managed_path = temp_root.path().join("managed.toml");
|
||||
fs::write(&config_path, "provider = \"deepseek\"\n")?;
|
||||
fs::write(
|
||||
&managed_path,
|
||||
"provider = \"openrouter\"\n\n[providers.openrouter]\nbase_url = \"https://managed-gateway.example.test/v1\"\n",
|
||||
)?;
|
||||
let _managed = EnvVarGuard::set("DEEPSEEK_MANAGED_CONFIG_PATH", &managed_path);
|
||||
let _base = EnvVarGuard::set("CODEWHALE_BASE_URL", "https://env-gateway.example.test/v1");
|
||||
|
||||
let config = Config::load(Some(config_path), None)?;
|
||||
|
||||
// Managed routing is authoritative for the active route.
|
||||
assert_eq!(config.api_provider(), ApiProvider::Openrouter);
|
||||
assert_eq!(
|
||||
config.deepseek_base_url(),
|
||||
"https://managed-gateway.example.test/v1"
|
||||
);
|
||||
|
||||
// The receipt must say "nobody owns the generic override" rather than
|
||||
// being cleared: a cleared receipt reads as "never met the environment
|
||||
// layer" and re-enables the generic fallback for every pinned child.
|
||||
assert_eq!(config.base_url_env_receipt, BaseUrlEnvReceipt::NoOwner);
|
||||
assert_eq!(config.root_base_url_owner, BaseUrlEnvReceipt::NoOwner);
|
||||
for provider in [
|
||||
ApiProvider::Moonshot,
|
||||
ApiProvider::Zai,
|
||||
ApiProvider::Minimax,
|
||||
ApiProvider::Deepseek,
|
||||
ApiProvider::DeepseekCN,
|
||||
] {
|
||||
assert_eq!(
|
||||
config.base_url_for_route(provider),
|
||||
provider.default_base_url(),
|
||||
"{provider:?} must not borrow the ambient generic host under managed routing"
|
||||
);
|
||||
assert!(!config.provider_uses_custom_endpoint(provider));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_custom_children_resolve_by_identity_not_by_the_active_custom_route() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let _managed = no_managed_config(temp_root.path());
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"provider = "acme"
|
||||
|
||||
[providers.acme]
|
||||
base_url = "https://acme.example.test/v1"
|
||||
model = "acme-1"
|
||||
|
||||
[providers.beta]
|
||||
base_url = "https://beta.example.test/v1"
|
||||
model = "beta-1"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = Config::load(Some(config_path), None)?;
|
||||
|
||||
assert_eq!(config.api_provider(), ApiProvider::Custom);
|
||||
assert_eq!(config.deepseek_base_url(), "https://acme.example.test/v1");
|
||||
// A pinned child of the other named custom table resolves its own host.
|
||||
assert_eq!(
|
||||
config.base_url_for_route_identity(ApiProvider::Custom, "beta"),
|
||||
"https://beta.example.test/v1"
|
||||
);
|
||||
assert!(config.custom_identity_is_resolvable("beta"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_custom_identity_fails_closed_instead_of_reading_the_active_custom() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let _managed = no_managed_config(temp_root.path());
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"provider = "acme"
|
||||
|
||||
[providers.acme]
|
||||
base_url = "https://acme.example.test/v1"
|
||||
model = "acme-1"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = Config::load(Some(config_path), None)?;
|
||||
let placeholder = custom_placeholder_base_url();
|
||||
|
||||
// A removed/renamed table, an empty identity, and the literal `custom`
|
||||
// key on a config that is not the legacy root-literal route all fail
|
||||
// closed to the descriptor placeholder — never to the active custom host.
|
||||
for identity in ["ghost", "", " ", "custom"] {
|
||||
let resolved = config.base_url_for_route_identity(ApiProvider::Custom, identity);
|
||||
assert_eq!(
|
||||
resolved, placeholder,
|
||||
"identity {identity:?} must not resolve to the active custom endpoint"
|
||||
);
|
||||
assert_ne!(resolved, "https://acme.example.test/v1");
|
||||
assert!(!config.custom_identity_is_resolvable(identity));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_literal_custom_root_endpoint_belongs_only_to_the_literal_identity() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let _managed = no_managed_config(temp_root.path());
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"provider = "custom"
|
||||
base_url = "https://legacy-root.example.test/v1"
|
||||
default_text_model = "legacy-1"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = Config::load(Some(config_path), None)?;
|
||||
|
||||
assert!(config.uses_legacy_literal_custom_route());
|
||||
assert_eq!(
|
||||
config.base_url_for_route_identity(ApiProvider::Custom, "custom"),
|
||||
"https://legacy-root.example.test/v1"
|
||||
);
|
||||
// A differently named custom child must not inherit the legacy root.
|
||||
assert_eq!(
|
||||
config.base_url_for_route_identity(ApiProvider::Custom, "acme"),
|
||||
custom_placeholder_base_url()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The bare `k3` id belongs to the Kimi Code coding-plan endpoint. A config
|
||||
/// that selects it there must resolve, and must be labelled as the membership
|
||||
/// product rather than the direct platform one (v0.9.1 kimi-k3 dogfood report).
|
||||
@@ -10418,3 +10843,95 @@ fn k3_and_kimi_k3_never_cross_products_and_fail_visibly() {
|
||||
KIMI_CODE_K3_MODEL
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_endpoint_and_billing_receipts_agree_for_every_resolved_route() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let _managed = no_managed_config(temp_root.path());
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
fs::write(&config_path, "provider = \"deepseek\"\n")?;
|
||||
let _base = EnvVarGuard::set("CODEWHALE_BASE_URL", "https://env-gateway.example.test/v1");
|
||||
|
||||
let config = Config::load(Some(config_path), None)?;
|
||||
|
||||
// `for_route` reads the ambient config; `for_dispatched_route` reads the
|
||||
// endpoint the client is actually built from. After the resolver became
|
||||
// identity-aware these must not be able to disagree for the active route.
|
||||
let provider = config.api_provider();
|
||||
let resolved = config.deepseek_base_url();
|
||||
assert_eq!(
|
||||
crate::route_billing::for_route(&config, provider),
|
||||
crate::route_billing::for_dispatched_route(
|
||||
&config,
|
||||
crate::route_billing::DispatchedRoute {
|
||||
provider,
|
||||
base_url: &resolved,
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
// A pinned cross-provider child bills from its own resolved endpoint,
|
||||
// which is its canonical host — not the session's env-selected gateway.
|
||||
for child in [ApiProvider::DeepseekCN, ApiProvider::Moonshot] {
|
||||
let child_base = config.base_url_for_route(child);
|
||||
assert_eq!(child_base, child.default_base_url(), "{child:?}");
|
||||
assert_eq!(
|
||||
crate::route_billing::for_dispatched_route(
|
||||
&config,
|
||||
crate::route_billing::DispatchedRoute {
|
||||
provider: child,
|
||||
base_url: &child_base,
|
||||
},
|
||||
),
|
||||
crate::route_billing::for_route(&config, child),
|
||||
"{child:?} ambient and dispatch billing receipts must agree"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_and_inventory_classify_the_resolved_route_not_the_session_host() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let _managed = no_managed_config(temp_root.path());
|
||||
let config_path = temp_root.path().join("config.toml");
|
||||
fs::write(&config_path, "provider = \"deepseek\"\n")?;
|
||||
let _base = EnvVarGuard::set("CODEWHALE_BASE_URL", "http://127.0.0.1:11434/v1");
|
||||
|
||||
let config = Config::load(Some(config_path), None)?;
|
||||
|
||||
// Readiness: the active route is on a local custom host and classifies as
|
||||
// keyless-local; the sibling identity is still the canonical hosted
|
||||
// endpoint and must not inherit that classification.
|
||||
assert_eq!(
|
||||
crate::provider_readiness::credential_state_for_provider(&config, ApiProvider::Deepseek),
|
||||
crate::provider_readiness::CredentialState::Local
|
||||
);
|
||||
assert_ne!(
|
||||
crate::provider_readiness::credential_state_for_provider(&config, ApiProvider::DeepseekCN),
|
||||
crate::provider_readiness::CredentialState::Local
|
||||
);
|
||||
|
||||
// Inventory: the runtime route the picker/inventory reads is built by
|
||||
// re-pointing a clone of this config, so it must resolve the sibling's own
|
||||
// canonical endpoint.
|
||||
let route = crate::route_runtime::resolve_runtime_route(&config, ApiProvider::DeepseekCN, None)
|
||||
.expect("deepseek-cn runtime route");
|
||||
assert_eq!(
|
||||
route.candidate.endpoint().base_url,
|
||||
DEFAULT_DEEPSEEKCN_BASE_URL
|
||||
);
|
||||
assert_eq!(
|
||||
route.config.deepseek_base_url(),
|
||||
DEFAULT_DEEPSEEKCN_BASE_URL
|
||||
);
|
||||
|
||||
// And a canonical/default endpoint is never reported as custom.
|
||||
assert!(!config.provider_uses_custom_endpoint(ApiProvider::DeepseekCN));
|
||||
assert!(config.provider_uses_custom_endpoint(ApiProvider::Deepseek));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3607,6 +3607,17 @@ impl Engine {
|
||||
let route_limits = crate::route_budget::known_route_limits(route.candidate.limits());
|
||||
let route_capabilities = route.candidate.capabilities();
|
||||
let route_api_config = route.config.clone();
|
||||
// Freeze the billing receipt here, while `route` is still the single
|
||||
// authority for this turn: `route.config` is the identity-scoped
|
||||
// Config the client is being built from, and `route.candidate` names
|
||||
// the endpoint it will call. After `install_resolved_runtime_route`
|
||||
// consumes `route`, the only sound source for these facts is this
|
||||
// receipt — an ambient `Config` read at TurnStarted or TurnComplete
|
||||
// would follow a later provider switch, auto-router hop, or custom
|
||||
// table change onto the wrong vendor.
|
||||
let dispatched_base_url = route.candidate.endpoint().base_url.clone();
|
||||
let dispatched_product =
|
||||
crate::route_billing::capture_product(&route.config, effective_provider);
|
||||
if let Err(err) = self.install_resolved_runtime_route(route) {
|
||||
let _ = self
|
||||
.tx_event
|
||||
@@ -3711,6 +3722,10 @@ impl Engine {
|
||||
// A start is not a dispatch. The billing envelope is attached
|
||||
// below, on the route held for the wire boundary only.
|
||||
billing: None,
|
||||
// The classification receipt, by contrast, is frozen here at the
|
||||
// client-freeze boundary and is readable from `TurnStarted` on.
|
||||
base_url: dispatched_base_url,
|
||||
billing_product: dispatched_product,
|
||||
};
|
||||
// Billing provenance follows the *route* that was installed for this
|
||||
// turn, which is authoritative even when a test or embedder injected the
|
||||
@@ -3726,8 +3741,20 @@ impl Engine {
|
||||
)
|
||||
.map(str::to_string),
|
||||
endpoint_fingerprint: route_base_url.and_then(crate::cost_status::endpoint_fingerprint),
|
||||
billing_mode: crate::route_billing::for_route(&self.api_config, effective_provider)
|
||||
.into(),
|
||||
// Classified from this turn's own frozen receipt, not from a
|
||||
// second ambient `for_route` read. Both halves of the route then
|
||||
// answer from the same captured endpoint + credential product, so
|
||||
// the envelope stamped on the wire and the receipt carried on
|
||||
// `TurnRoute` cannot disagree about how this turn bills.
|
||||
billing_mode: crate::route_billing::for_dispatched_receipt(
|
||||
crate::route_billing::DispatchedReceipt {
|
||||
provider: effective_provider,
|
||||
identity: Some(turn_route.provider_identity.as_str()),
|
||||
base_url: turn_route.base_url.as_str(),
|
||||
product: turn_route.billing_product,
|
||||
},
|
||||
)
|
||||
.into(),
|
||||
// Provisional. Replaced with the true wire-boundary instant
|
||||
// when `handle_deepseek_turn` emits `Event::RouteDispatched`.
|
||||
dispatched_at: turn_started_at,
|
||||
|
||||
@@ -9946,7 +9946,9 @@ fn context_budget_uses_provider_effective_window_for_openai_codex() {
|
||||
.expect("OpenAI Codex should use a conservative fallback without route metadata");
|
||||
let expected = usize::try_from(crate::config::OPENAI_CODEX_EFFECTIVE_CONTEXT_WINDOW_TOKENS)
|
||||
.expect("context window fits usize")
|
||||
- crate::config::provider_capability(ApiProvider::OpenaiCodex, "gpt-5.5").max_output
|
||||
- crate::config::provider_capability(ApiProvider::OpenaiCodex, "gpt-5.5")
|
||||
.max_output
|
||||
.expect("Codex route publishes a deliberate conservative output cap")
|
||||
as usize
|
||||
- 1_024usize;
|
||||
assert_eq!(budget, expected);
|
||||
@@ -9965,7 +9967,9 @@ fn route_context_budget_uses_shared_budget_service() {
|
||||
assert_eq!(
|
||||
budget.output_cap_tokens,
|
||||
u64::from(
|
||||
crate::config::provider_capability(ApiProvider::OpenaiCodex, "gpt-5.5").max_output
|
||||
crate::config::provider_capability(ApiProvider::OpenaiCodex, "gpt-5.5")
|
||||
.max_output
|
||||
.expect("Codex route publishes a deliberate conservative output cap")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -56,12 +56,46 @@ pub struct TurnRoute {
|
||||
/// boundary, and delivered on `RouteDispatched`. Consumers that price a
|
||||
/// turn must treat `None` as *unknown*, never as a zero-cost turn.
|
||||
pub billing: Option<RouteBillingEnvelope>,
|
||||
/// Endpoint this turn's client was frozen against, verbatim.
|
||||
///
|
||||
/// [`crate::route_receipt::TurnRouteReceipt`] deliberately keeps only a
|
||||
/// redacted endpoint identity, which billing cannot classify from, so the
|
||||
/// non-secret URL travels here. Captured from the resolved route candidate
|
||||
/// at the client-freeze boundary, before any ambient selection state can
|
||||
/// move. Empty only when no endpoint was captured, which bills Unknown
|
||||
/// rather than guessing.
|
||||
pub base_url: String,
|
||||
/// Credential/pay-mode product truth captured from the route-scoped config
|
||||
/// at the same instant.
|
||||
///
|
||||
/// Together with `provider_identity` and `base_url` this is a complete
|
||||
/// [`crate::route_billing::DispatchedReceipt`]: every fact billing needs,
|
||||
/// frozen at the client-freeze boundary. Consumers must classify from
|
||||
/// these fields and must never re-read an ambient `Config` after the turn
|
||||
/// starts — by `TurnComplete` a provider switch, an auto-router hop, or a
|
||||
/// `/provider` change can have moved it elsewhere.
|
||||
pub billing_product: crate::route_billing::RouteProduct,
|
||||
}
|
||||
|
||||
/// Dispatch-time billing evidence. Separate from [`TurnRoute`] so the type
|
||||
/// system — not a convention — enforces that no caller can read a billing
|
||||
/// surface, endpoint fingerprint, or dispatch instant off a route that was
|
||||
/// only *planned*.
|
||||
///
|
||||
/// This is deliberately *not* the same thing as the classification receipt
|
||||
/// carried by [`TurnRoute::base_url`] / [`TurnRoute::billing_product`], and
|
||||
/// the two are not merged. They are captured at different instants and answer
|
||||
/// different questions:
|
||||
///
|
||||
/// - `base_url` + `billing_product` + `provider_identity` are frozen at the
|
||||
/// **client-freeze** boundary and answer *which route is this and how does
|
||||
/// it bill* — a [`crate::route_billing::DispatchedReceipt`]. They must be
|
||||
/// readable from `TurnStarted` onward so a child turn arriving mid-flight
|
||||
/// can be billed against the parent's frozen route.
|
||||
/// - This envelope is stamped at the **wire** boundary and answers *what was
|
||||
/// actually put on the wire, when*. A planned-but-unsent route has no
|
||||
/// metering surface and no dispatch instant, so it must be structurally
|
||||
/// absent rather than defaulted.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RouteBillingEnvelope {
|
||||
pub billing_surface: Option<String>,
|
||||
|
||||
@@ -6176,10 +6176,12 @@ fn provider_capability_report(config: &Config) -> serde_json::Value {
|
||||
crate::route_runtime::ContextWindowSource::Fallback.label(),
|
||||
|route| route.context_window.source.label(),
|
||||
);
|
||||
// `null` when neither the resolved route nor the compatibility matrix
|
||||
// publishes an output ceiling — doctor must not invent one.
|
||||
let max_output = route_profile
|
||||
.as_ref()
|
||||
.and_then(|profile| profile.max_output)
|
||||
.unwrap_or(cap.max_output);
|
||||
.or(cap.max_output);
|
||||
let is_exact_kimi_code_k3 = route.as_ref().is_some_and(|route| {
|
||||
crate::config::is_exact_kimi_code_k3_route(
|
||||
provider,
|
||||
|
||||
@@ -29,7 +29,10 @@ pub(crate) struct ModelRouteCandidate {
|
||||
pub(crate) provider_display_name: &'static str,
|
||||
pub(crate) model: String,
|
||||
pub(crate) context_window: u32,
|
||||
pub(crate) max_output: u32,
|
||||
/// Known output ceiling, or `None` when this route publishes none. The
|
||||
/// classifier is told "unknown" rather than a fabricated number.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) max_output: Option<u32>,
|
||||
pub(crate) thinking_supported: bool,
|
||||
pub(crate) cache_telemetry_supported: bool,
|
||||
pub(crate) auth_source: ModelAuthSource,
|
||||
@@ -106,6 +109,18 @@ impl ModelInventory {
|
||||
if let Some(context_window) = route.candidate.limits().context_tokens {
|
||||
capability.context_window = context_window.min(u64::from(u32::MAX)) as u32;
|
||||
}
|
||||
// A concrete offering maximum is a stronger fact than the
|
||||
// static compatibility matrix — and is the only way a
|
||||
// membership route (no static cap) gets a known ceiling.
|
||||
if let Some(max_output) = route
|
||||
.candidate
|
||||
.limits()
|
||||
.output_tokens
|
||||
.and_then(|tokens| u32::try_from(tokens).ok())
|
||||
.filter(|tokens| *tokens > 0)
|
||||
{
|
||||
capability.max_output = Some(max_output);
|
||||
}
|
||||
// Do not promote bare `k3` into the global capability
|
||||
// catalog. Its thinking trace contract belongs only to
|
||||
// Kimi Code's exact membership-plan route.
|
||||
@@ -264,7 +279,8 @@ impl ModelInventory {
|
||||
provider_display_name: &'a str,
|
||||
model: &'a str,
|
||||
context_window: u32,
|
||||
max_output: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_output: Option<u32>,
|
||||
thinking_supported: bool,
|
||||
cache_telemetry_supported: bool,
|
||||
default_for_provider: bool,
|
||||
@@ -744,7 +760,7 @@ mod tests {
|
||||
provider_display_name: "OpenAI",
|
||||
model: "gpt-5.5".to_string(),
|
||||
context_window: 128_000,
|
||||
max_output: 16_384,
|
||||
max_output: Some(16_384),
|
||||
thinking_supported: true,
|
||||
cache_telemetry_supported: false,
|
||||
auth_source: ModelAuthSource::Config,
|
||||
@@ -773,7 +789,7 @@ mod tests {
|
||||
provider_display_name: "OpenAI",
|
||||
model: "unsupported-model".to_string(),
|
||||
context_window: 1,
|
||||
max_output: 1,
|
||||
max_output: Some(1),
|
||||
thinking_supported: false,
|
||||
cache_telemetry_supported: false,
|
||||
auth_source: ModelAuthSource::Config,
|
||||
@@ -807,7 +823,7 @@ mod tests {
|
||||
provider_display_name: "OpenAI",
|
||||
model: "unsupported-model".to_string(),
|
||||
context_window: 1,
|
||||
max_output: 1,
|
||||
max_output: Some(1),
|
||||
thinking_supported: false,
|
||||
cache_telemetry_supported: false,
|
||||
auth_source: ModelAuthSource::Config,
|
||||
|
||||
@@ -221,7 +221,10 @@ pub fn resolved_capability_profile_with_overrides(
|
||||
.context_window
|
||||
.unwrap_or(provider_cap.context_window),
|
||||
);
|
||||
let max_output = Some(overrides.max_output.unwrap_or(provider_cap.max_output));
|
||||
// An explicit override wins; otherwise carry the compatibility cap through
|
||||
// *including its unknown state*, so pickers and diagnostics render `?`
|
||||
// rather than a fabricated ceiling.
|
||||
let max_output = overrides.max_output.or(provider_cap.max_output);
|
||||
let reasoning = overrides
|
||||
.reasoning
|
||||
.unwrap_or_else(|| bool_state(provider_cap.thinking_supported));
|
||||
|
||||
@@ -335,7 +335,8 @@ fn known_context_window_for_model(model_lower: &str) -> Option<u32> {
|
||||
| "moonshotai/kimi-k2.6:free"
|
||||
| "kimi-k2.7-code"
|
||||
| "kimi-k2.6"
|
||||
| "kimi-for-coding" => Some(262_144),
|
||||
| "kimi-for-coding"
|
||||
| "kimi-for-coding-highspeed" => Some(262_144),
|
||||
"minimax-m2.7"
|
||||
| "minimax/minimax-m2.7"
|
||||
| "minimax-m2.7-highspeed"
|
||||
@@ -407,12 +408,15 @@ pub fn max_output_tokens_for_model(model: &str) -> Option<u32> {
|
||||
}
|
||||
// Kimi K2.7 Code has a 256K context window but its documented default
|
||||
// maximum generation is 32K. Keeping those separate prevents the
|
||||
// input budget from collapsing to the 1K emergency floor (#4368).
|
||||
"moonshotai/kimi-k2.7-code"
|
||||
| "moonshotai/kimi-k2.6"
|
||||
| "kimi-k2.7-code"
|
||||
| "kimi-k2.6"
|
||||
| "kimi-for-coding" => Some(32_768),
|
||||
// input budget from collapsing to the 1K emergency floor (#4368). The
|
||||
// direct-platform value matches the provider-reported bundled
|
||||
// catalog. The Kimi Code membership ids (`kimi-for-coding` family)
|
||||
// are deliberately absent here: the membership catalog is the source
|
||||
// of truth for their limits and no client-side output ceiling is
|
||||
// claimed, so they fall back to the generic default.
|
||||
"moonshotai/kimi-k2.7-code" | "moonshotai/kimi-k2.6" | "kimi-k2.7-code" | "kimi-k2.6" => {
|
||||
Some(32_768)
|
||||
}
|
||||
"minimax/minimax-m3" | "minimax-m3" => Some(524_288),
|
||||
// Alibaba's published limit is 65,536 output tokens; the earlier
|
||||
// 262,140 mirrored the context window (data-entry smell flagged by
|
||||
@@ -935,6 +939,7 @@ mod tests {
|
||||
assert!(model_supports_reasoning("kimi-k2.7-code"));
|
||||
assert!(model_supports_reasoning("kimi-k2.6"));
|
||||
assert!(model_supports_reasoning("kimi-for-coding"));
|
||||
assert!(model_supports_reasoning("kimi-for-coding-highspeed"));
|
||||
assert!(model_supports_reasoning("kimi-k2.5"));
|
||||
}
|
||||
|
||||
@@ -1045,6 +1050,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_code_membership_ids_mirror_their_family_facts() {
|
||||
// The high-speed membership id rides the kimi-for-coding family
|
||||
// context fact (256K) and reasoning support via the same `kimi-`
|
||||
// native-id rule as `kimi-for-coding`. No client-side output ceiling
|
||||
// is claimed for the membership ids — the membership catalog is the
|
||||
// source of truth, so the generic lookup returns None.
|
||||
assert_eq!(
|
||||
context_window_for_model("kimi-for-coding-highspeed"),
|
||||
Some(262_144)
|
||||
);
|
||||
assert_eq!(
|
||||
max_output_tokens_for_model("kimi-for-coding-highspeed"),
|
||||
None
|
||||
);
|
||||
assert_eq!(max_output_tokens_for_model("kimi-for-coding"), None);
|
||||
assert!(model_supports_reasoning("kimi-for-coding-highspeed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_provider_model_ids_mirror_vendor_prefixed_rows() {
|
||||
// Direct-provider routes (Moonshot, MiniMax, Z.ai) serve bare model
|
||||
@@ -1079,7 +1103,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(max_output_tokens_for_model("kimi-k2.7-code"), Some(32_768));
|
||||
assert_eq!(max_output_tokens_for_model("kimi-k2.6"), Some(32_768));
|
||||
assert_eq!(max_output_tokens_for_model("kimi-for-coding"), Some(32_768));
|
||||
assert_eq!(max_output_tokens_for_model("kimi-for-coding"), None);
|
||||
assert_eq!(max_output_tokens_for_model("kimi-k3"), Some(131_072));
|
||||
assert_eq!(max_output_tokens_for_model("minimax-m3"), Some(524_288));
|
||||
assert_eq!(max_output_tokens_for_model("glm-5.1"), Some(131_072));
|
||||
|
||||
+1806
-190
File diff suppressed because it is too large
Load Diff
+212
-17
@@ -71,6 +71,78 @@ pub(crate) fn effective_max_output_tokens(model: &str) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Conservative request ceiling for a model the static catalogue does not
|
||||
/// describe at all.
|
||||
///
|
||||
/// An absent compatibility cap is not evidence of a large ceiling. Remote
|
||||
/// OpenAI-compatible routes serving an unrecognized wire alias frequently
|
||||
/// publish a much lower `max_tokens` maximum and reject anything above it, so
|
||||
/// an uncatalogued id keeps this floor rather than inheriting the full
|
||||
/// [`API_MAX_OUTPUT_TOKENS`] request cap.
|
||||
const UNCATALOGUED_COMPAT_MAX_OUTPUT_TOKENS: u32 = 8_192;
|
||||
|
||||
/// Why a route's compatibility output ceiling has the value it does.
|
||||
///
|
||||
/// Carried so a clamp is always attributable: "unknown" is only allowed to
|
||||
/// mean "no clamp" when a route *truthfully publishes no ceiling*, never when
|
||||
/// the catalogue simply has no row for the model.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum OutputCeilingSource {
|
||||
/// The static catalogue publishes an exact/conservative ceiling.
|
||||
Documented(u32),
|
||||
/// The route is known to publish no output maximum we can stand behind
|
||||
/// (Kimi Code membership ids, operator-owned self-hosted engines). Unknown
|
||||
/// stays unknown and nothing is clamped.
|
||||
RouteDeclaredUnknown,
|
||||
/// The catalogue has no row for this model. Fail closed to a conservative
|
||||
/// ceiling rather than treating absence as permission.
|
||||
Uncatalogued(u32),
|
||||
}
|
||||
|
||||
impl OutputCeilingSource {
|
||||
/// The ceiling to intersect a requested cap with, if any.
|
||||
#[must_use]
|
||||
pub(crate) const fn clamp_tokens(self) -> Option<u32> {
|
||||
match self {
|
||||
Self::Documented(tokens) | Self::Uncatalogued(tokens) => Some(tokens),
|
||||
Self::RouteDeclaredUnknown => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an absent compatibility ceiling is a *declared* unknown for this
|
||||
/// route, rather than a gap in the catalogue.
|
||||
///
|
||||
/// Deliberately an allowlist. Everything not named here is uncatalogued and
|
||||
/// gets the conservative ceiling.
|
||||
#[must_use]
|
||||
fn route_declares_unknown_output_ceiling(provider: ApiProvider, model: &str) -> bool {
|
||||
match provider {
|
||||
// Operator-owned engines: the local server, not this process, owns the
|
||||
// output ceiling, and it is routinely far above any catalogue row.
|
||||
ApiProvider::Ollama | ApiProvider::Sglang | ApiProvider::Vllm => true,
|
||||
// Kimi Code membership ids publish their limits in the membership
|
||||
// catalog rather than the static model catalogue.
|
||||
ApiProvider::Moonshot => crate::config::is_kimi_code_membership_model(model),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the compatibility output ceiling for a route, with its provenance.
|
||||
#[must_use]
|
||||
pub(crate) fn output_ceiling_source(provider: ApiProvider, model: &str) -> OutputCeilingSource {
|
||||
provider_capability(provider, model).max_output.map_or_else(
|
||||
|| {
|
||||
if route_declares_unknown_output_ceiling(provider, model) {
|
||||
OutputCeilingSource::RouteDeclaredUnknown
|
||||
} else {
|
||||
OutputCeilingSource::Uncatalogued(UNCATALOGUED_COMPAT_MAX_OUTPUT_TOKENS)
|
||||
}
|
||||
},
|
||||
OutputCeilingSource::Documented,
|
||||
)
|
||||
}
|
||||
|
||||
/// Effective request output cap for a fully resolved provider/model route.
|
||||
#[must_use]
|
||||
pub(crate) fn effective_max_output_tokens_for_route(
|
||||
@@ -79,22 +151,18 @@ pub(crate) fn effective_max_output_tokens_for_route(
|
||||
route_limits: Option<RouteLimits>,
|
||||
) -> u32 {
|
||||
let requested_cap = effective_max_output_tokens(model);
|
||||
let compatibility_cap = provider_capability(provider, model).max_output;
|
||||
let compatibility_cap = output_ceiling_source(provider, model).clamp_tokens();
|
||||
let route_cap = route_output_limit_tokens(route_limits);
|
||||
// Arbitrary wire aliases on self-hosted engines cannot appear in the
|
||||
// static model catalogue. When that is the only reason the compatibility
|
||||
// cap fell back to 4K, a concrete route limit is the stronger fact. Known
|
||||
// model and hosted-provider caps remain authoritative and are still
|
||||
// intersected with any route maximum.
|
||||
let cap = if provider.is_self_hosted()
|
||||
&& crate::models::max_output_tokens_for_model(model).is_none()
|
||||
&& let Some(route_cap) = route_cap
|
||||
{
|
||||
requested_cap.min(route_cap)
|
||||
} else {
|
||||
let cap = requested_cap.min(compatibility_cap);
|
||||
route_cap.map_or(cap, |route_cap| cap.min(route_cap))
|
||||
};
|
||||
// Unknown means unknown only where a route *declares* it: membership ids
|
||||
// such as the `kimi-for-coding` family, and operator-owned self-hosted
|
||||
// engines. For those there is nothing to clamp against and the requested
|
||||
// cap stands. A model the catalogue simply has no row for is not the same
|
||||
// fact — absence is not permission, so it keeps a conservative ceiling
|
||||
// (see `output_ceiling_source`). Only a concrete route/offering maximum
|
||||
// narrows it further; known compatibility caps stay authoritative and are
|
||||
// still intersected with any route maximum.
|
||||
let cap = compatibility_cap.map_or(requested_cap, |compat| requested_cap.min(compat));
|
||||
let cap = route_cap.map_or(cap, |route_cap| cap.min(route_cap));
|
||||
let Some(window) = route_limits
|
||||
.and_then(|limits| limits.context_tokens)
|
||||
.and_then(|tokens| u32::try_from(tokens).ok())
|
||||
@@ -169,6 +237,55 @@ pub(crate) fn auto_compact_default_for_route(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Absence of a catalogue row is not evidence of a large ceiling. An
|
||||
/// unrecognized wire alias on a remote OpenAI-compatible route keeps the
|
||||
/// conservative compatibility ceiling, with an attributable source.
|
||||
#[test]
|
||||
fn uncatalogued_remote_model_keeps_a_conservative_ceiling() {
|
||||
let source = output_ceiling_source(ApiProvider::Openai, "totally-unknown-alias-v9");
|
||||
assert_eq!(
|
||||
source,
|
||||
OutputCeilingSource::Uncatalogued(UNCATALOGUED_COMPAT_MAX_OUTPUT_TOKENS)
|
||||
);
|
||||
assert_eq!(
|
||||
source.clamp_tokens(),
|
||||
Some(UNCATALOGUED_COMPAT_MAX_OUTPUT_TOKENS)
|
||||
);
|
||||
assert!(
|
||||
effective_max_output_tokens_for_route(
|
||||
ApiProvider::Openai,
|
||||
"totally-unknown-alias-v9",
|
||||
None
|
||||
) <= UNCATALOGUED_COMPAT_MAX_OUTPUT_TOKENS
|
||||
);
|
||||
}
|
||||
|
||||
/// Routes that *declare* an unknown ceiling still avoid the clamp.
|
||||
#[test]
|
||||
fn route_declared_unknown_ceilings_are_not_clamped() {
|
||||
for (provider, model) in [
|
||||
(ApiProvider::Moonshot, "kimi-for-coding"),
|
||||
(ApiProvider::Moonshot, "kimi-for-coding-highspeed"),
|
||||
(ApiProvider::Ollama, "some-local-build"),
|
||||
] {
|
||||
assert_eq!(
|
||||
output_ceiling_source(provider, model),
|
||||
OutputCeilingSource::RouteDeclaredUnknown,
|
||||
"{provider:?}/{model} must declare its unknown ceiling"
|
||||
);
|
||||
assert_eq!(output_ceiling_source(provider, model).clamp_tokens(), None);
|
||||
}
|
||||
// Bare `k3` is a membership id, but unlike the `kimi-for-coding`
|
||||
// family the K3 quickstart documents its output maximum, and the model
|
||||
// catalogue carries it. A documented ceiling is authoritative — the
|
||||
// membership allowlist only covers ids the catalogue has nothing to
|
||||
// say about, and must not turn a real fact back into an unknown.
|
||||
assert_eq!(
|
||||
output_ceiling_source(ApiProvider::Moonshot, "k3"),
|
||||
OutputCeilingSource::Documented(131_072)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_missing_route_metadata_uses_provider_context_floor() {
|
||||
assert_eq!(
|
||||
@@ -267,8 +384,8 @@ mod tests {
|
||||
"arbitrary-local-wire-alias",
|
||||
None,
|
||||
),
|
||||
4_096,
|
||||
"missing route facts must retain the conservative fallback"
|
||||
65_536,
|
||||
"an unknown compatibility cap must not clamp; only the requested cap applies"
|
||||
);
|
||||
assert_eq!(
|
||||
effective_max_output_tokens_for_route(
|
||||
@@ -283,4 +400,82 @@ mod tests {
|
||||
"known model caps must remain authoritative on self-hosted routes"
|
||||
);
|
||||
}
|
||||
|
||||
/// #4368 follow-up: the Kimi Code membership ids deliberately have no
|
||||
/// static output cap (the membership catalog owns their limits). The old
|
||||
/// generic `unwrap_or(4096)` in `provider_capability` turned that unknown
|
||||
/// into a hard 4K clamp here, silently truncating every offline membership
|
||||
/// turn. Unknown must mean "no compatibility clamp".
|
||||
#[test]
|
||||
fn kimi_membership_unknown_output_cap_does_not_clamp_to_4k() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let _codewhale = crate::test_support::EnvVarGuard::remove("CODEWHALE_MAX_OUTPUT_TOKENS");
|
||||
let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MAX_OUTPUT_TOKENS");
|
||||
|
||||
for model in ["kimi-for-coding", "kimi-for-coding-highspeed"] {
|
||||
assert_eq!(
|
||||
provider_capability(ApiProvider::Moonshot, model).max_output,
|
||||
None,
|
||||
"{model}: membership output ceiling must stay unknown, not a placeholder"
|
||||
);
|
||||
|
||||
let cap = effective_max_output_tokens_for_route(ApiProvider::Moonshot, model, None);
|
||||
assert_eq!(
|
||||
cap,
|
||||
effective_max_output_tokens(model),
|
||||
"{model}: unknown compatibility cap must leave the requested cap intact"
|
||||
);
|
||||
assert_ne!(cap, 4_096, "{model}: must not inherit the old 4K fallback");
|
||||
// No invented sentinel ceiling either.
|
||||
assert_ne!(cap, u32::MAX);
|
||||
assert_ne!(cap, 32_768);
|
||||
}
|
||||
}
|
||||
|
||||
/// A concrete membership offering limit is still authoritative — "unknown
|
||||
/// means no clamp" must not become "never clamp".
|
||||
#[test]
|
||||
fn kimi_membership_route_limit_still_caps_output() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let _codewhale = crate::test_support::EnvVarGuard::remove("CODEWHALE_MAX_OUTPUT_TOKENS");
|
||||
let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MAX_OUTPUT_TOKENS");
|
||||
|
||||
let limits = RouteLimits {
|
||||
context_tokens: Some(262_144),
|
||||
output_tokens: Some(16_384),
|
||||
..RouteLimits::default()
|
||||
};
|
||||
assert_eq!(
|
||||
effective_max_output_tokens_for_route(
|
||||
ApiProvider::Moonshot,
|
||||
"kimi-for-coding",
|
||||
Some(limits),
|
||||
),
|
||||
16_384
|
||||
);
|
||||
}
|
||||
|
||||
/// GLM and MiniMax publish real output ceilings; those stay authoritative
|
||||
/// so relaxing the unknown case cannot leak into known routes.
|
||||
#[test]
|
||||
fn known_glm_and_minimax_output_caps_remain_authoritative() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let _codewhale = crate::test_support::EnvVarGuard::remove("CODEWHALE_MAX_OUTPUT_TOKENS");
|
||||
let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MAX_OUTPUT_TOKENS");
|
||||
|
||||
// GLM 5.2: 1M window, documented 131K output. The requested cap is the
|
||||
// 65,536 API ceiling, so the known cap is above it and does not bind —
|
||||
// what matters is that the capability is *known*.
|
||||
let glm = provider_capability(ApiProvider::Zai, "glm-5.2");
|
||||
assert_eq!(glm.max_output, Some(131_072));
|
||||
|
||||
let minimax = provider_capability(ApiProvider::Minimax, "minimax-m3");
|
||||
assert_eq!(minimax.max_output, Some(524_288));
|
||||
|
||||
// A known cap below the requested cap must still clamp.
|
||||
assert_eq!(
|
||||
effective_max_output_tokens_for_route(ApiProvider::Moonshot, "kimi-k2.7-code", None),
|
||||
32_768,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,8 +580,8 @@ mod tests {
|
||||
// legacy fallback or the 131K max-output field.
|
||||
let cap = crate::config::provider_capability(ApiProvider::OpencodeGo, "kimi-k3");
|
||||
assert_eq!(cap.context_window, 1_048_576);
|
||||
assert_eq!(cap.max_output, 131_072);
|
||||
assert_ne!(cap.context_window, cap.max_output);
|
||||
assert_eq!(cap.max_output, Some(131_072));
|
||||
assert_ne!(Some(cap.context_window), cap.max_output);
|
||||
|
||||
let candidate =
|
||||
resolve_route_candidate(ApiProvider::OpencodeGo, Some("kimi-k3"), None, None, None)
|
||||
|
||||
@@ -3239,6 +3239,8 @@ async fn monitor_separates_lifecycle_start_from_billing_dispatch_and_child_usage
|
||||
billing_mode: crate::cost_status::RouteBillingMode::Metered,
|
||||
dispatched_at,
|
||||
}),
|
||||
base_url: ApiProvider::Stepfun.default_base_url().to_string(),
|
||||
billing_product: crate::route_billing::RouteProduct::Unproven,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -9395,6 +9395,17 @@ async fn run_subagent(
|
||||
// Interactive turns have no runtime owner; their mailbox is the sole
|
||||
// delivery path into the TUI cost projection.
|
||||
if let Some(mb) = runtime.mailbox.as_ref() {
|
||||
// The child's own route billing travels on `usage_route`: the
|
||||
// client this worker actually ran on froze its provider,
|
||||
// identity, endpoint fingerprint, billing surface and billing
|
||||
// mode at construction (`DeepSeekClient::from_parts`), so the
|
||||
// envelope *is* the child's dispatch receipt. It is deliberately
|
||||
// NOT a later ambient `Config` re-read — provider endpoint
|
||||
// variables (`MOONSHOT_BASE_URL`, `KIMI_BASE_URL`, …) are merged
|
||||
// into the *active* provider's table only, so a cross-provider
|
||||
// child's config entry does not describe the endpoint it
|
||||
// dispatched to. An endpoint/credential that names no known
|
||||
// product froze as Unknown and stays Unknown here.
|
||||
let _ = mb.send(MailboxMessage::token_usage(
|
||||
&agent_id,
|
||||
&usage_source_id,
|
||||
|
||||
@@ -811,6 +811,8 @@ fn active_turn_zai_receipt_overrides_all_mutable_parallel_route_metadata() {
|
||||
billing_mode: crate::cost_status::RouteBillingMode::Unknown,
|
||||
dispatched_at: chrono::Utc::now(),
|
||||
}),
|
||||
base_url: crate::config::DEFAULT_ZAI_BASE_URL.to_string(),
|
||||
billing_product: crate::route_billing::RouteProduct::Unproven,
|
||||
}),
|
||||
auto_route_receipt: None,
|
||||
suggestion_authority: None,
|
||||
|
||||
@@ -1188,6 +1188,8 @@ mod tests {
|
||||
billing_mode: crate::cost_status::RouteBillingMode::Unknown,
|
||||
dispatched_at: chrono::Utc::now(),
|
||||
}),
|
||||
base_url: crate::config::DEFAULT_DEEPSEEK_BASE_URL.to_string(),
|
||||
billing_product: crate::route_billing::RouteProduct::Unproven,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1534,6 +1536,8 @@ mod tests {
|
||||
billing_mode: crate::cost_status::RouteBillingMode::Unknown,
|
||||
dispatched_at: chrono::Utc::now(),
|
||||
}),
|
||||
base_url: DEEPSEEK_BASE.to_string(),
|
||||
billing_product: crate::route_billing::RouteProduct::Unproven,
|
||||
};
|
||||
assert!(
|
||||
capture_route_authority(&route).is_none(),
|
||||
|
||||
@@ -458,6 +458,12 @@ pub(super) fn handle_subagent_mailbox_for_turn(
|
||||
// only provider source used by that projection: configured/default
|
||||
// parent routes are not evidence that the child actually used them.
|
||||
record_agent_current_activity(app, message);
|
||||
// The child's own route truth always wins and is never guessed from
|
||||
// provider identity: `route` is the immutable envelope its client was
|
||||
// frozen with at construction, so its billing mode, billing surface
|
||||
// and endpoint fingerprint are the child's dispatch receipt. A child
|
||||
// whose route froze as Unknown stays Unknown.
|
||||
//
|
||||
// Sub-agent spend joins the parent total, so it also joins the
|
||||
// completeness counters `/cost` reports against that total.
|
||||
if app
|
||||
|
||||
@@ -394,6 +394,13 @@ fn accrue_child_token_cost_if_any(app: &mut App, result: &Result<ToolResult, Too
|
||||
let Some(usage) = crate::cost_status::child_usage_from_metadata(metadata) else {
|
||||
return;
|
||||
};
|
||||
// `route` is the child's own dispatch receipt, rehydrated from the
|
||||
// complete `child_*` metadata `attach_child_usage_metadata` emits at the
|
||||
// child's wire boundary (review/verify/rlm are the three producers). An
|
||||
// incomplete or legacy payload rehydrates as `RouteBillingMode::Unknown`,
|
||||
// so a child never inherits the live `app.billing_presentation` chip and a
|
||||
// `/provider` switch between dispatch and arrival cannot retro-bill it.
|
||||
//
|
||||
// Sub-agent spend lands in the same displayed total as parent turns, so it
|
||||
// has to feed the same completeness counters — otherwise `/cost` would call
|
||||
// a total complete while an unpriced child turn is missing from it.
|
||||
|
||||
@@ -3653,6 +3653,16 @@ async fn run_event_loop(
|
||||
|
||||
// Update session cost, and record what the total does
|
||||
// *not* cover so `/cost` can stay honest about it.
|
||||
//
|
||||
// `cost_audit` above came from `cost_envelope()`, i.e.
|
||||
// the billing envelope stamped at the wire boundary
|
||||
// and classified from this turn's frozen receipt. It
|
||||
// is `None` for a route that was never dispatched, and
|
||||
// a route whose receipt named no product classified as
|
||||
// Unknown — either way nothing accrues. A `/provider`
|
||||
// or custom-table switch since dispatch cannot
|
||||
// retro-bill this turn onto another route, because no
|
||||
// ambient `Config` is read here at all.
|
||||
let turn_cost = cost_audit.as_ref().and_then(|audit| audit.estimate);
|
||||
if let Some(audit) = cost_audit.as_ref() {
|
||||
app.record_turn_cost_audit(audit);
|
||||
|
||||
@@ -87,6 +87,8 @@ fn completed_turn_cost_receipt_uses_the_captured_effective_route() {
|
||||
billing_mode: crate::cost_status::RouteBillingMode::Metered,
|
||||
dispatched_at: chrono::Utc::now(),
|
||||
}),
|
||||
base_url: ApiProvider::Deepseek.default_base_url().to_string(),
|
||||
billing_product: crate::route_billing::RouteProduct::Unproven,
|
||||
}),
|
||||
auto_route_receipt: None,
|
||||
suggestion_authority: None,
|
||||
@@ -7643,6 +7645,8 @@ fn turn_liveness_recovers_stalled_in_progress_turn() {
|
||||
billing_mode: crate::cost_status::RouteBillingMode::Metered,
|
||||
dispatched_at: chrono::Utc::now(),
|
||||
}),
|
||||
base_url: String::new(),
|
||||
billing_product: crate::route_billing::RouteProduct::Unproven,
|
||||
}),
|
||||
auto_route_receipt: None,
|
||||
suggestion_authority: None,
|
||||
@@ -7692,6 +7696,8 @@ fn engine_event_disconnect_recovers_live_turn_immediately() {
|
||||
billing_mode: crate::cost_status::RouteBillingMode::Metered,
|
||||
dispatched_at: chrono::Utc::now(),
|
||||
}),
|
||||
base_url: String::new(),
|
||||
billing_product: crate::route_billing::RouteProduct::Unproven,
|
||||
}),
|
||||
auto_route_receipt: None,
|
||||
suggestion_authority: None,
|
||||
@@ -7765,6 +7771,8 @@ fn engine_event_disconnect_cleans_cancelled_turn_metadata() {
|
||||
billing_mode: crate::cost_status::RouteBillingMode::Metered,
|
||||
dispatched_at: chrono::Utc::now(),
|
||||
}),
|
||||
base_url: String::new(),
|
||||
billing_product: crate::route_billing::RouteProduct::Unproven,
|
||||
}),
|
||||
auto_route_receipt: None,
|
||||
suggestion_authority: None,
|
||||
@@ -10223,6 +10231,8 @@ fn turn_started_route_is_captured_before_cancel_suppression() {
|
||||
billing_mode: crate::cost_status::RouteBillingMode::Metered,
|
||||
dispatched_at: created_at.clone(),
|
||||
}),
|
||||
base_url: String::new(),
|
||||
billing_product: crate::route_billing::RouteProduct::Unproven,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -10285,6 +10295,8 @@ fn turn_started_suggestion_authority_comes_from_the_route_receipt_not_config() {
|
||||
billing_mode: crate::cost_status::RouteBillingMode::Unknown,
|
||||
dispatched_at: chrono::Utc::now(),
|
||||
}),
|
||||
base_url: String::new(),
|
||||
billing_product: crate::route_billing::RouteProduct::Unproven,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -10325,6 +10337,8 @@ fn turn_started_without_a_route_receipt_captures_no_suggestion_authority() {
|
||||
billing_mode: crate::cost_status::RouteBillingMode::Unknown,
|
||||
dispatched_at: chrono::Utc::now(),
|
||||
}),
|
||||
base_url: String::new(),
|
||||
billing_product: crate::route_billing::RouteProduct::Unproven,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -10359,6 +10373,8 @@ fn engine_error_health_accounting_uses_active_turn_route() {
|
||||
billing_mode: crate::cost_status::RouteBillingMode::Metered,
|
||||
dispatched_at: chrono::Utc::now(),
|
||||
}),
|
||||
base_url: String::new(),
|
||||
billing_product: crate::route_billing::RouteProduct::Unproven,
|
||||
}),
|
||||
};
|
||||
capture_turn_started_metadata(&mut app, &event);
|
||||
@@ -13425,6 +13441,37 @@ fn shell_wait_without_command_uses_task_id_until_command_metadata_arrives() {
|
||||
#[test]
|
||||
fn legacy_child_usage_metadata_fails_closed_without_parent_route_fallback() {
|
||||
let mut app = create_test_app();
|
||||
// An in-process review child publishes no route of its own, so it runs on
|
||||
// the parent turn's client and is billed from the parent's frozen receipt.
|
||||
// Without a receipt there is nothing sound to bill from, so the turn has to
|
||||
// be active for the child to accrue anything.
|
||||
app.active_turn = Some(crate::tui::app::ActiveTurnMetadata {
|
||||
turn_id: "turn-child-usage".to_string(),
|
||||
created_at: chrono::Utc::now(),
|
||||
route: Some(crate::core::events::TurnRoute {
|
||||
provider: ApiProvider::Deepseek,
|
||||
provider_identity: "deepseek".to_string(),
|
||||
model: "deepseek-v4-flash".to_string(),
|
||||
auto_model: false,
|
||||
receipt: None,
|
||||
// The parent turn is a fully dispatched, metered route. Even so,
|
||||
// a child that publishes no route receipt of its own must not
|
||||
// borrow it: the fail-closed answer is Unknown, reported as
|
||||
// missing spend rather than silently inherited.
|
||||
billing: Some(crate::core::events::RouteBillingEnvelope {
|
||||
billing_surface: Some(crate::pricing::FIRST_PARTY_PAYG_BILLING_SURFACE.to_string()),
|
||||
endpoint_fingerprint: crate::cost_status::endpoint_fingerprint(
|
||||
crate::config::DEFAULT_DEEPSEEK_BASE_URL,
|
||||
),
|
||||
billing_mode: crate::cost_status::RouteBillingMode::Metered,
|
||||
dispatched_at: chrono::Utc::now(),
|
||||
}),
|
||||
base_url: crate::config::DEFAULT_DEEPSEEK_BASE_URL.to_string(),
|
||||
billing_product: crate::route_billing::RouteProduct::Unproven,
|
||||
}),
|
||||
auto_route_receipt: None,
|
||||
suggestion_authority: None,
|
||||
});
|
||||
let result = Ok(crate::tools::spec::ToolResult::success("ok").with_metadata(
|
||||
serde_json::json!({
|
||||
"child_model": "deepseek-v4-flash",
|
||||
|
||||
@@ -681,6 +681,19 @@ aliases. When both forms are set the `CODEWHALE_*` value wins; the
|
||||
- `CODEWHALE_MODEL` (preferred) / `DEEPSEEK_MODEL` (legacy alias) — default model for the active provider
|
||||
- `CODEWHALE_BASE_URL` (preferred) / `DEEPSEEK_BASE_URL` (legacy alias) — base URL for the active provider
|
||||
|
||||
`CODEWHALE_BASE_URL` applies to the **active** route only. A request pinned to
|
||||
another provider — a subagent or fleet child, a routed tool, the per-turn
|
||||
auto-router, a picker preview — resolves its endpoint from that provider's own
|
||||
`[providers.<table>]`, then its provider-scoped variable (`MOONSHOT_BASE_URL`,
|
||||
`OPENAI_BASE_URL`, …), then that provider's default. It never inherits the
|
||||
active session's host, and a custom route with no configured `base_url` fails
|
||||
closed on a loopback placeholder rather than borrowing another provider's
|
||||
endpoint. The legacy root `base_url` behaves the same way: written in your
|
||||
config file it stays shared by the DeepSeek and DeepSeek-CN identities as it
|
||||
always has, but a value the environment wrote belongs to the identity it was
|
||||
addressed to. A managed-config overlay that supplies or reselects the effective
|
||||
route's endpoint takes the generic override away from every route.
|
||||
|
||||
Remaining variables:
|
||||
|
||||
- `DEEPSEEK_API_KEY`
|
||||
|
||||
+38
-12
@@ -325,10 +325,10 @@ Kimi remains API-key-only; external consent for Kimi is rejected.
|
||||
| `siliconflow` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.com/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | OpenAI-compatible hosted route. Official docs use the `.com` endpoint. `SILICONFLOW_MODEL` is accepted. Reasoning aliases `deepseek-reasoner` and `deepseek-r1` map to Pro; `deepseek-chat` and `deepseek-v3` map to Flash. |
|
||||
| `siliconflow-CN` | `[providers.siliconflow_cn]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | Uses the SiliconFlow model set | China regional SiliconFlow route. Falls back to `[providers.siliconflow]` for api_key / base_url / model when unset. Select it with `provider = "siliconflow-CN"` or `CODEWHALE_PROVIDER=siliconflow-CN`. |
|
||||
| `arcee` | `[providers.arcee]` | `ARCEE_API_KEY` | `ARCEE_BASE_URL`; default `https://api.arcee.ai/api/v1` | `trinity-large-thinking`, `trinity-large-preview` | Arcee AI direct OpenAI-compatible route, tracked as 256K-context BF16 serving. `ARCEE_MODEL` is accepted. OpenRouter's `arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced model ID; direct Arcee uses the bare `trinity-large-thinking` ID. |
|
||||
| `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | Direct Moonshot: `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`; Kimi Code membership: `k3`, `kimi-for-coding` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. `kimi` and `kimi-k2` aliases select `kimi-k2.7-code`; `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. Kimi thinking streams through `reasoning_content`; Codewhale keeps it in Thinking cells and replays it for thinking/tool-call continuity. For direct K3, use exact `base_url = "https://api.moonshot.ai/v1"` and `model = "kimi-k3"`; it is always-thinking and receives top-level `reasoning_effort = "low" | "high" | "max"` (`off` normalizes to `low`), uses only `max_completion_tokens`, and omits `temperature`/`top_p` per the [K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart). For Kimi Code K3, use a key from the [Kimi Code console](https://www.kimi.com/code/console), exact `base_url = "https://api.kimi.com/coding/v1"`, and bare `model = "k3"`; `off` becomes enabled `low`, while normal dispatched `auto` selects and sends a concrete Codewhale tier. Only an omitted reasoning setting leaves the provider default in control. That membership route defaults safely to 262,144 context tokens; the [Kimi Code model-tier table](https://www.kimi.com/code/docs/en/kimi-code/models.html) grants Allegretto and higher plans up to 1M, which those plans may express as `context_window = 1048576`. `k3[1m]` is Claude Code-only and Codewhale rejects it. `kimi-for-coding` remains the valid K2.7 membership route. Legacy `auth_mode = "kimi_oauth"` fails to API-key guidance without probing Kimi CLI files. Codewhale does not impersonate `kimi_cli` or `kimi_code_cli`. |
|
||||
| `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | Direct Moonshot: `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`; Kimi Code membership: `k3`, `kimi-for-coding`, `kimi-for-coding-highspeed` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. `kimi` and `kimi-k2` aliases select `kimi-k2.7-code`; `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. Kimi thinking streams through `reasoning_content`; Codewhale keeps it in Thinking cells and replays it for thinking/tool-call continuity. For direct K3, use exact `base_url = "https://api.moonshot.ai/v1"` and `model = "kimi-k3"`; it is always-thinking and receives top-level `reasoning_effort = "low" | "high" | "max"` (`off` normalizes to `low`), uses only `max_completion_tokens`, and omits `temperature`/`top_p` per the [K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart). For Kimi Code K3, use a key from the [Kimi Code console](https://www.kimi.com/code/console), exact `base_url = "https://api.kimi.com/coding/v1"`, and bare `model = "k3"`; `off` becomes enabled `low`, while normal dispatched `auto` selects and sends a concrete Codewhale tier. Only an omitted reasoning setting leaves the provider default in control. That membership route defaults safely to 262,144 context tokens; the [Kimi Code model-tier table](https://www.kimi.com/code/docs/en/kimi-code/models.html) grants Allegretto and higher plans up to 1M, which those plans may express as `context_window = 1048576`. `k3[1m]` is Claude Code-only and Codewhale rejects it. `kimi-for-coding` remains the valid K2.7 membership route, and `kimi-for-coding-highspeed` is its own high-speed roster entry (262,144 context); membership ids are rejected on the direct platform endpoint, and `kimi-k3` stays rejected on the membership endpoint. Billing is decided by the endpoint the route resolves to, judged once against the two exact product endpoints: direct Moonshot (`https://api.moonshot.ai/v1` or the default) bills metered with dollar estimates, the exact Kimi Code membership endpoint bills as Kimi Code quota and never shows dollar estimates, and anything else — a gateway host, a neighboring Kimi-hosted path — reports `cost: unknown` rather than borrowing either product. An imported Kimi Code token with no `base_url` in its table still resolves to the membership endpoint, so it bills as Kimi Code quota and never accrues dollars. A completed turn, parent or sub-agent, is billed from the immutable endpoint receipt its own client was built with, never from a later config re-read: `MOONSHOT_BASE_URL`/`KIMI_BASE_URL` are merged into the *active* provider's table only, and an in-turn provider switch can move the ambient config off the route that actually ran. Legacy `auth_mode = "kimi_oauth"` fails to API-key guidance without probing Kimi CLI files. Codewhale does not impersonate `kimi_cli` or `kimi_code_cli`. |
|
||||
| `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general API `https://api.z.ai/api/paas/v4` | `GLM-5.2` default; `GLM-5.1`, `GLM-5-Turbo` available | Z.AI GLM Coding Plan route. `GLM-5.2` is the default; set `model = "GLM-5.1"` or `ZAI_MODEL=GLM-5.1` for the smaller model, or `GLM-5-Turbo` for the fast variant used by faster/explore sub-agents. |
|
||||
| `stepfun` | `[providers.stepfun]` | `STEPFUN_API_KEY`, `STEP_API_KEY` | `STEPFUN_BASE_URL`, `STEP_BASE_URL`; default `https://api.stepfun.ai/v1`; Coding Plan endpoint `https://api.stepfun.ai/step_plan/v1` | `step-3.7-flash` | StepFun / StepFlash direct OpenAI-compatible route. `/provider` setup asks which billing route the key belongs to — pay-as-you-go or Step Plan — validates the key against the chosen endpoint, and writes the answer to `[providers.stepfun].base_url` only. A base URL that is neither recognized route is left alone and the question is skipped. You can also set `[providers.stepfun].base_url` or `STEP_BASE_URL` to the Coding Plan URL by hand. Offline accounting labels recognized routes as `stepfun-payg` or `stepfun-plan` without persisting the raw endpoint, and only the standard PAYG route receives token pricing. `STEPFUN_MODEL` and `STEP_MODEL` are accepted. |
|
||||
| `minimax` | `[providers.minimax]` | `MINIMAX_API_KEY` | `MINIMAX_BASE_URL`; default `https://api.minimax.io/v1`; China `https://api.minimaxi.com/v1` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | MiniMax direct OpenAI-compatible route. Codewhale sends `reasoning_split = true` so MiniMax thinking arrives separately from answer text. Official M3 input modalities are text, image, and video; M2.7 is text-only. |
|
||||
| `minimax` | `[providers.minimax]` | `MINIMAX_API_KEY` | `MINIMAX_BASE_URL`; default `https://api.minimax.io/v1`; China `https://api.minimaxi.com/v1` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | MiniMax direct OpenAI-compatible route. Codewhale sends `reasoning_split = true` so MiniMax thinking arrives separately from answer text. Both MiniMax dialects sell pay-as-you-go and Token Plan over the same endpoints and the same key, so billing is classified from the credential *product*, never from the endpoint or from a default. `mode = "token-plan"` in `[providers.minimax]`/`[providers.minimax_anthropic]`, or a Token Plan key shaped `sk-cp…`, bills as MiniMax Token Plan quota with no dollar estimates; an explicit pay-as-you-go mode (`pay-as-you-go`/`payg`/`metered`) wins over key shape. The key's product prefix is only visible when the key is in config, bound by `api_key_env`, or exported as `MINIMAX_API_KEY` on an official endpoint — a key saved through `codewhale auth set` (secret store / OS keyring) is deliberately not read to classify billing. With no explicit mode and no visible product marker the route reports `cost: unknown` rather than assuming pay-as-you-go, so a Token Plan account is never charged invented dollars. Custom/gateway endpoints also fail closed with `cost: unknown`. Official M3 input modalities are text, image, and video; M2.7 is text-only. |
|
||||
| `minimax-anthropic` | `[providers.minimax_anthropic]` | `MINIMAX_API_KEY` | `MINIMAX_ANTHROPIC_BASE_URL`; default `https://api.minimax.io/anthropic`; China `https://api.minimaxi.com/anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | MiniMax direct Anthropic-compatible Messages route. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`; the route uses `x-api-key`. M3 supports adaptive or disabled thinking. M2.7 always keeps thinking enabled. |
|
||||
| `sglang` | `[providers.sglang]` | Optional `SGLANG_API_KEY` | `SGLANG_BASE_URL`; default `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted OpenAI-compatible route. Localhost deployments commonly omit auth. `SGLANG_MODEL` is accepted. |
|
||||
| `vllm` | `[providers.vllm]` | Optional `VLLM_API_KEY` | `VLLM_BASE_URL`; default `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted vLLM OpenAI-compatible route. Localhost deployments commonly omit auth. `VLLM_MODEL` is accepted. |
|
||||
@@ -538,6 +538,17 @@ differs from the static table, set `[providers.<name>] context_window = N`.
|
||||
The configured value becomes the route-effective context window for prompts,
|
||||
context-pressure checks, compaction, and output-cap budgeting.
|
||||
|
||||
`max_output` is optional and truthful: it is `null` (and omitted from the
|
||||
capability struct on the wire) when the route publishes no output maximum we
|
||||
can stand behind — the Kimi Code membership `kimi-for-coding` family is the
|
||||
canonical example, since the membership catalog owns their limits. An unknown
|
||||
output ceiling is never backfilled with a placeholder, and it applies **no**
|
||||
compatibility clamp to a turn's requested `max_tokens`; only a concrete
|
||||
route/offering maximum narrows the request. A model the catalogue simply has no
|
||||
row for is a different fact — absence is not permission, so an uncatalogued id
|
||||
keeps a conservative ceiling. The "Max output metadata" column below reads
|
||||
`unknown` wherever no documented maximum exists.
|
||||
|
||||
| Provider/model class | Context window | Max output metadata | Thinking support | Cache telemetry | FIM endpoint |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| DeepSeek V4 (`deepseek-v4-pro`, `deepseek-v4-flash`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only |
|
||||
@@ -554,22 +565,23 @@ context-pressure checks, compaction, and output-cap budgeting.
|
||||
| Meta Model API `muse-spark-1.1` | 1,000,000 | 32,000 | yes | no | not documented in code |
|
||||
| OpenAI Codex / ChatGPT route (`openai-codex`) | 400,000 effective | 128,000 | yes | no | route uses Responses payload at `/codex/responses` |
|
||||
| OpenModel default/custom model IDs | 200,000 fallback unless model metadata or config overrides it | 64,000 fallback | model-dependent | no | route uses Messages payload at `/v1/messages` |
|
||||
| Wanjie Ark `reasoner` / `r1` model IDs | 128,000 | 4,096 | yes | no | not documented in code |
|
||||
| Wanjie Ark `reasoner` / `r1` model IDs | 128,000 | unknown (no documented maximum) | yes | no | not documented in code |
|
||||
| Direct Arcee API `trinity-large-thinking` | 262,144 | 262,144 | yes | no | not documented in code |
|
||||
| Direct Arcee API `trinity-large-preview` | 262,144 | 4,096 | no in doctor capability metadata | no | not documented in code |
|
||||
| Direct Arcee API `trinity-large-preview` | 262,144 | unknown (no documented maximum) | no in doctor capability metadata | no | not documented in code |
|
||||
| Direct Moonshot `kimi-k3` | 1,048,576 | 1,048,576 documented maximum; 131,072 provider default | yes | no | exact route uses `max_completion_tokens` and omits fixed sampling fields ([K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart)) |
|
||||
| Kimi Code membership `k3` | 262,144 safe baseline; 1,048,576 with an explicit entitled-plan override | 131,072 conservative default ceiling; membership maximum is not published | yes | no | exact `https://api.kimi.com/coding/v1` route |
|
||||
| Moonshot/Kimi K2.7/K2.6 (`kimi-k2.7-code`, `kimi-k2.6`, Kimi Code `kimi-for-coding`) | 262,144 | 32,768 | yes | no | not documented in code |
|
||||
| Direct Moonshot/Kimi K2.7/K2.6 (`kimi-k2.7-code`, `kimi-k2.6`) | 262,144 | 32,768 | yes | no | provider-reported bundled catalog |
|
||||
| Kimi Code membership `kimi-for-coding`, `kimi-for-coding-highspeed` | 262,144 | unknown — the membership catalog owns these limits and no client-side ceiling is claimed | yes | no | exact `https://api.kimi.com/coding/v1` route |
|
||||
| Direct Z.AI `GLM-5.2` (default) | 1,000,000 | 131,072 | yes | no | not documented in code |
|
||||
| Direct Z.AI `GLM-5.1` | 202,752 | 131,072 | yes | no | not documented in code |
|
||||
| Direct Z.AI `GLM-5-Turbo` | 202,752 | 131,072 | yes | no | faster/explore sub-agent sibling |
|
||||
| Direct MiniMax `MiniMax-M3` | 1,000,000 | 524,288 | yes | no | not documented in code |
|
||||
| Direct MiniMax M2.x models | 204,800 | 4,096 fallback until MiniMax output metadata is promoted | yes | no | not documented in code |
|
||||
| Direct MiniMax M2.x models | 204,800 | unknown until MiniMax output metadata is promoted | yes | no | not documented in code |
|
||||
| MiniMax Messages route (`MiniMax-M3`, `MiniMax-M2.7`) | model-specific values above | model-specific values above | yes | no | route uses `/anthropic/v1/messages` |
|
||||
| Generic `openai` and AtlasCloud | 128,000 | 4,096 | no in doctor capability metadata | no | not documented in code |
|
||||
| Ollama | 8,192 | 4,096 | no | no | not documented in code |
|
||||
| Hugging Face Inference Providers V4 model IDs | 131,072 | 4,096 | yes | no | not documented in code |
|
||||
| Other recognized DeepSeek model IDs | 128,000 unless the model name carries an explicit `Nk` hint | 4,096 | no unless V4/reasoner logic matches | DeepSeek/NIM only | DeepSeek beta only |
|
||||
| Generic `openai` and AtlasCloud | 128,000 | unknown (no documented maximum) | no in doctor capability metadata | no | not documented in code |
|
||||
| Ollama | 8,192 | unknown (no documented maximum) | no | no | not documented in code |
|
||||
| Hugging Face Inference Providers V4 model IDs | 131,072 | unknown (no documented maximum) | yes | no | not documented in code |
|
||||
| Other recognized DeepSeek model IDs | 128,000 unless the model name carries an explicit `Nk` hint | unknown (no documented maximum) | no unless V4/reasoner logic matches | DeepSeek/NIM only | DeepSeek beta only |
|
||||
|
||||
MiniMax M3 uses input-length and service tiers. Codewhale omits
|
||||
`service_tier`, so requests use the standard tier and cost estimates select the
|
||||
@@ -642,8 +654,22 @@ custom endpoints continue to own their model ids.
|
||||
|
||||
`/reasoning <effort>` (and the `reasoning_effort` config key) is translated to
|
||||
each provider's wire dialect by the client before the request is sent. `off`
|
||||
disables thinking where the route supports it. Both exact K3 routes preserve
|
||||
K3 by mapping `off` to their lowest supported tier, `low`. Normal dispatched
|
||||
disables thinking where the route supports it. Both exact K3 routes map `off`
|
||||
to their lowest supported tier, `low`, and the model is never switched to
|
||||
satisfy `off` — but they do so for different reasons:
|
||||
|
||||
- **Kimi Code membership K3** (exact `https://api.kimi.com/coding/v1` with bare
|
||||
`model = "k3"`) — the membership roster declares K3 always-thinking, so `off`
|
||||
cannot be honored without changing what the model is. The clamp preserves the
|
||||
fixed K3 identity.
|
||||
- **Direct Moonshot K3** (exact `https://api.moonshot.ai/v1` with
|
||||
`model = "kimi-k3"`) — this clamp is *defensive*, not a documented contract.
|
||||
The direct platform publishes no `off` state for K3, and Codewhale will not
|
||||
assert a fixed-thinking guarantee it cannot verify for a given key's
|
||||
entitlement, so the requested `off` is normalized to the lowest tier with the
|
||||
live entitlement left unknown.
|
||||
|
||||
Normal dispatched
|
||||
`auto` uses Codewhale's auto-reasoning selector and sends a concrete tier;
|
||||
only an omitted reasoning setting leaves the provider default in control.
|
||||
Providers marked "omitted" receive no reasoning fields at all for that tier.
|
||||
|
||||
Reference in New Issue
Block a user