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:
Hmbown
2026-07-27 05:31:18 -07:00
24 changed files with 3389 additions and 376 deletions
+1
View File
@@ -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
View File
@@ -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(())
+3
View File
@@ -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
View File
@@ -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(())
}
+29 -2
View File
@@ -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,
+6 -2
View File
@@ -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!(
+34
View File
@@ -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>,
+3 -1
View File
@@ -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,
+21 -5
View File
@@ -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,
+4 -1
View File
@@ -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));
+32 -8
View File
@@ -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));
File diff suppressed because it is too large Load Diff
+212 -17
View File
@@ -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,
);
}
}
+2 -2
View File
@@ -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)
+2
View File
@@ -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?;
+11
View File
@@ -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,
+2
View File
@@ -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,
+4
View File
@@ -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(),
+6
View File
@@ -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
+7
View File
@@ -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.
+10
View File
@@ -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);
+47
View File
@@ -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",
+13
View File
@@ -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
View File
@@ -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.