mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
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;8b5baa7a5fexplains 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 on135319aa1a. - After rebasing onto4d2b3fdb1b, `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:
1 parent
a43f4c1e78
commit
2947e4c51d
70 files changed
+752
-754
No files matched your search
Generated
+1
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", "", ""),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)?
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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 || {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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::*;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user