refactor(config): second batch of P3 edge cuts, plus #7069 composer history isolation

Continues P3, moving tui's config out of codewhale-tui. The destination is
codewhale-runtime; 8b5baa7a5f explains why. Each move is one-way, with every
caller migrated and no re-export left behind.

Edges cut from config:
- tools:
  - WorkshopConfig (the `[workshop]` schema) moves to codewhale-config.
  - Its methods become free functions in large_output_router
    (install_active_workshop, active_read_result_max_bytes,
    active_tool_result_max_bytes, workshop_threshold_for), because the
    orphan rule forbids inherent impls on a foreign type.
  - Config::user_input_limits becomes UserInputLimits::from_config.
  - Two intra-doc links into tools become plain code spans.
- plugins: PluginId (with as_str and Display) and PluginAuthority move to
  codewhale-config, and all 30 using files import them from there.
- provider_lake:
  - The provider-capability matrix (ProviderCapability, RequestPayloadMode,
    provider_capability, provider_capability_with_wire) moves out of
    config into provider_lake, its only home of use.
  - configured_model_for_route moves into config, which is what it reads.
  - deepseek_alias_deprecation becomes pub(crate) for the moved code.
  - wire_config_prefers_responses is now unused and is deleted.
- settings/config_keys -> reasoning_preference: the reasoning-effort
  spelling table moves to
  codewhale_config::settings_schema::canonical_reasoning_effort.
  ReasoningEffort::parse_strict delegates to it and maps the canonical name
  back through as_setting, so there is one table. settings and config_keys
  call the table directly.
- config_keys -> commands: edit_distance and best_suggestion_score move to
  codewhale_config::suggest. The duplicate edit_distance in
  core/engine/tool_catalog.rs is folded into it.
- credentials -> doctor: structural_url_authority moves to codewhale-config,
  which gains a `url` dependency. url is already in the lockfile through
  reqwest.
- Four doc links from config and settings into skills and session_resume
  become plain code spans; rustdoc can't resolve them once config leaves
  tui.

#7069: composer history ignored CODEWHALE_HOME. With an explicit
CODEWHALE_HOME, history now reads and writes
$CODEWHALE_HOME/composer_history.jsonl, with no ~/.deepseek fallback,
through the existing codewhale_paths resolver. Without one, resolution is
unchanged. An invalid CODEWHALE_HOME resolves to no history rather than
the ambient home.

Remaining config edges, all outside this batch:
- oauth (33 refs), apply_startup_providers (5), codex_model_cache (1) and
  provider_readiness (2): these wait for founder decision S7.
- read_denylist and HooksConfig.
- The sandbox requirements table.

Verified:
- scripts/dev-cargo.sh check --workspace --all-targets --locked: 0
  warnings, 0 errors (11m 38s). That ran on 135319aa1a.
- After rebasing onto 4d2b3fdb1b, `check -p codewhale-cli --all-targets`:
  no errors. The later main commits touched only cli/src/update.rs, the
  installers, web and docs; none of the moved symbols.
- cargo fmt --check: clean. module_graph --check: PASS. Dead-code budget:
  exactly at 212. Blocking-call budget: within 543.
- nextest:
  - codewhale-config: 806 passed, 1 skipped.
  - codewhale-tui, filtered to large_output_router, user_input, plugins,
    provider_lake, config::tests, composer_history, reasoning_preference,
    settings, config_keys, doctor, commands::tests, tool_catalog,
    extension_host plugin tests, model_profile and route_budget: 1139
    passed.
- Not run: clippy, doc, Windows, hosted CI.

Refs #7069 CW-6129 CW-6382 CW-7525

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-24 23:24:13 -07:00
1 parent a43f4c1e78
commit 2947e4c51d
70 files changed
+752 -754

No files matched your search

Generated
+1
View File
@@ -986,6 +986,7 @@ dependencies = [
"toml 1.1.4+spec-1.1.0",
"toml_edit 0.25.15+spec-1.1.0",
"tracing",
"url",
"windows-sys 0.61.2",
]
+5 -1
View File
@@ -2993,7 +2993,11 @@ fn capture_serve_full_access(cli: &mut Cli) {
return;
};
let before = args.args.len();
let separator = args.args.iter().position(|arg| arg == "--").unwrap_or(before);
let separator = args
.args
.iter()
.position(|arg| arg == "--")
.unwrap_or(before);
let mut index = 0;
args.args.retain(|arg| {
index += 1;
+1
View File
@@ -26,6 +26,7 @@ libc = "0.2"
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
url = "2.5"
tempfile.workspace = true
toml.workspace = true
toml_edit.workspace = true
+85
View File
@@ -25,6 +25,7 @@ pub mod resolve;
pub mod route;
pub mod settings_schema;
pub mod setup_state;
pub mod suggest;
pub mod user_constitution;
#[cfg(windows)]
pub mod windows_identity;
@@ -1724,6 +1725,68 @@ pub struct ToolsToml {
pub extras: BTreeMap<String, toml::Value>,
}
/// Existing `[workshop]` threshold configuration, retained for compatibility.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct WorkshopConfig {
/// Token threshold above which results become handle-only evidence.
#[serde(default)]
pub large_output_threshold_tokens: Option<usize>,
/// Per-tool threshold overrides (tool name → token limit). A tool whose
/// name appears here uses this limit instead of
/// `large_output_threshold_tokens`.
#[serde(default)]
pub per_tool_thresholds: Option<std::collections::HashMap<String, usize>>,
/// Optional model-visible byte budget for a single `read` / `read_file`
/// result. Absent keeps the compile-time default (#5367).
#[serde(default)]
pub read_result_max_bytes: Option<usize>,
/// Optional model-visible byte budget for a generic tool result after
/// spillover. Absent keeps the compile-time default (#5367).
#[serde(default)]
pub tool_result_max_bytes: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PluginId(pub String);
impl PluginId {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Persistable proof of the exact reviewed plugin authority attached to a
/// Skill or MCP server. Runtime contexts keep this receipt instead of a
/// pointer to mutable process-global discovery state, and revalidate it at
/// every side-effect boundary.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginAuthority {
pub plugin_id: PluginId,
pub plugin_name: String,
pub workspace: std::path::PathBuf,
pub state_path: std::path::PathBuf,
pub source_manifest: std::path::PathBuf,
pub staged_manifest: std::path::PathBuf,
pub content_hash: String,
pub capability_hash: String,
/// Monotonic generation of this plugin's persisted authority. Any trust,
/// enablement, disablement, or revocation transition invalidates receipts
/// held by another process immediately, even when hashes are unchanged.
pub state_generation: u64,
}
impl std::fmt::Display for PluginId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// Protocol owner selected only by user configuration. Rust is the default;
/// Host explicitly selects the SDK and never falls back to Rust on refusal.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -1734,6 +1797,28 @@ pub enum McpBackend {
Host,
}
/// Return only the non-secret network authority of a configured URL.
///
/// Userinfo, path, query keys and values, and fragments are all omitted because
/// every one of those components can carry credentials. Parse failures also
/// omit the original input rather than echoing an attacker-controlled value.
#[must_use]
pub fn structural_url_authority(url: &str) -> String {
let Some(parsed) = url::Url::parse(url).ok() else {
return "unparseable (configured value omitted)".to_string();
};
let Some(host) = parsed.host_str() else {
return "unparseable (configured value omitted)".to_string();
};
// Url::host_str already includes the brackets around an IPv6 address.
let mut authority = format!("{}://{host}", parsed.scheme());
if let Some(port) = parsed.port() {
authority.push(':');
authority.push_str(&port.to_string());
}
authority
}
/// Default snapshot retention window: 7 days.
pub const DEFAULT_SNAPSHOT_MAX_AGE: std::time::Duration =
std::time::Duration::from_secs(7 * 24 * 60 * 60);
+20
View File
@@ -328,6 +328,26 @@ const COST_CURRENCY: &[SettingOption] = &[
/// and the command cannot drift. Labels stay empty: an undeclared label means
/// "show the raw value", and a capitalized literal would also have to be
/// localized in every shipped locale to satisfy the schema message-key check.
/// Canonical setting value for a reasoning-effort spelling, accepting the
/// documented aliases. Unknown values are refused with the full vocabulary.
pub fn canonical_reasoning_effort(value: &str) -> Result<&'static str, String> {
let trimmed = value.trim();
match trimmed.to_ascii_lowercase().as_str() {
"off" | "disabled" | "none" | "false" => Ok("off"),
"minimal" => Ok("minimal"),
"low" | "minimum" | "light" => Ok("low"),
"medium" | "mid" => Ok("medium"),
"high" => Ok("high"),
"xhigh" => Ok("xhigh"),
"auto" | "automatic" => Ok("auto"),
"ultra" | "ultracode" => Ok("ultra"),
"max" | "maximum" => Ok("max"),
_ => Err(format!(
"Unrecognized reasoning effort {trimmed:?}. Expected one of: auto, off, minimal, low, medium, high, xhigh, ultra, or max."
)),
}
}
const REASONING_EFFORT: &[SettingOption] = &[
SettingOption::new("auto", "", ""),
SettingOption::new("off", "", ""),
+62
View File
@@ -0,0 +1,62 @@
#[must_use]
pub fn edit_distance(a: &str, b: &str) -> usize {
if a == b {
return 0;
}
if a.is_empty() {
return b.chars().count();
}
if b.is_empty() {
return a.chars().count();
}
let b_chars: Vec<char> = b.chars().collect();
let mut previous: Vec<usize> = (0..=b_chars.len()).collect();
let mut current = vec![0usize; b_chars.len() + 1];
for (i, a_ch) in a.chars().enumerate() {
current[0] = i + 1;
for (j, b_ch) in b_chars.iter().enumerate() {
let cost = if a_ch == *b_ch { 0 } else { 1 };
let delete = previous[j + 1] + 1;
let insert = current[j] + 1;
let substitute = previous[j] + cost;
current[j + 1] = delete.min(insert).min(substitute);
}
std::mem::swap(&mut previous, &mut current);
}
previous[b_chars.len()]
}
#[must_use]
pub fn best_suggestion_score<'a>(
query: &str,
candidates: impl IntoIterator<Item = &'a str>,
) -> Option<(u8, usize)> {
let mut best: Option<(u8, usize)> = None;
for candidate in candidates {
let prefix_match = candidate.starts_with(query) || query.starts_with(candidate);
let contains_match = candidate.contains(query) || query.contains(candidate);
let distance = edit_distance(candidate, query);
let close_typo = distance <= 2;
if !(prefix_match || contains_match || close_typo) {
continue;
}
let rank = if prefix_match {
0
} else if contains_match {
1
} else {
2
};
match best {
Some((best_rank, best_distance))
if rank > best_rank || (rank == best_rank && distance >= best_distance) => {}
_ => best = Some((rank, distance)),
}
}
best
}
+1 -1
View File
@@ -1796,7 +1796,7 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
// endpoint owns its model namespace, so an id declared for the
// saved DeepSeek route validates verbatim before the catalog
// normalization runs.
if crate::provider_lake::configured_model_for_route(
if crate::config::configured_model_for_route(
&saved,
ProviderKind::Deepseek,
identity.key.as_str(),
+6 -63
View File
@@ -463,67 +463,6 @@ pub fn switch_mode(app: &mut App, mode: AppMode) -> String {
config_main_host::switch_mode(app, mode)
}
fn edit_distance(a: &str, b: &str) -> usize {
if a == b {
return 0;
}
if a.is_empty() {
return b.chars().count();
}
if b.is_empty() {
return a.chars().count();
}
let b_chars: Vec<char> = b.chars().collect();
let mut previous: Vec<usize> = (0..=b_chars.len()).collect();
let mut current = vec![0usize; b_chars.len() + 1];
for (i, a_ch) in a.chars().enumerate() {
current[0] = i + 1;
for (j, b_ch) in b_chars.iter().enumerate() {
let cost = if a_ch == *b_ch { 0 } else { 1 };
let delete = previous[j + 1] + 1;
let insert = current[j] + 1;
let substitute = previous[j] + cost;
current[j + 1] = delete.min(insert).min(substitute);
}
std::mem::swap(&mut previous, &mut current);
}
previous[b_chars.len()]
}
pub(crate) fn best_suggestion_score<'a>(
query: &str,
candidates: impl IntoIterator<Item = &'a str>,
) -> Option<(u8, usize)> {
let mut best: Option<(u8, usize)> = None;
for candidate in candidates {
let prefix_match = candidate.starts_with(query) || query.starts_with(candidate);
let contains_match = candidate.contains(query) || query.contains(candidate);
let distance = edit_distance(candidate, query);
let close_typo = distance <= 2;
if !(prefix_match || contains_match || close_typo) {
continue;
}
let rank = if prefix_match {
0
} else if contains_match {
1
} else {
2
};
match best {
Some((best_rank, best_distance))
if rank > best_rank || (rank == best_rank && distance >= best_distance) => {}
_ => best = Some((rank, distance)),
}
}
best
}
fn suggest_command_names(
input: &str,
limit: usize,
@@ -549,7 +488,9 @@ fn suggest_command_names(
.copied()
.filter(|alias| user_commands.get(alias).is_none()),
);
if let Some((rank, distance)) = best_suggestion_score(&query, candidates) {
if let Some((rank, distance)) =
codewhale_config::suggest::best_suggestion_score(&query, candidates)
{
scored.push((rank, distance, command.name.to_string()));
}
}
@@ -562,7 +503,9 @@ fn suggest_command_names(
.is_some_and(|resolved| resolved.name == command.name)
}),
);
if let Some((rank, distance)) = best_suggestion_score(&query, candidates) {
if let Some((rank, distance)) =
codewhale_config::suggest::best_suggestion_score(&query, candidates)
{
scored.push((rank, distance, command.name.clone()));
}
}
+2 -2
View File
@@ -79,7 +79,7 @@ pub struct UserCommandMetadata {
pub pausable: bool,
pub aliases: Vec<String>,
pub hidden: bool,
pub plugin_authority: Option<crate::plugins::types::PluginAuthority>,
pub plugin_authority: Option<codewhale_config::PluginAuthority>,
/// Set for a command contributed by an extension-host plugin: it runs in
/// the host (`command/run`) instead of expanding `body`.
pub extension: Option<crate::extension_host::command::ExtensionCommandRef>,
@@ -532,7 +532,7 @@ struct CommandSourceEntry {
name: String,
content: String,
path: PathBuf,
plugin_authority: Option<crate::plugins::types::PluginAuthority>,
plugin_authority: Option<codewhale_config::PluginAuthority>,
/// Came from a workspace (repository) directory rather than the user's
/// own global store or a reviewed plugin.
workspace_sourced: bool,
+6
View File
@@ -47,6 +47,12 @@ fn default_history_path() -> Option<PathBuf> {
if let Some(home) = crate::test_support::unsealed_state_dir(".") {
return history_path_with_home(Some(home));
}
if codewhale_paths::codewhale_home_is_explicit() {
return codewhale_paths::codewhale_home()
.ok()
.flatten()
.map(|home| home.join(HISTORY_FILE_NAME));
}
history_path_with_home(crate::config::effective_home_dir())
}
+27 -315
View File
@@ -161,50 +161,6 @@ fn normalize_subagent_provider_key(value: &str) -> String {
.collect()
}
// ============================================================================
// Provider Capability Matrix
// ============================================================================
/// Known capabilities for a provider + resolved-model combination.
///
/// Returned by [`provider_capability`] to describe what a given provider
/// supports for the resolved model string. All fields are derived from
/// static knowledge (release docs, API guides) rather than live API probes.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct ProviderCapability {
/// Canonical provider identifier.
pub provider: ProviderKind,
/// Resolved model identifier that will be sent in the API payload.
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, when one is
/// actually known.
///
/// `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.
pub cache_telemetry_supported: bool,
/// Which request-payload dialect the provider uses.
pub request_payload_mode: RequestPayloadMode,
/// Deprecation metadata for compatibility aliases that are still accepted.
#[serde(skip_serializing_if = "Option::is_none")]
pub alias_deprecation: Option<ModelAliasDeprecation>,
}
pub const DEEPSEEK_ALIAS_RETIREMENT_DATE: &str = "2026-07-24";
pub const DEEPSEEK_ALIAS_RETIREMENT_UTC: &str = "2026-07-24T15:59:00Z";
pub use codewhale_config::catalog::reviewed::constants::DEEPSEEK_ALIAS_REPLACEMENT;
@@ -219,253 +175,25 @@ pub struct ModelAliasDeprecation {
pub notice: String,
}
/// Which request-payload dialect the provider speaks.
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub enum RequestPayloadMode {
/// Standard OpenAI-compatible `/v1/chat/completions` payload.
ChatCompletions,
/// OpenAI Responses API payload.
Responses,
/// Native Anthropic Messages API `/v1/messages` payload (#3014).
AnthropicMessages,
}
/// Resolve the provider capability for a given [`ProviderKind`] and resolved
/// model string.
///
/// The `resolved_model` should be the final model identifier that will appear
/// in the API payload (after normalization / provider-specific mapping).
#[must_use]
pub fn provider_capability(provider: ProviderKind, resolved_model: &str) -> ProviderCapability {
provider_capability_with_wire(provider, resolved_model, None)
}
/// Wire-aware variant of [`provider_capability`] that respects
/// `wire = "responses" | "anthropic" | "chat"` for `Custom` providers.
///
/// Built-ins keep their fixed policy; `Custom` defaults to `Chat` when `wire`
/// is absent so existing configs stay compatible. Mirrors
/// `crates/tui/src/client.rs::provider_wire_format_for_config` and the
/// `Custom` comment in `crates/config/src/provider.rs`.
#[must_use]
pub fn provider_capability_with_wire(
pub(crate) fn configured_model_for_route<'a>(
config: &'a Config,
provider: ProviderKind,
resolved_model: &str,
wire: Option<&str>,
) -> ProviderCapability {
// Custom wire overrides must be checked before the generic fallback so
// `[providers.<name>] wire = "responses"` / `"anthropic"` is honored.
if provider == ProviderKind::Custom {
if wire_config_prefers_anthropic(wire) {
return ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window: codewhale_models::context_window_for_model(resolved_model)
.unwrap_or(codewhale_models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS),
max_output: codewhale_models::max_output_tokens_for_model(resolved_model),
thinking_supported: codewhale_models::model_supports_reasoning(resolved_model),
cache_telemetry_supported: false,
request_payload_mode: RequestPayloadMode::AnthropicMessages,
alias_deprecation: None,
};
}
if wire_config_prefers_responses(wire) {
return ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window: codewhale_models::context_window_for_model(resolved_model)
.unwrap_or(codewhale_models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS),
max_output: codewhale_models::max_output_tokens_for_model(resolved_model),
thinking_supported: codewhale_models::model_supports_reasoning(resolved_model),
cache_telemetry_supported: false,
request_payload_mode: RequestPayloadMode::Responses,
alias_deprecation: None,
};
}
}
if matches!(
provider,
ProviderKind::Anthropic | ProviderKind::MinimaxAnthropic | ProviderKind::Openmodel
) {
return ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
// 200K is the conservative Anthropic floor; 4.6+ models resolve
// their 1M windows from models.rs rows (#3014).
context_window: codewhale_models::context_window_for_model(resolved_model)
.unwrap_or(200_000),
// 64K is the documented Anthropic Messages floor. For a model
// the catalogue describes this carries its documented ceiling;
// for an unknown one it is an *assumed* floor, and
// `route_budget::output_ceiling_source` labels it unverified so
// no receipt renders it as "documented" (#5440).
max_output: Some(
codewhale_models::max_output_tokens_for_model(resolved_model).unwrap_or(64_000),
),
thinking_supported: codewhale_models::model_supports_reasoning(resolved_model),
cache_telemetry_supported: matches!(provider, ProviderKind::Anthropic),
request_payload_mode: RequestPayloadMode::AnthropicMessages,
alias_deprecation: None,
};
}
if matches!(provider, ProviderKind::OpenaiCodex) {
return ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window: OPENAI_CODEX_EFFECTIVE_CONTEXT_WINDOW_TOKENS,
// 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. It is
// an assumption, not a documented fact — receipts label it
// unverified (#5440).
max_output: Some(4096),
thinking_supported: true,
cache_telemetry_supported: false,
request_payload_mode: RequestPayloadMode::Responses,
alias_deprecation: None,
};
}
// #3023: Delete the Openai/Atlascloud/Moonshot early-return so these
// providers use the generic model-based path below, which correctly
// resolves context windows, output limits, and thinking support from
// models.rs lookups. Ollama also falls through to model-based lookups
// with 8192 as the last-resort fallback instead of a hardcoded floor.
if matches!(provider, ProviderKind::XiaomiMimo) {
return ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window: codewhale_models::context_window_for_model(resolved_model)
.unwrap_or(codewhale_models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS),
// No documented output maximum for these routes: stay unknown so
// no compatibility clamp is applied downstream.
max_output: codewhale_models::max_output_tokens_for_model(resolved_model),
thinking_supported: codewhale_models::model_supports_reasoning(resolved_model),
cache_telemetry_supported: false,
request_payload_mode: RequestPayloadMode::ChatCompletions,
alias_deprecation: None,
};
}
if matches!(provider, ProviderKind::Arcee) {
return ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window: codewhale_models::context_window_for_model(resolved_model)
.unwrap_or(codewhale_models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS),
// No documented output maximum for these routes: stay unknown so
// no compatibility clamp is applied downstream.
max_output: codewhale_models::max_output_tokens_for_model(resolved_model),
thinking_supported: codewhale_models::model_supports_reasoning(resolved_model),
cache_telemetry_supported: false,
request_payload_mode: RequestPayloadMode::ChatCompletions,
alias_deprecation: None,
};
}
let model_lower = resolved_model.to_ascii_lowercase();
let alias_deprecation = if matches!(
provider,
ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
) {
deepseek_alias_deprecation(&model_lower)
} else {
None
};
let official_deepseek =
canonical_official_deepseek_model_id(&model_lower).is_some() || alias_deprecation.is_some();
let is_reasoner = matches!(provider, ProviderKind::WanjieArk)
&& (model_lower.contains("reasoner") || model_lower.contains("r1"));
// Provider-owned wire IDs can have exact catalog facts without a legacy
// model-only row. Reuse those facts before conservative fallback budgets.
let offering =
crate::provider_lake::bundled_catalog_offering_for_model(provider, resolved_model);
let context_window = if official_deepseek {
codewhale_models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
} else if let Some(window) = codewhale_models::context_window_for_model(resolved_model) {
window
} else if let Some(window) = offering
.as_ref()
.and_then(|row| row.limit.as_ref())
.and_then(|limit| limit.context)
.and_then(|window| u32::try_from(window).ok())
{
window
} else if matches!(provider, ProviderKind::Ollama) {
8192
} else {
codewhale_models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS
};
// Output limits require an exact catalog row or an explicitly recognized
// compatibility alias. A family-name match cannot document a new model.
// The catalog 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 = codewhale_models::max_output_tokens_for_model(resolved_model)
.or_else(|| {
// Provider-owned wire IDs need not exist in the legacy model-only
// catalog (for example Fireworks' accounts/... slug). Reuse the
// exact bundled offering instead of inferring a family ceiling.
offering
.as_ref()
.and_then(|offering| offering.limit.as_ref())
.and_then(|limit| limit.output)
.and_then(|limit| u32::try_from(limit).ok())
})
.or_else(|| {
canonical_official_deepseek_model_id(resolved_model)
.or_else(|| {
alias_deprecation
.as_ref()
.map(|_| DEEPSEEK_ALIAS_REPLACEMENT)
})
.and_then(codewhale_models::max_output_tokens_for_model)
});
// Exact catalog reasoning facts and recognized compatibility aliases only.
let thinking_supported = official_deepseek
|| is_reasoner
|| offering
.as_ref()
.is_some_and(|row| row.reasoning == Some(true))
|| codewhale_models::model_supports_reasoning(resolved_model);
// Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.
let cache_telemetry_supported = matches!(
provider,
ProviderKind::Deepseek | ProviderKind::NvidiaNim | ProviderKind::Volcengine
);
let request_payload_mode = if matches!(
provider,
ProviderKind::DeepseekAnthropic | ProviderKind::MinimaxAnthropic | ProviderKind::Openmodel
) {
RequestPayloadMode::AnthropicMessages
} else {
RequestPayloadMode::ChatCompletions
};
ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window,
max_output,
thinking_supported,
cache_telemetry_supported,
request_payload_mode,
alias_deprecation,
identity: &str,
base_url: &str,
model: &str,
) -> Option<&'a codewhale_config::catalog::configured::ConfiguredModel> {
// Account-owned OAuth rosters retain their separate authority.
if provider == ProviderKind::OpenaiCodex {
return None;
}
let models = config.custom_models.as_deref()?;
codewhale_config::catalog::configured::validate_configured_models(models).ok()?;
models
.iter()
.find(|row| row.id == model && row.matches_route(identity, base_url))
}
fn deepseek_alias_deprecation(model_lower: &str) -> Option<ModelAliasDeprecation> {
pub(crate) fn deepseek_alias_deprecation(model_lower: &str) -> Option<ModelAliasDeprecation> {
match model_lower {
"deepseek-chat" | "deepseek-reasoner" => Some(ModelAliasDeprecation {
alias: model_lower.to_string(),
@@ -903,7 +631,7 @@ pub(crate) fn legacy_deepseek_alias_effort_for_route(
/// fallback** (#4188).
///
/// Preferred sources are the live Models.dev catalog and the offline bundled
/// snapshot via [`crate::provider_lake`]. Call this directly only for
/// snapshot via `crate::provider_lake`. Call this directly only for
/// Codewhale-only / local providers Models.dev does not represent, or when
/// probing the fallback table in tests. Picker, inventory, and subagent
/// surfaces must go through the provider lake.
@@ -1362,13 +1090,13 @@ pub struct ToolsConfig {
/// Ceiling on how many questions one `request_user_input` call may ask
/// (#5949). `None` uses
/// [`crate::tools::user_input::DEFAULT_MAX_QUESTIONS`] (6). Values outside
/// `DEFAULT_MAX_QUESTIONS` (6). Values outside
/// `1..=10` are clamped with a warning rather than failing the load.
#[serde(default)]
pub user_input_max_questions: Option<u32>,
/// Ceiling on how many options each `request_user_input` question may
/// offer. `None` uses [`crate::tools::user_input::DEFAULT_MAX_OPTIONS`]
/// offer. `None` uses `DEFAULT_MAX_OPTIONS`
/// (4). Values outside `2..=10` are clamped with a warning.
#[serde(default)]
pub user_input_max_options: Option<u32>,
@@ -2494,8 +2222,8 @@ pub struct Config {
/// Community skill installer settings (#140). When absent, installer
/// commands fall back to the bundled defaults
/// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +
/// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).
/// (`crate::skills::install::DEFAULT_REGISTRY_URL` +
/// `crate::skills::install::DEFAULT_MAX_SIZE_BYTES`).
#[serde(default)]
pub skills: Option<SkillsConfig>,
@@ -2592,7 +2320,7 @@ pub struct Config {
/// Workshop / large-tool-output routing (#548). When absent, the global
/// default threshold of 4 096 tokens applies and routing is active.
#[serde(default)]
pub workshop: Option<crate::tools::large_output_router::WorkshopConfig>,
pub workshop: Option<codewhale_config::WorkshopConfig>,
/// Vision model configuration for the `image_analyze` tool.
#[serde(default)]
@@ -2908,7 +2636,7 @@ pub struct RuntimeApiConfig {
#[derive(Debug, Clone, Deserialize, Default)]
pub struct SkillsConfig {
/// Curated registry index. `/skill install <name>` looks up the spec here.
/// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].
/// Defaults to `crate::skills::install::DEFAULT_REGISTRY_URL`.
#[serde(default)]
pub registry_url: Option<String>,
/// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed
@@ -3020,7 +2748,7 @@ pub struct ProviderConfig {
pub kind: Option<String>,
/// Runtime-only receipt; a config file cannot manufacture plugin authority.
#[serde(skip)]
pub plugin_authority: Option<crate::plugins::types::PluginAuthority>,
pub plugin_authority: Option<codewhale_config::PluginAuthority>,
/// Name of the environment variable holding this custom provider's API key
/// (#1519), e.g. `api_key_env = "EXAMPLE_API_KEY"`. The key value itself is
/// never stored in config; only the env var name is.
@@ -4081,18 +3809,6 @@ impl Config {
.unwrap_or_default()
}
/// Effective `request_user_input` payload ceilings for this session
/// (#5949). Resolved once per engine build; out-of-range `[tools]` values
/// clamp with a single warning naming the key and the value used.
#[must_use]
pub fn user_input_limits(&self) -> crate::tools::user_input::UserInputLimits {
let tools = self.tools.as_ref();
crate::tools::user_input::UserInputLimits::from_config_values(
tools.and_then(|t| t.user_input_max_questions),
tools.and_then(|t| t.user_input_max_options),
)
}
/// Effective wait for a user-input answer or an approval decision
/// (#6003). `None` or `0` waits until the person answers or cancels.
/// A positive value bounds that one wait; values above 24h clamp.
@@ -4291,7 +4007,7 @@ impl Config {
.flatten()
})
.unwrap_or_default();
let declared = crate::provider_lake::configured_model_for_route(
let declared = configured_model_for_route(
&scoped,
provider,
identity.key.as_str(),
@@ -4390,7 +4106,7 @@ impl Config {
&& !model.trim().eq_ignore_ascii_case("auto")
&& !provider_passes_model_through(active_provider)
&& !self.active_provider_preserves_custom_base_url_model()
&& crate::provider_lake::configured_model_for_route(
&& configured_model_for_route(
self,
active_provider,
identity.key.as_str(),
@@ -5611,7 +5327,7 @@ impl Config {
}
let declared = |model: &str| {
crate::provider_lake::configured_model_for_route(
configured_model_for_route(
self,
provider,
identity.key.as_str(),
@@ -9116,7 +8832,7 @@ fn normalize_model_config(config: &mut Config) {
continue;
};
for model in config.custom_models.as_deref().unwrap_or_default() {
if crate::provider_lake::configured_model_for_route(
if configured_model_for_route(
config,
candidate.provider,
candidate.key.as_str(),
@@ -9430,10 +9146,6 @@ fn wire_config_prefers_anthropic(wire: Option<&str>) -> bool {
codewhale_config::provider::wire_dialect_prefers_anthropic(wire)
}
fn wire_config_prefers_responses(wire: Option<&str>) -> bool {
codewhale_config::provider::wire_dialect_prefers_responses(wire)
}
fn modelstudio_mode_is_coding_plan(provider: ProviderKind, mode: Option<&str>) -> bool {
if matches!(
provider,
+4 -3
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::provider_lake::{ProviderCapability, RequestPayloadMode, provider_capability};
use crate::test_support::{EnvVarGuard, env_scope_ticket, join_env_scope, lock_test_env};
use std::collections::HashMap;
use std::env;
@@ -2082,7 +2083,7 @@ fn tools_always_load_parses_and_trims_names() {
#[test]
fn user_input_limits_default_when_tools_table_is_absent() {
let parsed: ConfigFile = toml::from_str("").expect("empty config");
let limits = parsed.base.user_input_limits();
let limits = crate::tools::user_input::UserInputLimits::from_config(&parsed.base);
assert_eq!(limits.max_questions, 6);
assert_eq!(limits.max_options, 4);
}
@@ -2097,7 +2098,7 @@ fn user_input_limits_read_from_tools_table_and_clamp() {
"#,
)
.expect("tools config");
let limits = parsed.base.user_input_limits();
let limits = crate::tools::user_input::UserInputLimits::from_config(&parsed.base);
assert_eq!(limits.max_questions, 9);
assert_eq!(limits.max_options, 6);
@@ -2109,7 +2110,7 @@ fn user_input_limits_read_from_tools_table_and_clamp() {
"#,
)
.expect("tools config");
let clamped = clamped.base.user_input_limits();
let clamped = crate::tools::user_input::UserInputLimits::from_config(&clamped.base);
assert_eq!(clamped.max_questions, 10);
assert_eq!(clamped.max_options, 2);
}
+4 -5
View File
@@ -175,7 +175,7 @@ pub(crate) fn nearest_key<'a>(
candidates
.into_iter()
.filter_map(|candidate| {
crate::commands::best_suggestion_score(query, [candidate])
codewhale_config::suggest::best_suggestion_score(query, [candidate])
.map(|score| (score, candidate))
})
.min_by_key(|(score, _)| *score)
@@ -244,7 +244,7 @@ pub(crate) fn settings_toml_keys() -> impl Iterator<Item = &'static str> {
/// the value is one [`codewhale_config::config_toml_choices`] names, since
/// the TUI loader rejects the whole file otherwise.
/// - `reasoning_effort` is checked against its reader,
/// [`ReasoningEffort::parse_strict`], and stored in canonical spelling.
/// [`codewhale_config::settings_schema::canonical_reasoning_effort`], and stored in canonical spelling.
/// - A root field the TUI `Config` reads but `SETTINGS_SCHEMA` does not
/// declare (`yolo`, `max_subagents`, `mcp_oauth_callback_port`, ...) is
/// typed by that field's own deserializer: a string in a boolean or
@@ -252,7 +252,6 @@ pub(crate) fn settings_toml_keys() -> impl Iterator<Item = &'static str> {
/// value is stored as the first of string, boolean, integer or number the
/// field accepts, and refused when it accepts none.
///
/// [`ReasoningEffort::parse_strict`]: crate::reasoning_preference::ReasoningEffort::parse_strict
pub fn config_toml_value(key: &str, value: &str) -> Result<Option<toml::Value>> {
let key = key.trim();
if TABLE_ROOT_KEYS.contains(&key) {
@@ -263,9 +262,9 @@ pub fn config_toml_value(key: &str, value: &str) -> Result<Option<toml::Value>>
}
codewhale_config::check_config_toml_choice(key, value)?;
if key == "reasoning_effort" {
let effort = crate::reasoning_preference::ReasoningEffort::parse_strict(value)
let effort = codewhale_config::settings_schema::canonical_reasoning_effort(value)
.map_err(|error| anyhow::anyhow!("invalid value for '{key}': {error}"))?;
return Ok(Some(toml::Value::String(effort.as_setting().to_string())));
return Ok(Some(toml::Value::String(effort.to_string())));
}
if key.contains('.')
|| codewhale_config::setting(key).is_some()
+1 -1
View File
@@ -473,7 +473,7 @@ pub struct EngineConfig {
/// schemas into DeepSeek beta strict mode.
pub strict_tool_mode: bool,
/// Workshop / large-tool-output routing (#548). `None` disables routing.
pub workshop: Option<crate::tools::large_output_router::WorkshopConfig>,
pub workshop: Option<codewhale_config::WorkshopConfig>,
/// Which search backend `web_search` should use. Default: Firecrawl.
pub search_provider: crate::config::SearchProvider,
/// Optional Firecrawl key, or required key for other API search providers.
@@ -733,7 +733,7 @@ fn context_budget_scenario() {
.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(ProviderKind::OpenaiCodex, "gpt-5.5")
- crate::provider_lake::provider_capability(ProviderKind::OpenaiCodex, "gpt-5.5")
.max_output
.expect("Codex route publishes a deliberate conservative output cap")
as usize
@@ -759,7 +759,7 @@ fn route_context_scenario() {
assert_eq!(
budget.output_cap_tokens,
u64::from(
crate::config::provider_capability(ProviderKind::OpenaiCodex, "gpt-5.5")
crate::provider_lake::provider_capability(ProviderKind::OpenaiCodex, "gpt-5.5")
.max_output
.expect("Codex route publishes a deliberate conservative output cap")
)
+1 -31
View File
@@ -1005,36 +1005,6 @@ fn discover_tools_with_bm25_like(catalog: &[Tool], query: &str, max_results: usi
.collect()
}
fn edit_distance(a: &str, b: &str) -> usize {
if a == b {
return 0;
}
if a.is_empty() {
return b.chars().count();
}
if b.is_empty() {
return a.chars().count();
}
let b_chars: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
let mut curr = vec![0usize; b_chars.len() + 1];
for (i, a_ch) in a.chars().enumerate() {
curr[0] = i + 1;
for (j, b_ch) in b_chars.iter().enumerate() {
let cost = if a_ch == *b_ch { 0 } else { 1 };
let delete = prev[j + 1] + 1;
let insert = curr[j] + 1;
let substitute = prev[j] + cost;
curr[j + 1] = delete.min(insert).min(substitute);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b_chars.len()]
}
fn suggest_tool_names(catalog: &[Tool], requested: &str, limit: usize) -> Vec<String> {
let requested = requested.trim().to_ascii_lowercase();
if requested.is_empty() || limit == 0 {
@@ -1046,7 +1016,7 @@ fn suggest_tool_names(catalog: &[Tool], requested: &str, limit: usize) -> Vec<St
let candidate = tool.name.to_ascii_lowercase();
let prefix_match = candidate.starts_with(&requested) || requested.starts_with(&candidate);
let contains_match = candidate.contains(&requested) || requested.contains(&candidate);
let distance = edit_distance(&candidate, &requested);
let distance = codewhale_config::suggest::edit_distance(&candidate, &requested);
let close_typo = distance <= 3;
if !(prefix_match || contains_match || close_typo) {
+1 -1
View File
@@ -103,7 +103,7 @@ impl CredentialSource {
Self::AuthModeNone => Cow::Borrowed("auth_mode = \"none\""),
Self::KeylessRoute { base_url } => Cow::Owned(format!(
"keyless route {}",
crate::doctor::structural_url_authority(base_url)
codewhale_config::structural_url_authority(base_url)
)),
Self::CliOverride => Cow::Borrowed("--api-key"),
Self::ProviderConfigApiKey { table } => Cow::Owned(format!("[{table}] api_key")),
+1 -22
View File
@@ -227,27 +227,6 @@ pub(crate) fn config_credential_shaped_keys(raw: &str) -> Vec<String> {
flagged
}
/// Return only the non-secret network authority of a configured URL.
///
/// Userinfo, path, query keys and values, and fragments are all omitted because
/// every one of those components can carry credentials. Parse failures also
/// omit the original input rather than echoing an attacker-controlled value.
pub(crate) fn structural_url_authority(url: &str) -> String {
let Some(parsed) = reqwest::Url::parse(url).ok() else {
return "unparseable (configured value omitted)".to_string();
};
let Some(host) = parsed.host_str() else {
return "unparseable (configured value omitted)".to_string();
};
// Url::host_str already includes the brackets around an IPv6 address.
let mut authority = format!("{}://{host}", parsed.scheme());
if let Some(port) = parsed.port() {
authority.push(':');
authority.push_str(&port.to_string());
}
authority
}
/// Explicit live operations requested for one doctor invocation.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct DoctorProbeRequest {
@@ -347,7 +326,7 @@ pub(crate) async fn doctor_search_probe(
Ok(target) => target,
Err(error) => return DoctorSearchProbeReport::ConfigurationError(error),
};
let authority = structural_url_authority(target.url.as_str());
let authority = codewhale_config::structural_url_authority(target.url.as_str());
if let Some(policy) = config.network.clone().map(|network| {
// Doctor is a read-only diagnostic. Evaluate the same typed policy as
+2 -2
View File
@@ -432,14 +432,14 @@ fn structural_url_authority_omits_every_secret_capable_component() {
sentinels[0], sentinels[1], sentinels[2], sentinels[3], sentinels[4], sentinels[5]
);
let authority = structural_url_authority(&raw);
let authority = codewhale_config::structural_url_authority(&raw);
assert_eq!(authority, "https://example.invalid:8443");
for sentinel in sentinels {
assert!(!authority.contains(sentinel));
}
assert_eq!(
structural_url_authority("http://[::1]:9000/private?token=review-secret"),
codewhale_config::structural_url_authority("http://[::1]:9000/private?token=review-secret"),
"http://[::1]:9000"
);
}
+4 -4
View File
@@ -685,9 +685,7 @@ pub(crate) async fn run_exec_agent(
.tag()
.to_string(),
workshop: {
crate::tools::large_output_router::WorkshopConfig::install_active(
config.workshop.as_ref(),
);
crate::tools::large_output_router::install_active_workshop(config.workshop.as_ref());
config.workshop.clone()
},
search_provider: execution_config.search_provider(),
@@ -705,7 +703,9 @@ pub(crate) async fn run_exec_agent(
} else {
execution_config.tools_always_load()
},
user_input_limits: execution_config.user_input_limits(),
user_input_limits: crate::tools::user_input::UserInputLimits::from_config(
&execution_config,
),
user_input_timeout: execution_config.user_input_timeout(),
goal_max_steps: None,
tools: if fleet_authority_active {
+1 -1
View File
@@ -10,9 +10,9 @@ use super::{
use crate::plugins::{
activation::PluginActivationCapability,
manifest::{PluginManifest, ValidatedManifest},
types::PluginAuthority,
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use codewhale_config::PluginAuthority;
use codewhale_ratatui::avatar::{MAX_PACK_BYTES, MAX_PNG_BYTES, Pack, RegisteredPack};
use sha2::{Digest, Sha256};
use std::{
+1 -1
View File
@@ -55,7 +55,7 @@ use super::ManagerShared;
use super::protocol::{CommandResultWire, CommandRunParams, CoreRequest};
use super::registry::CommandRegistration;
use super::supervisor::HostCallError;
use crate::plugins::types::PluginAuthority;
use codewhale_config::PluginAuthority;
/// How long the user waits for one command before it is cancelled.
pub const COMMAND_RUN_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
+1 -1
View File
@@ -20,8 +20,8 @@ use super::tier::HostTier;
use super::{ExtensionHostManager, ManagerShared, activation};
use crate::plugins::PluginRegistry;
use crate::plugins::activation::PluginActivationCapability;
use crate::plugins::types::PluginAuthority;
use crate::tools::spec::{ToolContext, ToolError, ToolResult};
use codewhale_config::PluginAuthority;
const DEADLINE: Duration = Duration::from_secs(120);
const MAX_JOBS: usize = 32;
+1 -1
View File
@@ -166,7 +166,7 @@ use self::supervisor::{HostEvents, HostProcess, HostRequestContext};
use self::tier::{BuiltinModule, HostTier};
use crate::plugins::PluginRegistry;
use crate::plugins::activation::{self, PluginActivationCapability};
use crate::plugins::types::PluginAuthority;
use codewhale_config::PluginAuthority;
/// The host bundle, embedded so every distribution channel carries it.
const BUNDLE: &[u8] = include_bytes!("../../extension-host/dist/codewhale-extension-host.mjs");
+2 -3
View File
@@ -5,9 +5,8 @@ use super::protocol::{EntryRef, OwnerRef, RegisterKind, RegisterParams, Register
use super::supervisor::HostRequestContext;
use super::tier::HostTier;
use crate::mcp::McpServerConfig;
use crate::plugins::{
PluginRegistry, activation::PluginActivationCapability, types::PluginAuthority,
};
use crate::plugins::{PluginRegistry, activation::PluginActivationCapability};
use codewhale_config::PluginAuthority;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Weak};
+1 -1
View File
@@ -18,7 +18,7 @@ use serde_json::Value;
use super::protocol::{EntryRef, OwnerRef, RegisterKind, RegisterParams};
use super::tier::HostTier;
use crate::plugins::types::PluginAuthority;
use codewhale_config::PluginAuthority;
/// Largest accepted tool input schema, serialized.
pub const MAX_SCHEMA_BYTES: usize = 64 * 1024;
+2 -1
View File
@@ -10,7 +10,8 @@ use super::protocol::{OwnerRef, RegisterParams, RegisterResult};
use super::supervisor::HostRequestContext;
use super::tier::HostTier;
use crate::plugins::activation::PluginActivationCapability;
use crate::plugins::types::{PluginAuthority, PluginSkillSnapshot};
use crate::plugins::types::PluginSkillSnapshot;
use codewhale_config::PluginAuthority;
pub const MAX_ROOTS_PER_OWNER: usize = 8;
pub const MAX_ROOTS_PER_HOST: usize = 64;
+1 -1
View File
@@ -1389,7 +1389,7 @@ impl HostProcess {
#[cfg(windows)]
pub(crate) async fn admit_windows_root(
&self,
authority: crate::plugins::types::PluginAuthority,
authority: codewhale_config::PluginAuthority,
) -> Result<(), String> {
let sandbox = self
.windows
+3 -3
View File
@@ -143,9 +143,9 @@ async fn frames_decode_in_order_and_violations_are_typed() {
// Owner registry
// ---------------------------------------------------------------------------
pub(crate) fn fake_authority(plugin_id: &str) -> crate::plugins::types::PluginAuthority {
crate::plugins::types::PluginAuthority {
plugin_id: crate::plugins::types::PluginId(plugin_id.to_string()),
pub(crate) fn fake_authority(plugin_id: &str) -> codewhale_config::PluginAuthority {
codewhale_config::PluginAuthority {
plugin_id: codewhale_config::PluginId(plugin_id.to_string()),
plugin_name: plugin_id.to_string(),
workspace: PathBuf::from("/w"),
state_path: PathBuf::from("/s"),
+2 -2
View File
@@ -87,7 +87,7 @@ pub struct AgentProfile {
pub origin: ProfileOrigin,
/// Runtime authority for a profile loaded from an immutable plugin
/// snapshot. Rechecked at Agent spawn so another process can revoke it.
pub plugin_authority: Option<crate::plugins::types::PluginAuthority>,
pub plugin_authority: Option<codewhale_config::PluginAuthority>,
pub native_preset: Option<crate::extension_host::composition_scope::NativePresetRef>,
}
@@ -317,7 +317,7 @@ fn load_agent_profiles_from_dir_tolerant_in(
pub(crate) fn load_plugin_agent_profiles_from_component(
component: &Path,
authority: &crate::plugins::types::PluginAuthority,
authority: &codewhale_config::PluginAuthority,
) -> Result<(Vec<AgentProfile>, Vec<AgentProfileLoadIssue>)> {
let (mut profiles, issues) = if component.is_dir() {
load_agent_profiles_from_dir_tolerant(component, ProfileOrigin::Plugin)?
+1 -1
View File
@@ -373,7 +373,7 @@ fn validate_fleet_reasoning_effort(
return Ok(());
};
let provider = ProviderKind::parse(&route.provider_kind).unwrap_or(ProviderKind::Custom);
let capability = crate::config::provider_capability(provider, &route.wire_model_id);
let capability = crate::provider_lake::provider_capability(provider, &route.wire_model_id);
if capability.thinking_supported {
return Ok(());
}
+1 -1
View File
@@ -55,7 +55,7 @@ pub(crate) fn approved_project_hooks(
}
pub(crate) fn verify_hook_authorities(
plugin: Option<&crate::plugins::types::PluginAuthority>,
plugin: Option<&codewhale_config::PluginAuthority>,
project: Option<&ProjectHookAuthority>,
) -> Result<(), String> {
if let Some(authority) = plugin {
+2 -2
View File
@@ -294,7 +294,7 @@ pub struct Hook {
/// Never accepted from TOML; only the reviewed staged adapter may attach
/// it after parsing immutable bytes.
#[serde(skip)]
pub plugin_authority: Option<crate::plugins::types::PluginAuthority>,
pub plugin_authority: Option<codewhale_config::PluginAuthority>,
/// Exact-byte project approval attached by the loader; never read from TOML.
#[serde(skip)]
@@ -716,7 +716,7 @@ impl HooksConfig {
fn load_plugin_hook_component(
component: &Path,
authority: &crate::plugins::types::PluginAuthority,
authority: &codewhale_config::PluginAuthority,
) -> Result<HooksConfig, String> {
let mut paths = if component.is_file() {
vec![component.to_path_buf()]
+1 -1
View File
@@ -1428,7 +1428,7 @@ struct BackgroundHookJob {
stdin_bytes: Option<Vec<u8>>,
label: String,
timeout: Duration,
plugin_authority: Option<crate::plugins::types::PluginAuthority>,
plugin_authority: Option<codewhale_config::PluginAuthority>,
project_authority: Option<super::authority::ProjectHookAuthority>,
}
+20 -18
View File
@@ -4746,7 +4746,7 @@ fn run_setup_status(
);
println!(
" · base_url: {}",
crate::doctor::structural_url_authority(&config.active_route_base_url())
codewhale_config::structural_url_authority(&config.active_route_base_url())
);
let model = config
.default_text_model
@@ -5306,7 +5306,7 @@ async fn run_doctor(
println!(" · provider: {}", api_target.provider);
println!(
" · base_url: {}",
crate::doctor::structural_url_authority(&api_target.base_url)
codewhale_config::structural_url_authority(&api_target.base_url)
);
match api_target.resolution {
DoctorModelResolution::Resolved => {
@@ -5337,12 +5337,12 @@ async fn run_doctor(
if let Some(recommended) = strict_tool_mode.recommended_base_url.as_deref() {
println!(
" Use the {} endpoint for DeepSeek strict schemas.",
crate::doctor::structural_url_authority(recommended)
codewhale_config::structural_url_authority(recommended)
);
}
let capability = identity
.as_ref()
.map(|identity| crate::config::provider_capability(identity.provider, &api_target.model));
let capability = identity.as_ref().map(|identity| {
crate::provider_lake::provider_capability(identity.provider, &api_target.model)
});
if let Some(alias) = capability
.as_ref()
.and_then(|capability| capability.alias_deprecation.as_ref())
@@ -7678,10 +7678,10 @@ fn doctor_provider_model_report_json(config: &Config) -> serde_json::Value {
let credential_help = provider.provider().credential_help();
let credential_url = credential_help
.credential_url
.map(crate::doctor::structural_url_authority);
.map(codewhale_config::structural_url_authority);
let credential_docs_url = credential_help
.docs_url
.map(crate::doctor::structural_url_authority);
.map(codewhale_config::structural_url_authority);
json!({
"provider": {
@@ -8245,7 +8245,7 @@ fn run_doctor_json(
},
"external_credentials": doctor_external_credential_consent_json(config),
"dsh_integration": doctor_dsh_integration_json(config, workspace),
"base_url": crate::doctor::structural_url_authority(&api_target.base_url),
"base_url": codewhale_config::structural_url_authority(&api_target.base_url),
"default_text_model": api_target.model,
// DGF-01: this report describes the route a session launched now
// would resolve; a running session keeps its launch-time route.
@@ -8387,7 +8387,7 @@ fn provider_capability_report(config: &Config) -> serde_json::Value {
// Wire-aware so a custom provider's `wire = "responses" | "anthropic"`
// reports the payload mode the client will actually speak instead of the
// static Chat default.
let cap = crate::config::provider_capability_with_wire(
let cap = crate::provider_lake::provider_capability_with_wire(
provider,
resolved_model,
config.provider_wire_dialect(&identity),
@@ -8463,7 +8463,7 @@ fn doctor_route_report(config: &Config) -> serde_json::Value {
}
};
let provider = identity.provider;
let redacted_base_url = crate::doctor::structural_url_authority(&target.base_url);
let redacted_base_url = codewhale_config::structural_url_authority(&target.base_url);
let route_result = crate::route_runtime::resolve_runtime_route_for_identity(
config,
&identity,
@@ -8482,7 +8482,7 @@ fn doctor_route_report(config: &Config) -> serde_json::Value {
})
.unwrap_or_else(|| {
json!({
"tokens": crate::config::provider_capability(provider, &target.model).context_window,
"tokens": crate::provider_lake::provider_capability(provider, &target.model).context_window,
"source": crate::route_runtime::ContextWindowSource::Fallback.label(),
})
});
@@ -8822,7 +8822,7 @@ fn doctor_strict_tool_mode_report_json(status: &DoctorStrictToolModeStatus) -> s
"recommended_base_url": status
.recommended_base_url
.as_deref()
.map(crate::doctor::structural_url_authority),
.map(codewhale_config::structural_url_authority),
})
}
@@ -8892,7 +8892,7 @@ fn doctor_timeout_recovery_lines(config: &Config) -> Vec<String> {
let mut lines = vec![
format!(
"Connection timed out while reaching {}.",
crate::doctor::structural_url_authority(&target.base_url)
codewhale_config::structural_url_authority(&target.base_url)
),
// #6889: some providers load a model on its first call, which can
// take longer than this check waits.
@@ -12427,7 +12427,7 @@ fn doctor_mcp_server_json(name: &str, server: &McpServerConfig) -> serde_json::V
"status": status.legacy_status(),
"detail": status.detail(),
"transport": if server.url.is_some() { "http" } else { "stdio" },
"endpoint": server.url.as_deref().map(crate::doctor::structural_url_authority),
"endpoint": server.url.as_deref().map(codewhale_config::structural_url_authority),
"command_configured": server.command.is_some(),
"args_count": server.args.len(),
"env_count": server.env.len(),
@@ -12464,7 +12464,7 @@ fn doctor_check_mcp_server(server: &McpServerConfig) -> McpServerDoctorStatus {
// URL-based server: omit userinfo, query, and fragment entirely.
if let Some(ref url) = server.url {
let authority = crate::doctor::structural_url_authority(url);
let authority = codewhale_config::structural_url_authority(url);
return if authority.starts_with("unparseable") {
McpServerDoctorStatus::Warning(
"HTTP/SSE server URL is invalid; configured value omitted".to_string(),
@@ -16734,7 +16734,7 @@ mod doctor_endpoint_tests {
assert_eq!(report["resolved_model"], "deepseek-v4-pro");
assert!(report["alias_deprecation"].is_null());
assert!(
crate::config::provider_capability(
crate::provider_lake::provider_capability(
crate::config::ProviderKind::Deepseek,
"deepseek-v4-pro"
)
@@ -16816,7 +16816,9 @@ mod doctor_endpoint_tests {
assert_eq!(report["model"], crate::config::DEFAULT_SILICONFLOW_MODEL);
assert_eq!(
report["base_url"]["redacted"],
crate::doctor::structural_url_authority(crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL)
codewhale_config::structural_url_authority(
crate::config::DEFAULT_SILICONFLOW_CN_BASE_URL
)
);
assert_eq!(report["base_url"]["class"], "default");
assert_eq!(report["auth"]["scheme"], "bearer");
+3 -3
View File
@@ -301,7 +301,7 @@ fn doctor_provider_json_redacts_every_credential_url_to_its_authority() {
};
assert_eq!(
value,
crate::doctor::structural_url_authority(value),
codewhale_config::structural_url_authority(value),
"{provider:?} {field} must be authority-only"
);
assert!(
@@ -334,10 +334,10 @@ fn doctor_provider_url_reports_omit_secret_capable_components() {
let target = doctor_api_target(&config);
let human = format!(
"base_url: {}",
crate::doctor::structural_url_authority(&target.base_url)
codewhale_config::structural_url_authority(&target.base_url)
);
let json = serde_json::json!({
"base_url": crate::doctor::structural_url_authority(&target.base_url),
"base_url": codewhale_config::structural_url_authority(&target.base_url),
"route": doctor_route_report(&config),
})
.to_string();
+6 -6
View File
@@ -684,7 +684,7 @@ pub struct McpServerConfig {
#[derive(Debug, Clone)]
pub(crate) struct ReviewedPluginMcpSource {
authority: crate::plugins::types::PluginAuthority,
authority: codewhale_config::PluginAuthority,
approved_remote_endpoint: Option<String>,
approved_remote_origin: Option<String>,
host_environment: Arc<crate::plugins::HostEnvironment>,
@@ -700,7 +700,7 @@ impl ReviewedPluginMcpSource {
}
fn from_authority(
authority: crate::plugins::types::PluginAuthority,
authority: codewhale_config::PluginAuthority,
remote_endpoint: Option<&str>,
host_environment: Arc<crate::plugins::HostEnvironment>,
) -> Result<Self> {
@@ -3080,7 +3080,7 @@ struct McpToolRoute {
server_name: String,
tool_name: String,
catalog_generation: u64,
plugin_authority: Option<crate::plugins::types::PluginAuthority>,
plugin_authority: Option<codewhale_config::PluginAuthority>,
}
/// Model-facing name suffix of the synthetic self-serve OAuth login tool
@@ -3219,7 +3219,7 @@ pub(crate) struct McpHandoff {
connections: Vec<(String, McpServerIdentity, McpConnection)>,
}
type McpServerIdentity = (u64, Option<crate::plugins::types::PluginAuthority>);
type McpServerIdentity = (u64, Option<codewhale_config::PluginAuthority>);
fn mcp_server_identity(config: &McpConfig, name: &str) -> Option<McpServerIdentity> {
config.servers.get(name).map(|server| {
@@ -6931,7 +6931,7 @@ fn merge_plugin_mcp_servers_from_plugins_with_environment(
Item = (
String,
crate::plugins::types::LoadedPlugin,
crate::plugins::types::PluginAuthority,
codewhale_config::PluginAuthority,
),
>,
host_environment: Arc<crate::plugins::HostEnvironment>,
@@ -7023,7 +7023,7 @@ fn merge_plugin_mcp_servers_from_plugins(
Item = (
String,
crate::plugins::types::LoadedPlugin,
crate::plugins::types::PluginAuthority,
codewhale_config::PluginAuthority,
),
>,
) -> Result<McpConfig> {
+1 -1
View File
@@ -2340,7 +2340,7 @@ fn active_plugin_fixture(
plugin_base: &Path,
) -> (
crate::plugins::types::LoadedPlugin,
crate::plugins::types::PluginAuthority,
codewhale_config::PluginAuthority,
) {
let plugins_root = plugin_base.parent().expect("plugin parent").to_path_buf();
// Callers use both `<temp>/plugins/<name>` and `<temp>/<name>` layouts.
+3 -2
View File
@@ -9,9 +9,10 @@ use serde::Serialize;
use crate::client::system_one::DecisionRouterRoute;
use crate::config::{
AutoRouterKind, Config, ProviderIdentity, ProviderKind, has_api_key_for,
normalize_model_name_for_provider, provider_capability,
normalize_model_name_for_provider,
};
use crate::provider_lake::models_for_provider;
use crate::provider_lake::provider_capability;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
@@ -253,7 +254,7 @@ impl ModelInventory {
push_model(&mut models, provider, &model);
}
for declaration in config.custom_models.as_deref().unwrap_or_default() {
if crate::provider_lake::configured_model_for_route(
if crate::config::configured_model_for_route(
config,
provider,
identity.key.as_str(),
+3 -2
View File
@@ -1,13 +1,14 @@
//! Typed model and resolved-route capability descriptors (#3365).
//!
//! This module bridges the additive config catalog intrinsic facts and the
//! provider+model capability matrix in [`crate::config::provider_capability`].
//! provider+model capability matrix in [`crate::provider_lake::provider_capability`].
//! It intentionally keeps intrinsic model facts separate from resolved route
//! facts so future route resolution can combine catalog offerings, user
//! overrides, live hints, and auth readiness without scattering provider/model
//! string checks through prompt, tool, and Fleet code.
use crate::config::{ProviderKind, RequestPayloadMode, provider_capability};
use crate::config::ProviderKind;
use crate::provider_lake::{RequestPayloadMode, provider_capability};
pub use codewhale_config::catalog::reviewed::ModelFamily as ModelProvider;
use codewhale_config::route::{RouteCapabilities, RouteLimits};
+1 -1
View File
@@ -4754,7 +4754,7 @@ pub async fn plugin_oauth_login(
provider: String,
base_url: String,
descriptor: PluginOAuthConfig,
authority: crate::plugins::types::PluginAuthority,
authority: codewhale_config::PluginAuthority,
) -> Result<()> {
let policy = crate::plugins::activation::extension_host_policy_enabled();
tokio::task::spawn_blocking(move || {
+3 -2
View File
@@ -10,9 +10,10 @@ use super::manifest::{PluginManifest, ValidatedManifest};
use super::path_identity::metadata_is_link_or_reparse;
use super::registry::PluginRegistry;
use super::types::{
LoadedPlugin, PluginDiagnostic, PluginId, PluginOrigin, PluginScope, PluginSkillSnapshot,
LoadedPlugin, PluginDiagnostic, PluginOrigin, PluginScope, PluginSkillSnapshot,
PluginTrustStatus,
};
use codewhale_config::PluginId;
#[derive(Debug, Clone)]
pub struct DiscoveryConfig {
@@ -562,7 +563,7 @@ pub(crate) fn load_staged_skill_snapshots(
}
pub(crate) fn load_staged_skill_root_snapshots(
authority: &super::types::PluginAuthority,
authority: &codewhale_config::PluginAuthority,
relative: &Path,
) -> Result<Vec<PluginSkillSnapshot>, String> {
let staged_root = super::agent_plugin::plugin_root_for_manifest(&authority.staged_manifest)
+2 -2
View File
@@ -39,7 +39,7 @@
//! [`verify_plugin_state_authority`](super::registry::verify_plugin_state_authority),
//! the execution-boundary revocation probe, which re-reads the persisted
//! `state.json` `enabled` bit directly and never sees the in-memory registry.
//! A [`PluginAuthority`](super::types::PluginAuthority) minted before the
//! A [`PluginAuthority`](codewhale_config::PluginAuthority) minted before the
//! policy arrived and then *persisted* — today only the offline-queue
//! `skill_provenance` receipt, which survives a restart — therefore
//! revalidates against `state.json` alone. Closing that path means enforcing
@@ -53,7 +53,7 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::context::HostEnvironment;
use super::types::PluginId;
use codewhale_config::PluginId;
/// Schema version of the managed policy document, versioned exactly like
/// `PluginStateFile`: a required `schema_version` field that must match.
+2 -1
View File
@@ -24,7 +24,8 @@ use serde::{Deserialize, Serialize};
use super::super::install::PluginInstallSource;
use super::super::manifest::{PluginCompatibility, PluginInventory, PluginWhen};
use super::super::types::{PluginDiagnosticLevel, PluginId};
use super::super::types::PluginDiagnosticLevel;
use codewhale_config::PluginId;
/// Which real catalog format a document follows.
///
+1 -1
View File
@@ -116,7 +116,7 @@ pub(crate) fn admitted_entries(
) -> Vec<(
NativePresetRef,
NativePresetMetadata,
super::types::PluginAuthority,
codewhale_config::PluginAuthority,
)> {
let (sources, _) =
super::runtime::active_component_sources(plugins, PluginActivationCapability::Native);
+1 -1
View File
@@ -202,7 +202,7 @@ pub(crate) fn plugin_auth_entry(config: &Config, provider: &str) -> anyhow::Resu
/// `None` skips public-header comparison for login/logout; inference must pass
/// `Some` with its effective configured headers, including an empty map.
pub(crate) fn verify_provider_binding(
authority: &super::types::PluginAuthority,
authority: &codewhale_config::PluginAuthority,
name: &str,
base_url: &str,
oauth: &crate::oauth::PluginOAuthConfig,
+2 -4
View File
@@ -14,10 +14,8 @@ use super::manifest::PluginInventory;
use super::path_identity::metadata_is_link_or_reparse;
#[cfg(windows)]
use super::path_identity::windows_file_identity;
use super::types::{
LoadedPlugin, PluginAuthority, PluginDiagnostic, PluginDiagnosticLevel, PluginId,
PluginTrustStatus,
};
use super::types::{LoadedPlugin, PluginDiagnostic, PluginDiagnosticLevel, PluginTrustStatus};
use codewhale_config::{PluginAuthority, PluginId};
pub(crate) mod gc;
+2 -1
View File
@@ -51,7 +51,8 @@ use crate::plugins::builtin::{
EmbeddedBundle, STAMP_NAME, embedded_bundles, parse_snapshot_dir_name, snapshots_dir,
};
use crate::plugins::discovery::{plugin_id, plugin_root_hash};
use crate::plugins::types::{PluginId, PluginScope};
use crate::plugins::types::PluginScope;
use codewhale_config::PluginId;
/// How long a snapshot or stage must sit untouched before it may be removed.
/// Every start of a build re-stamps its snapshot, so this is "not started by
+2 -1
View File
@@ -10,7 +10,8 @@ use std::path::{Path, PathBuf};
use super::PluginRegistry;
use super::activation::PluginActivationCapability;
use super::registry::verify_plugin_component_authority;
use super::types::{LoadedPlugin, PluginAuthority, PluginScope};
use super::types::{LoadedPlugin, PluginScope};
use codewhale_config::PluginAuthority;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginComponentSource {
+2 -38
View File
@@ -4,6 +4,8 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use codewhale_config::{PluginAuthority, PluginId};
use super::activation::{PluginActivationCapability, PluginActivationPolicy};
use super::manifest::{
PluginCompatibility, PluginInventory, PluginManifest, ResolvedPluginComponents,
@@ -59,44 +61,6 @@ impl fmt::Display for PluginOrigin {
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PluginId(pub String);
impl PluginId {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Persistable proof of the exact reviewed plugin authority attached to a
/// Skill or MCP server. Runtime contexts keep this receipt instead of a
/// pointer to mutable process-global discovery state, and revalidate it at
/// every side-effect boundary.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginAuthority {
pub plugin_id: PluginId,
pub plugin_name: String,
pub workspace: PathBuf,
pub state_path: PathBuf,
pub source_manifest: PathBuf,
pub staged_manifest: PathBuf,
pub content_hash: String,
pub capability_hash: String,
/// Monotonic generation of this plugin's persisted authority. Any trust,
/// enablement, disablement, or revocation transition invalidates receipts
/// held by another process immediately, even when hashes are unchanged.
pub state_generation: u64,
}
impl fmt::Display for PluginId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginDiagnosticLevel {
+291 -19
View File
@@ -1396,24 +1396,6 @@ pub(crate) fn catalog_offering_for_route(
Some(offering)
}
pub(crate) fn configured_model_for_route<'a>(
config: &'a Config,
provider: ProviderKind,
identity: &str,
base_url: &str,
model: &str,
) -> Option<&'a codewhale_config::catalog::configured::ConfiguredModel> {
// Account-owned OAuth rosters retain their separate authority.
if provider == ProviderKind::OpenaiCodex {
return None;
}
let models = config.custom_models.as_deref()?;
codewhale_config::catalog::configured::validate_configured_models(models).ok()?;
models
.iter()
.find(|row| row.id == model && row.matches_route(identity, base_url))
}
/// Config declarations take precedence only for the exact selected route.
pub(crate) fn configured_catalog_offering_for_route(
config: &Config,
@@ -1423,7 +1405,7 @@ pub(crate) fn configured_catalog_offering_for_route(
model: &str,
) -> Option<CatalogOffering> {
let lower = catalog_offering_for_route(provider, identity, base_url, model);
configured_model_for_route(config, provider, identity, base_url, model)
crate::config::configured_model_for_route(config, provider, identity, base_url, model)
.map(|row| {
let mut offering = row.to_catalog_offering();
if let Some(lower) = lower.as_ref() {
@@ -2167,6 +2149,296 @@ pub(crate) async fn run_models(
Ok(())
}
// ============================================================================
// Provider Capability Matrix
// ============================================================================
/// Known capabilities for a provider + resolved-model combination.
///
/// Returned by [`provider_capability`] to describe what a given provider
/// supports for the resolved model string. All fields are derived from
/// static knowledge (release docs, API guides) rather than live API probes.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct ProviderCapability {
/// Canonical provider identifier.
pub provider: ProviderKind,
/// Resolved model identifier that will be sent in the API payload.
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, when one is
/// actually known.
///
/// `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.
pub cache_telemetry_supported: bool,
/// Which request-payload dialect the provider uses.
pub request_payload_mode: RequestPayloadMode,
/// Deprecation metadata for compatibility aliases that are still accepted.
#[serde(skip_serializing_if = "Option::is_none")]
pub alias_deprecation: Option<crate::config::ModelAliasDeprecation>,
}
/// Which request-payload dialect the provider speaks.
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub enum RequestPayloadMode {
/// Standard OpenAI-compatible `/v1/chat/completions` payload.
ChatCompletions,
/// OpenAI Responses API payload.
Responses,
/// Native Anthropic Messages API `/v1/messages` payload (#3014).
AnthropicMessages,
}
/// Resolve the provider capability for a given [`ProviderKind`] and resolved
/// model string.
///
/// The `resolved_model` should be the final model identifier that will appear
/// in the API payload (after normalization / provider-specific mapping).
#[must_use]
pub fn provider_capability(provider: ProviderKind, resolved_model: &str) -> ProviderCapability {
provider_capability_with_wire(provider, resolved_model, None)
}
/// Wire-aware variant of [`provider_capability`] that respects
/// `wire = "responses" | "anthropic" | "chat"` for `Custom` providers.
///
/// Built-ins keep their fixed policy; `Custom` defaults to `Chat` when `wire`
/// is absent so existing configs stay compatible. Mirrors
/// `crates/tui/src/client.rs::provider_wire_format_for_config` and the
/// `Custom` comment in `crates/config/src/provider.rs`.
#[must_use]
pub fn provider_capability_with_wire(
provider: ProviderKind,
resolved_model: &str,
wire: Option<&str>,
) -> ProviderCapability {
// Custom wire overrides must be checked before the generic fallback so
// `[providers.<name>] wire = "responses"` / `"anthropic"` is honored.
if provider == ProviderKind::Custom {
if codewhale_config::provider::wire_dialect_prefers_anthropic(wire) {
return ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window: codewhale_models::context_window_for_model(resolved_model)
.unwrap_or(codewhale_models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS),
max_output: codewhale_models::max_output_tokens_for_model(resolved_model),
thinking_supported: codewhale_models::model_supports_reasoning(resolved_model),
cache_telemetry_supported: false,
request_payload_mode: RequestPayloadMode::AnthropicMessages,
alias_deprecation: None,
};
}
if codewhale_config::provider::wire_dialect_prefers_responses(wire) {
return ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window: codewhale_models::context_window_for_model(resolved_model)
.unwrap_or(codewhale_models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS),
max_output: codewhale_models::max_output_tokens_for_model(resolved_model),
thinking_supported: codewhale_models::model_supports_reasoning(resolved_model),
cache_telemetry_supported: false,
request_payload_mode: RequestPayloadMode::Responses,
alias_deprecation: None,
};
}
}
if matches!(
provider,
ProviderKind::Anthropic | ProviderKind::MinimaxAnthropic | ProviderKind::Openmodel
) {
return ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
// 200K is the conservative Anthropic floor; 4.6+ models resolve
// their 1M windows from models.rs rows (#3014).
context_window: codewhale_models::context_window_for_model(resolved_model)
.unwrap_or(200_000),
// 64K is the documented Anthropic Messages floor. For a model
// the catalogue describes this carries its documented ceiling;
// for an unknown one it is an *assumed* floor, and
// `route_budget::output_ceiling_source` labels it unverified so
// no receipt renders it as "documented" (#5440).
max_output: Some(
codewhale_models::max_output_tokens_for_model(resolved_model).unwrap_or(64_000),
),
thinking_supported: codewhale_models::model_supports_reasoning(resolved_model),
cache_telemetry_supported: matches!(provider, ProviderKind::Anthropic),
request_payload_mode: RequestPayloadMode::AnthropicMessages,
alias_deprecation: None,
};
}
if matches!(provider, ProviderKind::OpenaiCodex) {
return ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window: crate::config::OPENAI_CODEX_EFFECTIVE_CONTEXT_WINDOW_TOKENS,
// 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. It is
// an assumption, not a documented fact — receipts label it
// unverified (#5440).
max_output: Some(4096),
thinking_supported: true,
cache_telemetry_supported: false,
request_payload_mode: RequestPayloadMode::Responses,
alias_deprecation: None,
};
}
// #3023: Delete the Openai/Atlascloud/Moonshot early-return so these
// providers use the generic model-based path below, which correctly
// resolves context windows, output limits, and thinking support from
// models.rs lookups. Ollama also falls through to model-based lookups
// with 8192 as the last-resort fallback instead of a hardcoded floor.
if matches!(provider, ProviderKind::XiaomiMimo) {
return ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window: codewhale_models::context_window_for_model(resolved_model)
.unwrap_or(codewhale_models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS),
// No documented output maximum for these routes: stay unknown so
// no compatibility clamp is applied downstream.
max_output: codewhale_models::max_output_tokens_for_model(resolved_model),
thinking_supported: codewhale_models::model_supports_reasoning(resolved_model),
cache_telemetry_supported: false,
request_payload_mode: RequestPayloadMode::ChatCompletions,
alias_deprecation: None,
};
}
if matches!(provider, ProviderKind::Arcee) {
return ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window: codewhale_models::context_window_for_model(resolved_model)
.unwrap_or(codewhale_models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS),
// No documented output maximum for these routes: stay unknown so
// no compatibility clamp is applied downstream.
max_output: codewhale_models::max_output_tokens_for_model(resolved_model),
thinking_supported: codewhale_models::model_supports_reasoning(resolved_model),
cache_telemetry_supported: false,
request_payload_mode: RequestPayloadMode::ChatCompletions,
alias_deprecation: None,
};
}
let model_lower = resolved_model.to_ascii_lowercase();
let alias_deprecation = if matches!(
provider,
ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
) {
crate::config::deepseek_alias_deprecation(&model_lower)
} else {
None
};
let official_deepseek = codewhale_models::canonical_official_deepseek_model_id(&model_lower)
.is_some()
|| alias_deprecation.is_some();
let is_reasoner = matches!(provider, ProviderKind::WanjieArk)
&& (model_lower.contains("reasoner") || model_lower.contains("r1"));
// Provider-owned wire IDs can have exact catalog facts without a legacy
// model-only row. Reuse those facts before conservative fallback budgets.
let offering = bundled_catalog_offering_for_model(provider, resolved_model);
let context_window = if official_deepseek {
codewhale_models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS
} else if let Some(window) = codewhale_models::context_window_for_model(resolved_model) {
window
} else if let Some(window) = offering
.as_ref()
.and_then(|row| row.limit.as_ref())
.and_then(|limit| limit.context)
.and_then(|window| u32::try_from(window).ok())
{
window
} else if matches!(provider, ProviderKind::Ollama) {
8192
} else {
codewhale_models::LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS
};
// Output limits require an exact catalog row or an explicitly recognized
// compatibility alias. A family-name match cannot document a new model.
// The catalog 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 = codewhale_models::max_output_tokens_for_model(resolved_model)
.or_else(|| {
// Provider-owned wire IDs need not exist in the legacy model-only
// catalog (for example Fireworks' accounts/... slug). Reuse the
// exact bundled offering instead of inferring a family ceiling.
offering
.as_ref()
.and_then(|offering| offering.limit.as_ref())
.and_then(|limit| limit.output)
.and_then(|limit| u32::try_from(limit).ok())
})
.or_else(|| {
codewhale_models::canonical_official_deepseek_model_id(resolved_model)
.or_else(|| {
alias_deprecation.as_ref().map(|_| {
codewhale_config::catalog::reviewed::constants::DEEPSEEK_ALIAS_REPLACEMENT
})
})
.and_then(codewhale_models::max_output_tokens_for_model)
});
// Exact catalog reasoning facts and recognized compatibility aliases only.
let thinking_supported = official_deepseek
|| is_reasoner
|| offering
.as_ref()
.is_some_and(|row| row.reasoning == Some(true))
|| codewhale_models::model_supports_reasoning(resolved_model);
// Cache telemetry: returned only by DeepSeek-native and NVIDIA NIM endpoints.
let cache_telemetry_supported = matches!(
provider,
ProviderKind::Deepseek | ProviderKind::NvidiaNim | ProviderKind::Volcengine
);
let request_payload_mode = if matches!(
provider,
ProviderKind::DeepseekAnthropic | ProviderKind::MinimaxAnthropic | ProviderKind::Openmodel
) {
RequestPayloadMode::AnthropicMessages
} else {
RequestPayloadMode::ChatCompletions
};
ProviderCapability {
provider,
resolved_model: resolved_model.to_string(),
context_window,
max_output,
thinking_supported,
cache_telemetry_supported,
request_payload_mode,
alias_deprecation,
}
}
#[cfg(test)]
mod tests {
use super::*;
+15 -15
View File
@@ -119,21 +119,21 @@ impl ReasoningEffort {
/// for its compatibility fallback, but a new CLI, settings, or tool input
/// must reject an unknown value instead of quietly turning it into `max`.
pub fn parse_strict(value: &str) -> Result<Self, String> {
let trimmed = value.trim();
match trimmed.to_ascii_lowercase().as_str() {
"off" | "disabled" | "none" | "false" => Ok(Self::Off),
"minimal" => Ok(Self::Minimal),
"low" | "minimum" | "light" => Ok(Self::Low),
"medium" | "mid" => Ok(Self::Medium),
"high" => Ok(Self::High),
"xhigh" => Ok(Self::XHigh),
"auto" | "automatic" => Ok(Self::Auto),
"ultra" | "ultracode" => Ok(Self::Ultra),
"max" | "maximum" => Ok(Self::Max),
_ => Err(format!(
"Unrecognized reasoning effort {trimmed:?}. Expected one of: auto, off, minimal, low, medium, high, xhigh, ultra, or max."
)),
}
let canonical = codewhale_config::settings_schema::canonical_reasoning_effort(value)?;
Ok([
Self::Off,
Self::Minimal,
Self::Low,
Self::Medium,
Self::High,
Self::XHigh,
Self::Ultra,
Self::Auto,
Self::Max,
]
.into_iter()
.find(|effort| effort.as_setting() == canonical)
.unwrap_or_default())
}
/// Parse a persisted config-file string into an effort tier. Unknown
+3 -2
View File
@@ -1,6 +1,7 @@
use codewhale_config::route::RouteLimits;
use crate::config::{ProviderKind, provider_capability};
use crate::config::ProviderKind;
use crate::provider_lake::provider_capability;
use crate::reasoning_preference::ReasoningEffort;
use codewhale_models::{DEFAULT_COMPACTION_TOKEN_THRESHOLD, context_window_for_model};
use codewhale_runtime::context_budget::ContextBudget;
@@ -97,7 +98,7 @@ const TOOL_OUTPUT_INLINE_OPT_IN_MAX_CHARS: usize = 2 * 1024 * 1024;
pub(crate) fn route_inline_char_budget(window_tokens: Option<u32>) -> usize {
route_inline_char_budget_with_raise(
window_tokens,
crate::tools::large_output_router::WorkshopConfig::active_tool_result_max_bytes(),
crate::tools::large_output_router::active_tool_result_max_bytes(),
)
}
+3 -3
View File
@@ -949,7 +949,7 @@ fn plan_limit_overrides(
}
let fallback_tokens =
crate::config::provider_capability(provider, resolved.wire_model_id().as_str())
crate::provider_lake::provider_capability(provider, resolved.wire_model_id().as_str())
.context_window;
LimitOverridePlan {
overrides,
@@ -1612,7 +1612,7 @@ mod tests {
// OpenCode Go may not own a models.dev row for kimi-k3; capability and
// budget resolution still must use the 1M K3 contract, never the 128K
// legacy fallback or the 131K max-output field.
let cap = crate::config::provider_capability(ProviderKind::OpencodeGo, "kimi-k3");
let cap = crate::provider_lake::provider_capability(ProviderKind::OpencodeGo, "kimi-k3");
assert_eq!(cap.context_window, 1_048_576);
assert_eq!(cap.max_output, Some(131_072));
assert_ne!(Some(cap.context_window), cap.max_output);
@@ -1711,7 +1711,7 @@ mod tests {
candidate.limits().output_tokens
);
assert_eq!(
crate::config::provider_capability(
crate::provider_lake::provider_capability(
ProviderKind::Moonshot,
crate::config::KIMI_CODE_K3_MODEL
)
+6 -14
View File
@@ -1675,10 +1675,8 @@ fn replay_flag_is_absent(replayed: &bool) -> bool {
!*replayed
}
fn install_runtime_server_workshop_budgets(
config: &Config,
) -> crate::tools::large_output_router::WorkshopConfig {
crate::tools::large_output_router::WorkshopConfig::install_active(config.workshop.as_ref())
fn install_runtime_server_workshop_budgets(config: &Config) -> codewhale_config::WorkshopConfig {
crate::tools::large_output_router::install_active_workshop(config.workshop.as_ref())
}
#[cfg(test)]
@@ -1687,10 +1685,7 @@ fn open_runtime_threads_for_server(
workspace: PathBuf,
manager_config: RuntimeThreadManagerConfig,
plugin_registry: Arc<crate::plugins::PluginRegistry>,
) -> Result<(
SharedRuntimeThreadManager,
crate::tools::large_output_router::WorkshopConfig,
)> {
) -> Result<(SharedRuntimeThreadManager, codewhale_config::WorkshopConfig)> {
open_runtime_threads_for_host(
config,
workspace,
@@ -1710,10 +1705,7 @@ pub(crate) fn open_runtime_threads_for_host(
acp: bool,
preheld_lock: Option<crate::runtime_threads::RuntimeProcessOwnerLock>,
binding: Option<&crate::runtime_threads::RuntimeStoreBinding>,
) -> Result<(
SharedRuntimeThreadManager,
crate::tools::large_output_router::WorkshopConfig,
)> {
) -> Result<(SharedRuntimeThreadManager, codewhale_config::WorkshopConfig)> {
// The Runtime API lazily creates engines after the HTTP/Web server starts.
// Install the resolved process-wide read/tool byte limits before the
// thread manager can spawn any of those engines, matching interactive and
@@ -10962,7 +10954,7 @@ fn provider_models_for_api(
}
}
for model in config.custom_models.as_deref().unwrap_or_default() {
if crate::provider_lake::configured_model_for_route(
if crate::config::configured_model_for_route(
config,
provider,
identity.key.as_str(),
@@ -12724,7 +12716,7 @@ fn normalize_runtime_config_model(
.map_err(ApiError::bad_request)?;
let provider = identity.provider;
let value = value.trim();
if crate::provider_lake::configured_model_for_route(
if crate::config::configured_model_for_route(
config,
provider,
identity.key.as_str(),
+1 -1
View File
@@ -301,7 +301,7 @@ fn mcp_server_review(name: &str, cfg: &crate::mcp::McpServerConfig) -> PluginMcp
url: cfg
.url
.as_deref()
.map(crate::doctor::structural_url_authority),
.map(codewhale_config::structural_url_authority),
env_keys,
header_keys,
}
+9 -9
View File
@@ -155,10 +155,10 @@ async fn http_and_web_server_thread_manager_installs_configured_workshop_byte_bu
let workspace = temp.path().join("workspace");
fs::create_dir_all(&workspace)?;
let config = Config {
workshop: Some(crate::tools::large_output_router::WorkshopConfig {
workshop: Some(codewhale_config::WorkshopConfig {
read_result_max_bytes: Some(73_728),
tool_result_max_bytes: Some(65_536),
..crate::tools::large_output_router::WorkshopConfig::default()
..codewhale_config::WorkshopConfig::default()
}),
..Config::default()
};
@@ -178,20 +178,20 @@ async fn http_and_web_server_thread_manager_installs_configured_workshop_byte_bu
assert_eq!(startup_activation.read_result_max_bytes, Some(73_728));
assert_eq!(startup_activation.tool_result_max_bytes, Some(65_536));
assert_eq!(
crate::tools::large_output_router::WorkshopConfig::active_read_result_max_bytes(),
crate::tools::large_output_router::active_read_result_max_bytes(),
Some(73_728)
);
assert_eq!(
crate::tools::large_output_router::WorkshopConfig::active_tool_result_max_bytes(),
crate::tools::large_output_router::active_tool_result_max_bytes(),
Some(65_536)
);
let reload_activation = runtime_threads
.reload_config(Config {
workshop: Some(crate::tools::large_output_router::WorkshopConfig {
workshop: Some(codewhale_config::WorkshopConfig {
read_result_max_bytes: Some(81_920),
tool_result_max_bytes: Some(77_824),
..crate::tools::large_output_router::WorkshopConfig::default()
..codewhale_config::WorkshopConfig::default()
}),
..Config::default()
})
@@ -199,14 +199,14 @@ async fn http_and_web_server_thread_manager_installs_configured_workshop_byte_bu
assert_eq!(reload_activation.read_result_max_bytes, Some(81_920));
assert_eq!(reload_activation.tool_result_max_bytes, Some(77_824));
assert_eq!(
crate::tools::large_output_router::WorkshopConfig::active_read_result_max_bytes(),
crate::tools::large_output_router::active_read_result_max_bytes(),
Some(81_920)
);
assert_eq!(
crate::tools::large_output_router::WorkshopConfig::active_tool_result_max_bytes(),
crate::tools::large_output_router::active_tool_result_max_bytes(),
Some(77_824)
);
crate::tools::large_output_router::WorkshopConfig::install_active(None);
crate::tools::large_output_router::install_active_workshop(None);
Ok(())
}
@@ -15,7 +15,7 @@ struct CatalogReset;
impl Drop for CatalogReset {
fn drop(&mut self) {
cold_process();
crate::tools::large_output_router::WorkshopConfig::install_active(None);
crate::tools::large_output_router::install_active_workshop(None);
}
}
+3 -3
View File
@@ -7956,7 +7956,7 @@ impl RuntimeThreadManager {
pub async fn reload_config(
&self,
mut new_config: Config,
) -> Result<crate::tools::large_output_router::WorkshopConfig> {
) -> Result<codewhale_config::WorkshopConfig> {
new_config.runtime_thread_inference_unrelated = !new_config.runtime_chat_isolated;
let _config_admission = self.config_admission.write().await;
new_config.preserve_runtime_credentials(&self.read_config());
@@ -8023,7 +8023,7 @@ impl RuntimeThreadManager {
// byte limits are active. Rejected reloads leave the prior limits in
// place.
new_config.retain_compatible_session_keys();
let workshop_activation = crate::tools::large_output_router::WorkshopConfig::install_active(
let workshop_activation = crate::tools::large_output_router::install_active_workshop(
new_config.workshop.as_ref(),
);
crate::config::initialize_cloud_facts(&new_config);
@@ -16675,7 +16675,7 @@ impl RuntimeThreadManager {
} else {
cfg.tools_always_load()
},
user_input_limits: cfg.user_input_limits(),
user_input_limits: crate::tools::user_input::UserInputLimits::from_config(&cfg),
user_input_timeout: cfg.user_input_timeout(),
goal_max_steps: Some(cfg.goal_max_steps()),
tools: (!isolated_chat).then(|| cfg.tools.clone()).flatten(),
+3 -4
View File
@@ -12,7 +12,6 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::config::{expand_path, normalize_model_name};
use crate::reasoning_preference::ReasoningEffort;
use codewhale_config::resolve::Layer;
use codewhale_localization::normalize_configured_locale;
use codewhale_palette::{normalize_hex_rgb_color, normalize_theme_setting};
@@ -403,7 +402,7 @@ pub struct Settings {
/// always take precedence; when this is on, startup still refuses to
/// resume an archived, unreadable, or foreign-workspace session and falls
/// back to a fresh transcript with a receipt. See
/// [`crate::session_resume`] for the decision table.
/// `crate::session_resume` for the decision table.
#[serde(default, skip_serializing_if = "is_false")]
pub session_auto_resume: bool,
/// Cost display currency: usd or cny.
@@ -2771,8 +2770,8 @@ fn normalize_reasoning_effort_setting(value: &str) -> Result<Option<String>> {
return Ok(None);
}
ReasoningEffort::parse_strict(trimmed)
.map(|effort| Some(effort.as_setting().to_string()))
codewhale_config::settings_schema::canonical_reasoning_effort(trimmed)
.map(|effort| Some(effort.to_string()))
.map_err(|err| anyhow::anyhow!("Failed to update setting: {err}"))
}
+8 -8
View File
@@ -324,7 +324,7 @@ pub enum SkillSource {
Plugin {
plugin_id: String,
plugin_name: String,
authority: Box<crate::plugins::types::PluginAuthority>,
authority: Box<codewhale_config::PluginAuthority>,
native_registration: Option<crate::extension_host::skills::NativeSkillRef>,
},
}
@@ -335,24 +335,24 @@ pub enum SkillSource {
#[serde(untagged)]
pub enum SkillProvenance {
NativeRoot(NativeSkillProvenance),
Plugin(crate::plugins::types::PluginAuthority),
Plugin(codewhale_config::PluginAuthority),
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NativeSkillProvenance {
pub authority: crate::plugins::types::PluginAuthority,
pub authority: codewhale_config::PluginAuthority,
pub registration: crate::extension_host::skills::NativeSkillRef,
}
impl From<crate::plugins::types::PluginAuthority> for SkillProvenance {
fn from(authority: crate::plugins::types::PluginAuthority) -> Self {
impl From<codewhale_config::PluginAuthority> for SkillProvenance {
fn from(authority: codewhale_config::PluginAuthority) -> Self {
Self::Plugin(authority)
}
}
impl SkillProvenance {
pub fn authority(&self) -> &crate::plugins::types::PluginAuthority {
pub fn authority(&self) -> &codewhale_config::PluginAuthority {
match self {
Self::Plugin(authority) => authority,
Self::NativeRoot(source) => &source.authority,
@@ -1508,7 +1508,7 @@ fn merge_plugin_skills_from_plugins(
plugins: impl IntoIterator<
Item = (
crate::plugins::types::LoadedPlugin,
crate::plugins::types::PluginAuthority,
codewhale_config::PluginAuthority,
),
>,
) {
@@ -1539,7 +1539,7 @@ fn merge_plugin_skill_snapshots(
registry: &mut SkillRegistry,
plugin_id: &str,
plugin_name: &str,
authority: &crate::plugins::types::PluginAuthority,
authority: &codewhale_config::PluginAuthority,
snapshots: Vec<crate::plugins::types::PluginSkillSnapshot>,
native_registration: Option<crate::extension_host::skills::NativeSkillRef>,
) {
+7 -9
View File
@@ -722,11 +722,10 @@ async fn load_contract_source(
/// request never shrinks a budget the operator widened, and an operator who
/// widened it process-wide keeps that floor when the model asks for less.
fn effective_read_max_bytes(requested: Option<usize>) -> usize {
let baseline =
crate::tools::large_output_router::WorkshopConfig::active_read_result_max_bytes()
.map_or(READ_DEFAULT_MAX_BYTES, |configured| {
configured.clamp(READ_DEFAULT_MAX_BYTES, READ_RESULT_ABSOLUTE_MAX_BYTES)
});
let baseline = crate::tools::large_output_router::active_read_result_max_bytes()
.map_or(READ_DEFAULT_MAX_BYTES, |configured| {
configured.clamp(READ_DEFAULT_MAX_BYTES, READ_RESULT_ABSOLUTE_MAX_BYTES)
});
match requested {
Some(requested) => baseline.max(requested.min(READ_REQUEST_MAX_BYTES)),
None => baseline,
@@ -1473,10 +1472,9 @@ fn render_line_window(
// short head (budget/5) plus the matching tail so the model sees both
// ends of a long range. The full file already lives at `path_str` — the
// recovery note names that absolute/workspace path for a re-read.
let visible_bytes =
crate::tools::large_output_router::WorkshopConfig::active_read_result_max_bytes()
.map(|n| n.clamp(MAX_VISIBLE_BYTES, READ_RESULT_ABSOLUTE_MAX_BYTES))
.unwrap_or(MAX_VISIBLE_BYTES);
let visible_bytes = crate::tools::large_output_router::active_read_result_max_bytes()
.map(|n| n.clamp(MAX_VISIBLE_BYTES, READ_RESULT_ABSOLUTE_MAX_BYTES))
.unwrap_or(MAX_VISIBLE_BYTES);
let truncated_by_bytes = numbered.len() > visible_bytes;
let shown_content = if truncated_by_bytes {
let (head, tail) = head_tail_for_budget(&numbered, visible_bytes);
+6 -6
View File
@@ -112,7 +112,7 @@ fn contract_read_byte_limit_keeps_only_complete_utf8_lines() {
fn read_budget_precedence_is_request_then_workshop_then_default() {
let _workshop_guard = crate::tools::large_output_router::active_workshop_test_guard();
crate::tools::large_output_router::WorkshopConfig::install_active(None);
crate::tools::large_output_router::install_active_workshop(None);
assert_eq!(effective_read_max_bytes(None), READ_DEFAULT_MAX_BYTES);
assert_eq!(effective_read_max_bytes(Some(250_000)), 250_000);
// Above the model-requestable maximum clamps down instead of erroring.
@@ -123,8 +123,8 @@ fn read_budget_precedence_is_request_then_workshop_then_default() {
// A request below the active baseline leaves the baseline in place.
assert_eq!(effective_read_max_bytes(Some(10)), READ_DEFAULT_MAX_BYTES);
crate::tools::large_output_router::WorkshopConfig::install_active(Some(
&crate::tools::large_output_router::WorkshopConfig {
crate::tools::large_output_router::install_active_workshop(Some(
&codewhale_config::WorkshopConfig {
read_result_max_bytes: Some(700_000),
..Default::default()
},
@@ -132,8 +132,8 @@ fn read_budget_precedence_is_request_then_workshop_then_default() {
assert_eq!(effective_read_max_bytes(None), 700_000);
assert_eq!(effective_read_max_bytes(Some(200_000)), 700_000);
// The workshop override keeps the 2 MiB absolute ceiling.
crate::tools::large_output_router::WorkshopConfig::install_active(Some(
&crate::tools::large_output_router::WorkshopConfig {
crate::tools::large_output_router::install_active_workshop(Some(
&codewhale_config::WorkshopConfig {
read_result_max_bytes: Some(READ_RESULT_ABSOLUTE_MAX_BYTES * 4),
..Default::default()
},
@@ -142,7 +142,7 @@ fn read_budget_precedence_is_request_then_workshop_then_default() {
effective_read_max_bytes(None),
READ_RESULT_ABSOLUTE_MAX_BYTES
);
crate::tools::large_output_router::WorkshopConfig::install_active(None);
crate::tools::large_output_router::install_active_workshop(None);
}
#[tokio::test]
+61 -88
View File
@@ -8,7 +8,6 @@
//! and publishes non-inline results exactly once under their origin session
//! with immutable evidence metadata for bounded retrieval.
use std::collections::HashMap;
use std::io;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
@@ -16,6 +15,7 @@ use std::sync::{Mutex, OnceLock};
use serde::{Deserialize, Serialize};
use crate::tools::spec::ToolResult;
use codewhale_config::WorkshopConfig;
// ── Constants ──────────────────────────────────────────────────────────────────
@@ -110,85 +110,60 @@ fn active_workshop_slot() -> &'static Mutex<WorkshopConfig> {
// ── Configuration ─────────────────────────────────────────────────────────────
/// Existing `[workshop]` threshold configuration, retained for compatibility.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct WorkshopConfig {
/// Token threshold above which results become handle-only evidence.
#[serde(default)]
pub large_output_threshold_tokens: Option<usize>,
/// Per-tool threshold overrides (tool name → token limit). A tool whose
/// name appears here uses this limit instead of
/// `large_output_threshold_tokens`.
#[serde(default)]
pub per_tool_thresholds: Option<HashMap<String, usize>>,
/// Optional model-visible byte budget for a single `read` / `read_file`
/// result. Absent keeps the compile-time default (#5367).
#[serde(default)]
pub read_result_max_bytes: Option<usize>,
/// Optional model-visible byte budget for a generic tool result after
/// spillover. Absent keeps the compile-time default (#5367).
#[serde(default)]
pub tool_result_max_bytes: Option<usize>,
/// Install the process-wide workshop budgets used by read/tool compactors.
///
/// The returned immutable receipt is the snapshot written while the
/// singleton lock was held. Callers that need evidence of their own
/// activation can inspect it without racing a later process-wide update.
pub fn install_active_workshop(config: Option<&WorkshopConfig>) -> WorkshopConfig {
#[cfg(test)]
let _test_serial = if ACTIVE_WORKSHOP_TEST_SERIAL_HELD.with(std::cell::Cell::get) {
None
} else {
Some(
ACTIVE_WORKSHOP_TEST_SERIAL
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
)
};
let snapshot = config.cloned().unwrap_or_default();
let mut slot = active_workshop_slot()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*slot = snapshot;
slot.clone()
}
impl WorkshopConfig {
/// Install the process-wide workshop budgets used by read/tool compactors.
///
/// The returned immutable receipt is the snapshot written while the
/// singleton lock was held. Callers that need evidence of their own
/// activation can inspect it without racing a later process-wide update.
pub fn install_active(config: Option<&Self>) -> Self {
#[cfg(test)]
let _test_serial = if ACTIVE_WORKSHOP_TEST_SERIAL_HELD.with(std::cell::Cell::get) {
None
} else {
Some(
ACTIVE_WORKSHOP_TEST_SERIAL
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
)
};
let snapshot = config.cloned().unwrap_or_default();
let mut slot = active_workshop_slot()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*slot = snapshot;
slot.clone()
}
/// Optional model-visible read budget, when the user opted in (#5367).
#[must_use]
pub fn active_read_result_max_bytes() -> Option<usize> {
active_workshop_slot()
.lock()
.ok()
.and_then(|cfg| cfg.read_result_max_bytes.filter(|n| *n > 0))
}
/// Optional model-visible read budget, when the user opted in (#5367).
#[must_use]
pub fn active_read_result_max_bytes() -> Option<usize> {
active_workshop_slot()
.lock()
.ok()
.and_then(|cfg| cfg.read_result_max_bytes.filter(|n| *n > 0))
}
/// Optional model-visible tool-result budget, when the user opted in (#5367).
#[must_use]
pub fn active_tool_result_max_bytes() -> Option<usize> {
active_workshop_slot()
.lock()
.ok()
.and_then(|cfg| cfg.tool_result_max_bytes.filter(|n| *n > 0))
}
/// Optional model-visible tool-result budget, when the user opted in (#5367).
#[must_use]
pub fn active_tool_result_max_bytes() -> Option<usize> {
active_workshop_slot()
.lock()
.ok()
.and_then(|cfg| cfg.tool_result_max_bytes.filter(|n| *n > 0))
}
/// Resolve the effective threshold for the given tool name.
#[must_use]
pub fn threshold_for(&self, tool_name: &str) -> usize {
if let Some(per_tool) = self.per_tool_thresholds.as_ref()
&& let Some(&limit) = per_tool.get(tool_name)
{
return limit;
}
self.large_output_threshold_tokens
.unwrap_or(DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS)
/// Resolve the effective threshold for the given tool name.
#[must_use]
pub fn workshop_threshold_for(config: &WorkshopConfig, tool_name: &str) -> usize {
if let Some(per_tool) = config.per_tool_thresholds.as_ref()
&& let Some(&limit) = per_tool.get(tool_name)
{
return limit;
}
config
.large_output_threshold_tokens
.unwrap_or(DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS)
}
// ── Token estimation ──────────────────────────────────────────────────────────
@@ -230,7 +205,7 @@ impl LargeOutputRouter {
tool_name: &str,
result: &ToolResult,
) -> (EvidenceRouting, usize, usize) {
let threshold = self.config.threshold_for(tool_name);
let threshold = workshop_threshold_for(&self.config, tool_name);
let estimated_tokens = estimate_tokens(&result.content);
// There is no per-call bypass of the context bound: exact bytes stay
// available through the artifact handle.
@@ -382,6 +357,7 @@ pub fn evidence_is_expired(artifact: &EvidenceArtifact, now_ms: u64) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn make_result(content: &str) -> ToolResult {
ToolResult::success(content.to_string())
@@ -410,11 +386,11 @@ mod tests {
read_result_max_bytes: None,
tool_result_max_bytes: None,
};
assert_eq!(config.threshold_for("grep_files"), 100);
assert_eq!(config.threshold_for("read_file"), 4096);
assert_eq!(workshop_threshold_for(&config, "grep_files"), 100);
assert_eq!(workshop_threshold_for(&config, "read_file"), 4096);
let default_config = WorkshopConfig::default();
assert_eq!(
default_config.threshold_for("read_file"),
workshop_threshold_for(&default_config, "read_file"),
DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS
);
}
@@ -422,7 +398,7 @@ mod tests {
#[test]
fn workshop_byte_budgets_raise_floor_only() {
let _guard = active_workshop_test_guard();
let installed = WorkshopConfig::install_active(Some(&WorkshopConfig {
let installed = install_active_workshop(Some(&WorkshopConfig {
large_output_threshold_tokens: None,
per_tool_thresholds: None,
read_result_max_bytes: Some(102_400),
@@ -430,16 +406,13 @@ mod tests {
}));
assert_eq!(installed.read_result_max_bytes, Some(102_400));
assert_eq!(installed.tool_result_max_bytes, Some(80_000));
assert_eq!(
WorkshopConfig::active_read_result_max_bytes(),
Some(102_400)
);
assert_eq!(WorkshopConfig::active_tool_result_max_bytes(), Some(80_000));
let cleared = WorkshopConfig::install_active(None);
assert_eq!(active_read_result_max_bytes(), Some(102_400));
assert_eq!(active_tool_result_max_bytes(), Some(80_000));
let cleared = install_active_workshop(None);
assert_eq!(cleared.read_result_max_bytes, None);
assert_eq!(cleared.tool_result_max_bytes, None);
assert_eq!(WorkshopConfig::active_read_result_max_bytes(), None);
assert_eq!(WorkshopConfig::active_tool_result_max_bytes(), None);
assert_eq!(active_read_result_max_bytes(), None);
assert_eq!(active_tool_result_max_bytes(), None);
}
#[test]
+4 -4
View File
@@ -457,8 +457,8 @@ mod tests {
plugin_id: "workspace/1/demo".to_string(),
plugin_name: "demo".to_string(),
native_registration: None,
authority: Box::new(crate::plugins::types::PluginAuthority {
plugin_id: crate::plugins::types::PluginId("workspace/1/demo".to_string()),
authority: Box::new(codewhale_config::PluginAuthority {
plugin_id: codewhale_config::PluginId("workspace/1/demo".to_string()),
plugin_name: "demo".to_string(),
workspace: tmp.path().to_path_buf(),
state_path: tmp.path().join("state.json"),
@@ -492,8 +492,8 @@ mod tests {
plugin_id: "workspace/123/demo".to_string(),
plugin_name: "demo".to_string(),
native_registration: None,
authority: Box::new(crate::plugins::types::PluginAuthority {
plugin_id: crate::plugins::types::PluginId("workspace/123/demo".to_string()),
authority: Box::new(codewhale_config::PluginAuthority {
plugin_id: codewhale_config::PluginId("workspace/123/demo".to_string()),
plugin_name: "demo".to_string(),
workspace: tmp.path().to_path_buf(),
state_path: tmp.path().join("state.json"),
+1 -1
View File
@@ -19006,7 +19006,7 @@ fn annotate_child_model_error_with_origin(
const SUBAGENT_SUMMARY_CHAR_BUDGET: usize = 12_000;
fn subagent_summary_char_budget() -> usize {
crate::tools::large_output_router::WorkshopConfig::active_tool_result_max_bytes()
crate::tools::large_output_router::active_tool_result_max_bytes()
.map(|bytes| bytes.clamp(SUBAGENT_SUMMARY_CHAR_BUDGET, 2 * 1024 * 1024))
.unwrap_or(SUBAGENT_SUMMARY_CHAR_BUDGET)
}
+13 -1
View File
@@ -29,7 +29,7 @@ pub const MAX_OPTIONS_KEY: &str = "[tools] user_input_max_options";
/// Effective `request_user_input` payload ceilings for one session.
///
/// Resolved once from `[tools]` in config.toml (see
/// [`crate::config::Config::user_input_limits`]) and carried to the three
/// [`UserInputLimits::from_config`]) and carried to the three
/// places that must agree: the validator, the tool's JSON schema, and its
/// model-visible description.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -48,6 +48,18 @@ impl Default for UserInputLimits {
}
impl UserInputLimits {
/// Effective `request_user_input` payload ceilings for this session
/// (#5949). Resolved once per engine build; out-of-range `[tools]` values
/// clamp with a single warning naming the key and the value used.
#[must_use]
pub fn from_config(config: &crate::config::Config) -> Self {
let tools = config.tools.as_ref();
Self::from_config_values(
tools.and_then(|t| t.user_input_max_questions),
tools.and_then(|t| t.user_input_max_options),
)
}
/// Resolve raw `[tools]` values, clamping each into its supported range.
/// An out-of-range value is honoured as far as it can be rather than
/// failing the load, and says so once with the key and the value used.
+2 -2
View File
@@ -2561,7 +2561,7 @@ fn push_provider_model_rows(
let base_url = config.base_url_for_route(admitted);
let fresh_roster = fresh_roster_listing(provider, &identity, &base_url);
for declaration in config.custom_models.as_deref().unwrap_or_default() {
if crate::provider_lake::configured_model_for_route(
if crate::config::configured_model_for_route(
config,
provider,
&identity,
@@ -3709,7 +3709,7 @@ fn effective_picker_metadata_with_codex(
.or_else(|| config.context_window_for_provider_config(admitted));
let base_url = config.base_url_for_route(admitted);
if let Some(declared) =
crate::provider_lake::configured_model_for_route(config, provider, identity, &base_url, id)
crate::config::configured_model_for_route(config, provider, identity, &base_url, id)
{
let (wire_model_id, canonical_model) = picker_offering_identity(offering.as_ref(), id);
return EffectivePickerMetadata {
+3 -3
View File
@@ -788,7 +788,7 @@ impl ProviderDashboardRow {
.flatten()
})
.filter(|model| {
crate::provider_lake::configured_model_for_route(
crate::config::configured_model_for_route(
config,
provider,
&provider_id,
@@ -920,7 +920,7 @@ impl ProviderDashboardRow {
capabilities.context_window = Some(context_window);
}
capabilities.context_window_source = route_context_window_source;
if let Some(declared) = crate::provider_lake::configured_model_for_route(
if let Some(declared) = crate::config::configured_model_for_route(
config,
provider,
&provider_id,
@@ -1656,7 +1656,7 @@ fn configured_model_cost_label(config: &Config, row: &ProviderDashboardRow, mode
crate::route_billing::BillingPresentation::Local => return "local".to_string(),
_ => {}
}
if let Some(declared) = crate::provider_lake::configured_model_for_route(
if let Some(declared) = crate::config::configured_model_for_route(
config,
row.provider,
&row.provider_id,
+2 -4
View File
@@ -1061,9 +1061,7 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
reasoning_only_reprompt_message: Some(config.reasoning_only_reprompt_message().to_string()),
locale_tag: app.ui_locale.tag().to_string(),
workshop: {
crate::tools::large_output_router::WorkshopConfig::install_active(
config.workshop.as_ref(),
);
crate::tools::large_output_router::install_active_workshop(config.workshop.as_ref());
config.workshop.clone()
},
search_provider: config.search_provider(),
@@ -1071,7 +1069,7 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
search_base_url: config.search.as_ref().and_then(|s| s.base_url.clone()),
search_native: config.search_native(),
tools_always_load: config.tools_always_load(),
user_input_limits: config.user_input_limits(),
user_input_limits: crate::tools::user_input::UserInputLimits::from_config(&config),
user_input_timeout: config.user_input_timeout(),
goal_max_steps: Some(config.goal_max_steps()),
tools: config.tools.clone(),