mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
fix(tui): actionable CLI error messages (doctor, exec, stream, review)
Message-only slices plus one presence-only credential check: - doctor: plain value/profile validation errors are shown (without the rejected value, which may be a pasted secret) with `fix: codewhale config set <key> <valid>`; parse and credential errors stay suppressed. Text and --json paths share SafeConfigDiagnostic. - doctor verdict: a key saved with `codewhale auth set` (secret store, config, or env; read-only presence) clears "no model provider"; the hint is /provider or `codewhale auth set --provider <p>` when headless. - exec preflight: headless next steps name CLI commands only, no duplicate auth instruction, no "..", multi-line guidance keeps its indentation; DeepSeek zsh caveat on its own line and the DeepSeek Harness bullet only when dsh is on PATH; the final exec failure line no longer repeats an error the stream already printed. - `config set provider <typo>` reuses "Invalid provider 'x': expected ...". - SSE H1 fallback: full error chain, FORCE_HTTP1 hint only after protocol/TLS errors, connect failures read "Cannot reach host:port". - 400s classify as ContextLengthError only on context-specific phrases. - cloud sanitize_error: controls/whitespace collapse to one space, redact before cut, keep head and tail. - exec --resume shows the typed id (redacted only for stream-json) plus `Run \`codewhale sessions\` to list ids.`; review outside a repo says "Not inside a git repository (cwd: ...)", git diff errors keep one line. Tests: targeted codewhale-tui lib tests, 63 passed / 0 failed; touched modules (config::tests, llm_client, cloud_dispatch, exec_agent, route_preferences, doctor) 724 passed / 0 failed / 1 ignored. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_014ZwqatxgVFxHvovngywnks
This commit is contained in:
1 parent
44b547dace
commit
2562f29971
12 files changed
+824
-130
No files matched your search
@@ -229,11 +229,55 @@ fn h1_fallback_error(err: anyhow::Error) -> anyhow::Error {
|
||||
return err;
|
||||
}
|
||||
|
||||
anyhow::Error::new(LlmError::NetworkError(format!(
|
||||
"SSE stream request failed after HTTP/1.1 fallback: {err}. \
|
||||
`codewhale doctor` can still pass when non-streaming requests work; \
|
||||
on Windows or proxy networks, try `CODEWHALE_FORCE_HTTP1=1` and rerun `codewhale`."
|
||||
)))
|
||||
let detail = format!("{err:#}");
|
||||
if let Some(unreachable) = connect_failure_summary(&err, &detail) {
|
||||
return anyhow::Error::new(LlmError::NetworkError(format!("{unreachable}: {detail}")));
|
||||
}
|
||||
let mut message = format!("SSE stream request failed after HTTP/1.1 fallback: {detail}.");
|
||||
// The HTTP/1.1 hint only helps when the protocol or TLS layer failed; it
|
||||
// is noise for a refused connection or an unknown host.
|
||||
if is_protocol_or_tls_failure(&detail) {
|
||||
message.push_str(
|
||||
" `codewhale doctor` can still pass when non-streaming requests work; \
|
||||
on Windows or proxy networks, try `CODEWHALE_FORCE_HTTP1=1` and rerun `codewhale`.",
|
||||
);
|
||||
}
|
||||
anyhow::Error::new(LlmError::NetworkError(message))
|
||||
}
|
||||
|
||||
/// `Cannot reach host:port (<why>)` for a failure to open the connection.
|
||||
fn connect_failure_summary(err: &anyhow::Error, detail: &str) -> Option<String> {
|
||||
let reqwest_error = err
|
||||
.chain()
|
||||
.find_map(|cause| cause.downcast_ref::<reqwest::Error>())
|
||||
.filter(|error| error.is_connect())?;
|
||||
let target = reqwest_error
|
||||
.url()
|
||||
.and_then(|url| {
|
||||
Some(format!(
|
||||
"{}:{}",
|
||||
url.host_str()?,
|
||||
url.port_or_known_default()?
|
||||
))
|
||||
})
|
||||
.unwrap_or_else(|| "the provider host".to_string());
|
||||
let lower = detail.to_ascii_lowercase();
|
||||
let why = if lower.contains("refused") {
|
||||
"connection refused"
|
||||
} else if lower.contains("dns") || lower.contains("lookup") {
|
||||
"DNS lookup failed"
|
||||
} else {
|
||||
"connection failed"
|
||||
};
|
||||
Some(format!("Cannot reach {target} ({why})"))
|
||||
}
|
||||
|
||||
fn is_protocol_or_tls_failure(detail: &str) -> bool {
|
||||
let lower = detail.to_ascii_lowercase();
|
||||
should_retry_with_h1(StreamHttpPolicy::DualWithH1Fallback, &lower)
|
||||
|| ["tls", "ssl", "certificate", "handshake", "alpn"]
|
||||
.iter()
|
||||
.any(|needle| lower.contains(needle))
|
||||
}
|
||||
|
||||
/// Open an SSE response through the shared transport policy.
|
||||
@@ -505,6 +549,58 @@ mod tests {
|
||||
assert!(err.to_string().contains("connection reset"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refused_connection_names_the_host_and_skips_the_http1_hint() {
|
||||
let port = {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
listener.local_addr().expect("addr").port()
|
||||
};
|
||||
let client = crate::tls::reqwest_client();
|
||||
let url = format!("http://127.0.0.1:{port}/v1/chat");
|
||||
let err = open_sse_response(
|
||||
&open_req(StreamHttpPolicy::DualWithH1Fallback, Duration::from_secs(5)),
|
||||
|_| {
|
||||
let request = client.post(&url);
|
||||
async move { Ok::<_, anyhow::Error>(request.send().await?) }
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("nothing listens on the port");
|
||||
let text = err.to_string();
|
||||
assert!(
|
||||
text.contains(&format!(
|
||||
"Cannot reach 127.0.0.1:{port} (connection refused)"
|
||||
)),
|
||||
"{text}"
|
||||
);
|
||||
assert!(!text.contains("CODEWHALE_FORCE_HTTP1"), "{text}");
|
||||
assert!(
|
||||
err.downcast_ref::<LlmError>()
|
||||
.is_some_and(LlmError::is_retryable),
|
||||
"{err:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h1_fallback_hint_follows_protocol_errors_with_the_full_chain() {
|
||||
let protocol = h1_fallback_error(
|
||||
anyhow::Error::new(LlmError::NetworkError("http2 protocol error".to_string()))
|
||||
.context("sending stream request"),
|
||||
);
|
||||
let text = protocol.to_string();
|
||||
assert!(text.contains("sending stream request: "), "{text}");
|
||||
assert!(text.contains("http2 protocol error"), "{text}");
|
||||
assert!(text.contains("CODEWHALE_FORCE_HTTP1=1"), "{text}");
|
||||
|
||||
let other = h1_fallback_error(anyhow::Error::new(LlmError::NetworkError(
|
||||
"unexpected end of body".to_string(),
|
||||
)));
|
||||
assert!(
|
||||
!other.to_string().contains("CODEWHALE_FORCE_HTTP1"),
|
||||
"{other}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_error_from_h1_fallback_keeps_its_semantic_type() {
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
@@ -2107,17 +2107,29 @@ fn one_line(value: &str, max: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitized (control-character-free, bounded) error text for job notes.
|
||||
const SANITIZED_ERROR_MAX_CHARS: usize = 240;
|
||||
|
||||
/// Sanitized (single-line, secret-redacted, bounded) error text for job
|
||||
/// notes. Controls, newlines and tabs become one space so words never fuse;
|
||||
/// long text keeps its head and its tail, because harness and command output
|
||||
/// put the actual failure last.
|
||||
pub fn sanitize_error(message: &str) -> String {
|
||||
redact_machine_tokens(&redact_url_userinfo(
|
||||
&message
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_control())
|
||||
.collect::<String>(),
|
||||
))
|
||||
.chars()
|
||||
.take(240)
|
||||
.collect()
|
||||
let flat = message
|
||||
.split(|ch: char| ch.is_control() || ch.is_whitespace())
|
||||
.filter(|word| !word.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
// Redact before cutting so a secret can never be split past the redactor.
|
||||
let redacted = redact_machine_tokens(&redact_url_userinfo(&flat));
|
||||
let count = redacted.chars().count();
|
||||
if count <= SANITIZED_ERROR_MAX_CHARS {
|
||||
return redacted;
|
||||
}
|
||||
let head_chars = SANITIZED_ERROR_MAX_CHARS / 3;
|
||||
let tail_chars = SANITIZED_ERROR_MAX_CHARS - head_chars - 1;
|
||||
let head: String = redacted.chars().take(head_chars).collect();
|
||||
let tail: String = redacted.chars().skip(count - tail_chars).collect();
|
||||
format!("{}…{}", head.trim_end(), tail.trim_start())
|
||||
}
|
||||
|
||||
/// Replace anything shaped like a Codewhale account machine token with its
|
||||
@@ -2576,6 +2588,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_error_flattens_whitespace_and_keeps_the_tail() {
|
||||
assert_eq!(
|
||||
sanitize_error("npm ERR!\tcode 1\n\n npm ERR!\u{7} missing\r\nscript"),
|
||||
"npm ERR! code 1 npm ERR! missing script"
|
||||
);
|
||||
let long = format!(
|
||||
"{} error: the real failure is here",
|
||||
"progress line\n".repeat(60)
|
||||
);
|
||||
let sanitized = sanitize_error(&long);
|
||||
assert!(
|
||||
sanitized.chars().count() <= SANITIZED_ERROR_MAX_CHARS,
|
||||
"{sanitized}"
|
||||
);
|
||||
assert!(
|
||||
sanitized.starts_with("progress line progress line"),
|
||||
"{sanitized}"
|
||||
);
|
||||
assert!(sanitized.contains('…'), "{sanitized}");
|
||||
assert!(
|
||||
sanitized.ends_with("error: the real failure is here"),
|
||||
"{sanitized}"
|
||||
);
|
||||
assert!(!sanitized.contains('\n'), "{sanitized}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_names_are_slug_charset_and_bounded() {
|
||||
assert!(valid_snapshot_name("codewhale-cloud-agent"));
|
||||
|
||||
+176
-34
@@ -5132,10 +5132,13 @@ impl Config {
|
||||
.and_then(|providers| providers.custom_provider_config(provider))
|
||||
.is_none()
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Invalid provider '{provider}': expected {}.",
|
||||
ApiProvider::names_hint()
|
||||
);
|
||||
return Err(SafeConfigDiagnostic::invalid_value(
|
||||
"provider",
|
||||
provider,
|
||||
&ApiProvider::names_hint(),
|
||||
ApiProvider::Deepseek.as_str(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let active_provider = self.api_provider();
|
||||
match validate_kimi_code_api_model_id(
|
||||
@@ -5191,10 +5194,16 @@ impl Config {
|
||||
} else {
|
||||
format!(" (for example: {})", known.join(", "))
|
||||
};
|
||||
anyhow::bail!(
|
||||
"Invalid configured model '{model}' for provider '{}': expected auto or a model ID this provider serves{hint}.",
|
||||
provider.as_str()
|
||||
);
|
||||
return Err(SafeConfigDiagnostic::invalid_value(
|
||||
"model",
|
||||
model,
|
||||
&format!(
|
||||
"auto or a model ID provider '{}' serves{hint}",
|
||||
provider.as_str()
|
||||
),
|
||||
"auto",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if let Some(policy) = self.approval_policy.as_deref() {
|
||||
let normalized = policy.trim().to_ascii_lowercase();
|
||||
@@ -5202,15 +5211,25 @@ impl Config {
|
||||
normalized.as_str(),
|
||||
"on-request" | "untrusted" | "never" | "auto" | "suggest"
|
||||
) {
|
||||
anyhow::bail!(
|
||||
"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest."
|
||||
);
|
||||
return Err(SafeConfigDiagnostic::invalid_value(
|
||||
"approval_policy",
|
||||
policy,
|
||||
"on-request, untrusted, never, auto, or suggest",
|
||||
"on-request",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
if let Some(v) = self.verbosity.as_deref() {
|
||||
let normalized = v.trim().to_ascii_lowercase();
|
||||
if !matches!(normalized.as_str(), "normal" | "concise") {
|
||||
anyhow::bail!("Invalid verbosity '{v}': expected normal or concise.");
|
||||
return Err(SafeConfigDiagnostic::invalid_value(
|
||||
"verbosity",
|
||||
v,
|
||||
"normal or concise",
|
||||
"normal",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
if let Some(mode) = self.sandbox_mode.as_deref() {
|
||||
@@ -5219,9 +5238,13 @@ impl Config {
|
||||
normalized.as_str(),
|
||||
"read-only" | "workspace-write" | "danger-full-access" | "external-sandbox"
|
||||
) {
|
||||
anyhow::bail!(
|
||||
"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox."
|
||||
);
|
||||
return Err(SafeConfigDiagnostic::invalid_value(
|
||||
"sandbox_mode",
|
||||
mode,
|
||||
"read-only, workspace-write, danger-full-access, or external-sandbox",
|
||||
"workspace-write",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
if let Some(tui) = &self.tui
|
||||
@@ -5229,9 +5252,13 @@ impl Config {
|
||||
{
|
||||
let mode = mode.to_ascii_lowercase();
|
||||
if !matches!(mode.as_str(), "auto" | "always" | "never") {
|
||||
anyhow::bail!(
|
||||
"Invalid tui.alternate_screen '{mode}': expected auto, always, or never."
|
||||
);
|
||||
return Err(SafeConfigDiagnostic::invalid_value(
|
||||
"tui.alternate_screen",
|
||||
&mode,
|
||||
"auto, always, or never",
|
||||
"auto",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
if let Some(transcript) = &self.transcript
|
||||
@@ -5546,6 +5573,29 @@ impl Config {
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
/// Resolve a provider a user is selecting now (`codewhale config set
|
||||
/// provider <name>`). A name that is neither a built-in provider nor a
|
||||
/// configured table is a typo, not a saved session missing its route, so
|
||||
/// it gets config validation's wording instead of the resume wording.
|
||||
pub(crate) fn resolve_provider_selection_identity(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
) -> std::result::Result<ProviderIdentity, String> {
|
||||
let requested = provider_id.trim();
|
||||
if !requested.is_empty()
|
||||
&& ApiProvider::parse(requested).is_none()
|
||||
&& !requested.eq_ignore_ascii_case(ApiProvider::Custom.as_str())
|
||||
&& self
|
||||
.providers
|
||||
.as_ref()
|
||||
.and_then(|providers| providers.custom_provider_config(requested))
|
||||
.is_none()
|
||||
{
|
||||
return Err(invalid_provider_message(requested));
|
||||
}
|
||||
self.resolve_provider_pin_identity(provider_id)
|
||||
}
|
||||
|
||||
/// Resolve an additive exact provider id. Unlike raw selector resolution,
|
||||
/// an id means the record requires that exact `[providers.<id>]` table.
|
||||
fn resolve_exact_provider_identity(
|
||||
@@ -7264,21 +7314,11 @@ impl Config {
|
||||
.credential_url()
|
||||
.unwrap_or("https://app.codewhale.net/settings?section=api")
|
||||
),
|
||||
ApiProvider::Deepseek | ApiProvider::DeepseekCN => anyhow::bail!(
|
||||
"DeepSeek API key not found.\n\
|
||||
\n\
|
||||
1. Get a key: https://platform.deepseek.com/api_keys\n\
|
||||
2. Save it (works in every folder, no OS prompts):\n\
|
||||
codewhale auth set --provider deepseek\n\
|
||||
\n\
|
||||
Alternatives:\n\
|
||||
• export DEEPSEEK_API_KEY=<your-key> (current shell only;\n\
|
||||
also note: zsh users — exports in ~/.zshrc only reach interactive\n\
|
||||
shells, prefer ~/.zshenv for everything)\n\
|
||||
• api_key = \"<your-key>\" in ~/.codewhale/config.toml\n\
|
||||
• already configured DeepSeek Harness? grant read-only access:\n\
|
||||
codewhale auth external-consent --provider deepseek --mode read-only"
|
||||
),
|
||||
ApiProvider::Deepseek | ApiProvider::DeepseekCN => {
|
||||
anyhow::bail!(deepseek_missing_key_message(
|
||||
crate::integrations::dsh::detect::dsh_on_path()
|
||||
))
|
||||
}
|
||||
ApiProvider::SiliconflowCn => anyhow::bail!(
|
||||
"SiliconFlow China API key not found. Get a key: {}. Run 'codewhale auth set --provider siliconflow-CN', \
|
||||
set {}, or add [{}] api_key in ~/.codewhale/config.toml. \
|
||||
@@ -10682,6 +10722,31 @@ pub(crate) fn is_kimi_code_membership_model(model: &str) -> bool {
|
||||
.any(|id| model.eq_ignore_ascii_case(id))
|
||||
}
|
||||
|
||||
/// Missing-key guidance for DeepSeek. Built from lines so indentation
|
||||
/// survives (a `\` string continuation strips it). The DeepSeek Harness
|
||||
/// bullet appears only when `dsh` is installed.
|
||||
fn deepseek_missing_key_message(dsh_detected: bool) -> String {
|
||||
let mut lines = vec![
|
||||
"DeepSeek API key not found.",
|
||||
"",
|
||||
"1. Get a key: https://platform.deepseek.com/api_keys",
|
||||
"2. Save it (works in every folder, no OS prompts):",
|
||||
" codewhale auth set --provider deepseek",
|
||||
"",
|
||||
"Alternatives:",
|
||||
" • export DEEPSEEK_API_KEY=<your-key> (current shell only)",
|
||||
" zsh: exports in ~/.zshrc reach only interactive shells; use ~/.zshenv.",
|
||||
" • api_key = \"<your-key>\" in ~/.codewhale/config.toml",
|
||||
];
|
||||
if dsh_detected {
|
||||
lines.extend([
|
||||
" • already configured DeepSeek Harness? grant read-only access:",
|
||||
" codewhale auth external-consent --provider deepseek --mode read-only",
|
||||
]);
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// The Moonshot direct-platform roster, as one fact. Mirror of
|
||||
/// [`KIMI_CODE_MEMBERSHIP_MODELS`] for the pay-as-you-go product.
|
||||
pub(crate) const MOONSHOT_DIRECT_PLATFORM_MODELS: [&str; 3] = [
|
||||
@@ -10692,10 +10757,68 @@ pub(crate) const MOONSHOT_DIRECT_PLATFORM_MODELS: [&str; 3] = [
|
||||
|
||||
pub(crate) const KIMI_CODE_CLAUDE_ALIAS_GUIDANCE: &str = "Kimi Code model `k3[1m]` is a Claude Code environment convention, not an API model id. Use model = \"k3\". If your Kimi Code plan includes 1M context, also set context_window = 1048576; otherwise keep the 262144 safe default.";
|
||||
|
||||
/// Configuration errors whose text is safe to show in diagnostics such as
|
||||
/// `codewhale doctor`. Everything else stays suppressed there because parse
|
||||
/// errors and credential fields can echo secret material.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum SafeConfigDiagnostic {
|
||||
#[error("{}", KIMI_CODE_CLAUDE_ALIAS_GUIDANCE)]
|
||||
KimiCodeClaudeAlias,
|
||||
/// A plain enum/value validation failure on a non-credential key.
|
||||
/// `message` quotes the rejected value for the local error; `shareable`
|
||||
/// omits it, because a mistyped value can still be a pasted secret.
|
||||
#[error("{message}")]
|
||||
InvalidValue {
|
||||
message: String,
|
||||
shareable: String,
|
||||
/// A `codewhale config set <key> <valid>` command, when one fixes it.
|
||||
fix: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl SafeConfigDiagnostic {
|
||||
fn invalid_value(key: &str, value: &str, expected: &str, valid: &str) -> Self {
|
||||
Self::InvalidValue {
|
||||
message: format!("Invalid {key} '{value}': expected {expected}."),
|
||||
shareable: format!("Invalid {key} (value not shown): expected {expected}."),
|
||||
fix: Some(format!("codewhale config set {key} {valid}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The diagnostic text for reports such as `codewhale doctor`, without
|
||||
/// the rejected value and redacted defensively.
|
||||
pub(crate) fn display_message(&self) -> String {
|
||||
let text = match self {
|
||||
Self::KimiCodeClaudeAlias => self.to_string(),
|
||||
Self::InvalidValue { shareable, .. } => shareable.clone(),
|
||||
};
|
||||
codewhale_secrets::redact::redact_secrets(&text)
|
||||
}
|
||||
|
||||
pub(crate) fn fix(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::KimiCodeClaudeAlias => None,
|
||||
Self::InvalidValue { fix, .. } => fix.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a safe diagnostic anywhere in an error chain (loaders wrap
|
||||
/// validation errors in file-path context).
|
||||
pub(crate) fn find_in(error: &anyhow::Error) -> Option<&Self> {
|
||||
error.chain().find_map(|cause| cause.downcast_ref::<Self>())
|
||||
}
|
||||
}
|
||||
|
||||
/// The one wording for an unknown provider name, shared by config validation
|
||||
/// and `config set provider`.
|
||||
pub(crate) fn invalid_provider_message(provider: &str) -> String {
|
||||
SafeConfigDiagnostic::invalid_value(
|
||||
"provider",
|
||||
provider,
|
||||
&ApiProvider::names_hint(),
|
||||
ApiProvider::Deepseek.as_str(),
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Fail closed on known-bad model/endpoint pairings (#4687).
|
||||
@@ -11072,7 +11195,14 @@ fn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result<Config> {
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "none".to_string());
|
||||
anyhow::bail!("Profile '{profile_name}' not found. Available profiles: {available}")
|
||||
let message =
|
||||
format!("Profile '{profile_name}' not found. Available profiles: {available}");
|
||||
Err(SafeConfigDiagnostic::InvalidValue {
|
||||
shareable: message.clone(),
|
||||
message,
|
||||
fix: None,
|
||||
}
|
||||
.into())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -12317,6 +12447,18 @@ pub fn active_provider_has_env_api_key(config: &Config) -> bool {
|
||||
&& provider_env_api_key(provider).is_some())
|
||||
}
|
||||
|
||||
/// Whether the active provider has a credential in the environment, the
|
||||
/// durable secret store, or the user-global config. Presence only: the store
|
||||
/// is opened read-only and every value is dropped unread by the caller, so
|
||||
/// diagnostics can answer "is a key saved?" without probing or showing it.
|
||||
#[must_use]
|
||||
pub(crate) fn active_provider_credential_present(config: &Config) -> bool {
|
||||
let provider = config.api_provider();
|
||||
active_provider_has_env_api_key(config)
|
||||
|| provider_secret_store_api_key_with_mode(config, provider, true).is_some()
|
||||
|| user_global_config_api_key(provider).is_some()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn active_provider_uses_env_only_api_key(config: &Config) -> bool {
|
||||
active_provider_has_env_api_key(config) && !active_provider_has_config_api_key(config)
|
||||
|
||||
@@ -15521,3 +15521,51 @@ fn no_parse_leaves_a_top_level_key_behind() -> Result<()> {
|
||||
assert!(!codewhale_config::legacy_root::has_legacy_root_keys(&table));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_set_provider_typo_reuses_the_invalid_provider_wording() {
|
||||
let config = Config::default();
|
||||
let error = config
|
||||
.resolve_provider_selection_identity("deepsek")
|
||||
.expect_err("a typo is not a provider");
|
||||
assert!(
|
||||
error.starts_with("Invalid provider 'deepsek': expected deepseek"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(!error.contains("saved session"), "{error}");
|
||||
// The resume path keeps its own saved-session wording.
|
||||
let resume = config
|
||||
.resolve_provider_pin_identity("deepsek")
|
||||
.expect_err("missing custom route");
|
||||
assert!(resume.starts_with("saved session requires"), "{resume}");
|
||||
assert_eq!(
|
||||
config
|
||||
.resolve_provider_selection_identity("deepseek")
|
||||
.expect("built-in provider")
|
||||
.provider,
|
||||
ApiProvider::Deepseek
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deepseek_missing_key_message_keeps_indentation_and_gates_the_harness_bullet() {
|
||||
let plain = deepseek_missing_key_message(false);
|
||||
assert!(
|
||||
plain.contains("\n codewhale auth set --provider deepseek\n"),
|
||||
"{plain}"
|
||||
);
|
||||
assert!(
|
||||
plain.contains(
|
||||
"\n zsh: exports in ~/.zshrc reach only interactive shells; use ~/.zshenv.\n"
|
||||
),
|
||||
"{plain}"
|
||||
);
|
||||
assert!(!plain.contains("DeepSeek Harness"), "{plain}");
|
||||
let with_dsh = deepseek_missing_key_message(true);
|
||||
assert!(
|
||||
with_dsh.ends_with(
|
||||
"\n codewhale auth external-consent --provider deepseek --mode read-only"
|
||||
),
|
||||
"{with_dsh}"
|
||||
);
|
||||
}
|
||||
@@ -388,7 +388,7 @@ pub(crate) async fn run_exec_agent(
|
||||
Some(&effective_model),
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.validate()
|
||||
.validate_for(crate::route_runtime::RouteErrorSurface::Headless)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let effective_provider_name = validated_route.identity.key.clone();
|
||||
let effective_provider_id = validated_route.identity.exact_id.clone();
|
||||
@@ -796,6 +796,9 @@ pub(crate) async fn run_exec_agent(
|
||||
let mut approval_required = false;
|
||||
let mut tool_error_seen = false;
|
||||
let mut last_error_category = None;
|
||||
// The error text already printed to stderr in text mode, so the final
|
||||
// failure line does not print the same message twice.
|
||||
let mut printed_error: Option<String> = None;
|
||||
let mut reported_sandbox_contract = false;
|
||||
|
||||
let mut should_persist_session =
|
||||
@@ -1107,6 +1110,7 @@ pub(crate) async fn run_exec_agent(
|
||||
})?;
|
||||
} else if !json_output {
|
||||
eprintln!("error: {}", envelope.message);
|
||||
printed_error = Some(envelope.message);
|
||||
}
|
||||
}
|
||||
Event::TurnUsage {
|
||||
@@ -1487,12 +1491,13 @@ pub(crate) async fn run_exec_agent(
|
||||
// the process level without parsing the stream. Genuine failures
|
||||
// keep the historical `bail!` → exit 1 path.
|
||||
let exit_code = exec_failure_exit_code(summary.error_category.as_deref());
|
||||
let failure = exec_failure_line(error, printed_error.as_deref());
|
||||
if exit_code != 1 {
|
||||
eprintln!("Error: exec turn failed: {error}");
|
||||
eprintln!("Error: {failure}");
|
||||
let _ = io::stdout().flush();
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
bail!("exec turn failed: {error}");
|
||||
bail!("{failure}");
|
||||
}
|
||||
|
||||
if matches!(
|
||||
@@ -1506,9 +1511,36 @@ pub(crate) async fn run_exec_agent(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The final exec failure line. When the stream already printed this exact
|
||||
/// error, repeat only the verdict, not the message.
|
||||
fn exec_failure_line(error: &str, already_printed: Option<&str>) -> String {
|
||||
if already_printed.is_some_and(|printed| printed.trim() == error.trim()) {
|
||||
"exec turn failed (error above)".to_string()
|
||||
} else {
|
||||
format!("exec turn failed: {error}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ExecAgentEvents, exec_automation_services, exec_disallowed_tools};
|
||||
|
||||
#[test]
|
||||
fn exec_failure_line_does_not_repeat_a_streamed_error() {
|
||||
let error = "Provider returned 401: invalid api key";
|
||||
assert_eq!(
|
||||
super::exec_failure_line(error, Some(error)),
|
||||
"exec turn failed (error above)"
|
||||
);
|
||||
assert_eq!(
|
||||
super::exec_failure_line(error, None),
|
||||
format!("exec turn failed: {error}")
|
||||
);
|
||||
assert_eq!(
|
||||
super::exec_failure_line(error, Some("a different warning")),
|
||||
format!("exec turn failed: {error}")
|
||||
);
|
||||
}
|
||||
use crate::core::engine::mock_engine_handle;
|
||||
use crate::core::engine::tool_catalog::REQUEST_USER_INPUT_NAME;
|
||||
use crate::core::events::{Event, TurnOutcomeStatus};
|
||||
|
||||
@@ -229,6 +229,12 @@ pub(crate) fn resolve_dsh_home(env: &DetectEnv) -> (PathBuf, bool) {
|
||||
(home.join(".dsh"), false)
|
||||
}
|
||||
|
||||
/// Whether a DeepSeek Harness launcher is on this process's `PATH`. A cheap
|
||||
/// `PATH` scan only: nothing is run and `$DSH_HOME` is not touched.
|
||||
pub(crate) fn dsh_on_path() -> bool {
|
||||
find_on_path(std::env::var_os("PATH").as_ref()).is_some()
|
||||
}
|
||||
|
||||
fn find_on_path(path: Option<&OsString>) -> Option<PathBuf> {
|
||||
let path = path?;
|
||||
for dir in std::env::split_paths(path) {
|
||||
|
||||
+177
-34
@@ -1099,13 +1099,26 @@ fn resolve_exec_resume_session_id(args: &ExecArgs, workspace: &Path) -> Result<O
|
||||
)
|
||||
}
|
||||
|
||||
fn load_exec_resume_session(session_id: &str) -> Result<session_manager::SavedSession> {
|
||||
let session_ref = exec_stream_session_ref(session_id);
|
||||
fn load_exec_resume_session(
|
||||
session_id: &str,
|
||||
output_format: ExecOutputFormat,
|
||||
) -> Result<session_manager::SavedSession> {
|
||||
SessionManager::default_location()
|
||||
.context("could not open session manager for resume")?
|
||||
.resume_session_by_prefix(session_id)
|
||||
.map(|recovery| recovery.session)
|
||||
.with_context(|| format!("could not load session {session_ref}"))
|
||||
.with_context(|| exec_resume_load_error(session_id, output_format))
|
||||
}
|
||||
|
||||
/// The person who typed `--resume <id>` sees that id; only the
|
||||
/// machine-readable stream-json run keeps the redacted breadcrumb.
|
||||
fn exec_resume_load_error(session_id: &str, output_format: ExecOutputFormat) -> String {
|
||||
let session_ref = if output_format == ExecOutputFormat::StreamJson {
|
||||
exec_stream_session_ref(session_id)
|
||||
} else {
|
||||
session_id.to_string()
|
||||
};
|
||||
format!("could not load session {session_ref}. Run `codewhale sessions` to list ids.")
|
||||
}
|
||||
|
||||
/// Select the route for `exec --resume` before any engine/client is built.
|
||||
@@ -2381,11 +2394,7 @@ async fn run_async_main_dispatch(
|
||||
let config = match load_doctor_config_from_cli(&cli, &args) {
|
||||
Ok(config) => config,
|
||||
Err(error) if args.json => return run_doctor_json_config_error(&error),
|
||||
Err(_) => {
|
||||
bail!(
|
||||
"doctor configuration validation failed; details omitted because configuration errors may contain credential material"
|
||||
)
|
||||
}
|
||||
Err(error) => bail!(doctor_config_error_text(&error)),
|
||||
};
|
||||
let workspace = resolve_workspace(&cli);
|
||||
if args.repair_sessions {
|
||||
@@ -2559,7 +2568,7 @@ async fn run_async_main_dispatch(
|
||||
)?;
|
||||
let resume_session = resume_session_id
|
||||
.as_deref()
|
||||
.map(load_exec_resume_session)
|
||||
.map(|id| load_exec_resume_session(id, args.output_format))
|
||||
.transpose()?;
|
||||
let explicit_model = args
|
||||
.model
|
||||
@@ -4679,7 +4688,11 @@ async fn run_doctor(
|
||||
println!("{}", "==================".truecolor(sky_r, sky_g, sky_b));
|
||||
// Verdict first (U7): the answer and the next step, before the detail.
|
||||
let (verdict_state, _) = doctor_setup_state(config, workspace);
|
||||
let verdict = doctor_verdict(&verdict_state);
|
||||
let verdict = doctor_verdict(
|
||||
&verdict_state,
|
||||
doctor_has_credentials_or_local_runtime(config),
|
||||
config.api_provider().as_str(),
|
||||
);
|
||||
println!("{}", verdict.truecolor(aqua_r, aqua_g, aqua_b).bold());
|
||||
println!();
|
||||
|
||||
@@ -5668,25 +5681,33 @@ async fn run_doctor(
|
||||
println!("{}", verdict.truecolor(aqua_r, aqua_g, aqua_b).bold());
|
||||
}
|
||||
|
||||
/// Doctor's one-line answer: ready, or the single next step (U7). Readiness
|
||||
/// is the setup lane's own verdict; doctor never probes credential values to
|
||||
/// decide it.
|
||||
fn doctor_verdict(state: &codewhale_config::SetupState) -> &'static str {
|
||||
/// Doctor's one-line answer: ready, or the single next step (U7).
|
||||
/// `credential_present` is the resolved credential's presence (secret store,
|
||||
/// config, or env); doctor never displays or probes the value to decide it.
|
||||
fn doctor_verdict(
|
||||
state: &codewhale_config::SetupState,
|
||||
credential_present: bool,
|
||||
provider: &str,
|
||||
) -> String {
|
||||
// NeedsAction means a named route needs repair (missing credentials or a
|
||||
// failed check). Configured routes can be used without a prior probe.
|
||||
// `first_run_ready` accepts NeedsAction (a failed key still reaches the
|
||||
// wizard's ready screen), so check the provider first: finished setup
|
||||
// with a route needing repair is not "Ready".
|
||||
let provider_configured = matches!(
|
||||
state.status(codewhale_config::SetupStep::ProviderModel),
|
||||
codewhale_config::StepStatus::Configured | codewhale_config::StepStatus::Verified
|
||||
);
|
||||
// failed check). Configured routes can be used without a prior probe, and
|
||||
// a credential saved since setup ran (`codewhale auth set`) configures
|
||||
// the route even though the persisted step predates it. `first_run_ready`
|
||||
// accepts NeedsAction (a failed key still reaches the wizard's ready
|
||||
// screen), so check the provider first.
|
||||
let provider_configured = credential_present
|
||||
|| matches!(
|
||||
state.status(codewhale_config::SetupStep::ProviderModel),
|
||||
codewhale_config::StepStatus::Configured | codewhale_config::StepStatus::Verified
|
||||
);
|
||||
if !provider_configured {
|
||||
"Not ready: no model provider set up → run /provider in Codewhale, or `codewhale setup`."
|
||||
format!(
|
||||
"Not ready: no model provider set up → run /provider in Codewhale, or `codewhale auth set --provider {provider}` when headless."
|
||||
)
|
||||
} else if state.first_run_ready() {
|
||||
"Ready: setup is complete."
|
||||
"Ready: setup is complete.".to_string()
|
||||
} else {
|
||||
"Not ready: first-run setup is unfinished → run `codewhale setup`."
|
||||
"Not ready: first-run setup is unfinished → run `codewhale setup`.".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5694,9 +5715,24 @@ fn doctor_verdict(state: &codewhale_config::SetupState) -> &'static str {
|
||||
mod doctor_verdict_tests {
|
||||
#[test]
|
||||
fn a_fresh_home_is_not_ready_and_names_the_provider_step() {
|
||||
let verdict = super::doctor_verdict(&codewhale_config::SetupState::default());
|
||||
let verdict =
|
||||
super::doctor_verdict(&codewhale_config::SetupState::default(), false, "deepseek");
|
||||
assert!(verdict.starts_with("Not ready"), "{verdict}");
|
||||
assert!(verdict.contains("/provider"), "{verdict}");
|
||||
assert!(
|
||||
verdict.contains("`codewhale auth set --provider deepseek` when headless"),
|
||||
"{verdict}"
|
||||
);
|
||||
assert!(!verdict.contains("codewhale setup"), "{verdict}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_credential_configures_the_provider_step() {
|
||||
// `codewhale auth set` on a fresh home writes no setup state; the
|
||||
// resolved credential alone must clear the "no model provider" step.
|
||||
let verdict =
|
||||
super::doctor_verdict(&codewhale_config::SetupState::default(), true, "deepseek");
|
||||
assert!(!verdict.contains("no model provider"), "{verdict}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5716,7 +5752,7 @@ mod doctor_verdict_tests {
|
||||
state.runtime_posture_source = RuntimePostureSource::Confirmed;
|
||||
state.constitution_choice = ConstitutionChoice::Bundled;
|
||||
assert!(state.first_run_ready(), "fixture must be wizard-ready");
|
||||
let verdict = super::doctor_verdict(&state);
|
||||
let verdict = super::doctor_verdict(&state, false, "deepseek");
|
||||
assert!(verdict.starts_with("Not ready"), "{verdict}");
|
||||
assert!(verdict.contains("/provider"), "{verdict}");
|
||||
|
||||
@@ -5724,7 +5760,10 @@ mod doctor_verdict_tests {
|
||||
SetupStep::ProviderModel,
|
||||
StepEntry::new(StepStatus::Verified, true, "0.10.1"),
|
||||
);
|
||||
assert_eq!(super::doctor_verdict(&state), "Ready: setup is complete.");
|
||||
assert_eq!(
|
||||
super::doctor_verdict(&state, false, "deepseek"),
|
||||
"Ready: setup is complete."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6384,10 +6423,13 @@ fn doctor_inherited_setup_facts(
|
||||
}
|
||||
}
|
||||
|
||||
/// Presence only: a key in the secret store, config, or env configures the
|
||||
/// route; doctor keeps the yes/no and never reads the value into output.
|
||||
fn doctor_has_credentials_or_local_runtime(config: &Config) -> bool {
|
||||
resolve_credential_diagnostic(config)
|
||||
.availability
|
||||
.certifies_ready()
|
||||
|| crate::config::active_provider_credential_present(config)
|
||||
}
|
||||
|
||||
fn print_doctor_setup_report(
|
||||
@@ -7294,15 +7336,35 @@ fn run_doctor_repair_sessions(dry_run: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const DOCTOR_CONFIG_ERROR_OMITTED: &str = "configuration validation failed; details omitted because configuration errors may contain credential material";
|
||||
|
||||
/// Human doctor text for a config load failure. Plain value/profile
|
||||
/// validation errors are shown with their fix; anything else (parse errors,
|
||||
/// credential fields) stays suppressed because it may echo secret material.
|
||||
fn doctor_config_error_text(error: &anyhow::Error) -> String {
|
||||
let Some(diagnostic) = crate::config::SafeConfigDiagnostic::find_in(error) else {
|
||||
return format!("doctor {DOCTOR_CONFIG_ERROR_OMITTED}");
|
||||
};
|
||||
let mut text = format!(
|
||||
"doctor configuration validation failed: {}",
|
||||
diagnostic.display_message()
|
||||
);
|
||||
if let Some(fix) = diagnostic.fix() {
|
||||
text.push_str("\nfix: ");
|
||||
text.push_str(fix);
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn run_doctor_json_config_error(error: &anyhow::Error) -> Result<()> {
|
||||
let safe_message = error
|
||||
.downcast_ref::<crate::config::SafeConfigDiagnostic>()
|
||||
.map(ToString::to_string);
|
||||
let diagnostic = crate::config::SafeConfigDiagnostic::find_in(error);
|
||||
let safe_message = diagnostic.map(crate::config::SafeConfigDiagnostic::display_message);
|
||||
let report = serde_json::json!({
|
||||
"status": "error",
|
||||
"error": {
|
||||
"kind": "config_validation",
|
||||
"message": safe_message.as_deref().unwrap_or("configuration validation failed; details omitted because configuration errors may contain credential material"),
|
||||
"message": safe_message.as_deref().unwrap_or(DOCTOR_CONFIG_ERROR_OMITTED),
|
||||
"fix": diagnostic.and_then(crate::config::SafeConfigDiagnostic::fix),
|
||||
},
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
@@ -10361,12 +10423,15 @@ fn collect_diff(
|
||||
cmd.arg("--").arg(path);
|
||||
}
|
||||
|
||||
ensure_review_workspace_is_git_repo(workspace)?;
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to run git diff. Is git installed? ({e})"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("git diff failed: {}", stderr.trim());
|
||||
bail!(
|
||||
"git diff failed: {}",
|
||||
first_stderr_line(&String::from_utf8_lossy(&output.stderr))
|
||||
);
|
||||
}
|
||||
String::from_utf8_lossy(&output.stdout).to_string()
|
||||
};
|
||||
@@ -10376,6 +10441,28 @@ fn collect_diff(
|
||||
Ok(diff)
|
||||
}
|
||||
|
||||
/// Outside a work tree `git diff` prints its whole `--no-index` usage; say
|
||||
/// what is actually wrong instead.
|
||||
fn ensure_review_workspace_is_git_repo(workspace: &std::path::Path) -> Result<()> {
|
||||
let output = crate::dependencies::Git::review_command(workspace)?
|
||||
.current_dir(workspace)
|
||||
.args(["rev-parse", "--is-inside-work-tree"])
|
||||
.output()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to run git. Is git installed? ({e})"))?;
|
||||
if !output.status.success() || String::from_utf8_lossy(&output.stdout).trim() != "true" {
|
||||
bail!("Not inside a git repository (cwd: {})", workspace.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn first_stderr_line(stderr: &str) -> &str {
|
||||
stderr
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.unwrap_or("(no error output)")
|
||||
}
|
||||
|
||||
fn ensure_local_review_diff_fits(diff: &str, max_chars: usize) -> Result<()> {
|
||||
let chars = diff.chars().count();
|
||||
if chars > max_chars {
|
||||
@@ -17408,6 +17495,62 @@ api_key = "test-only-key"
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_outside_a_git_repository_says_so_in_one_line() {
|
||||
let outside = tempfile::tempdir().expect("tempdir");
|
||||
let error = ensure_review_workspace_is_git_repo(outside.path())
|
||||
.expect_err("a plain directory is not a work tree");
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"Not inside a git repository (cwd: {})",
|
||||
outside.path().display()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
first_stderr_line("\nfatal: bad revision 'nope...HEAD'\nusage: git diff\n --stat\n"),
|
||||
"fatal: bad revision 'nope...HEAD'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_resume_error_shows_the_typed_id_except_in_stream_json() {
|
||||
let id = "0123456789abcdef-session";
|
||||
let human = exec_resume_load_error(id, ExecOutputFormat::Text);
|
||||
assert_eq!(
|
||||
human,
|
||||
format!("could not load session {id}. Run `codewhale sessions` to list ids.")
|
||||
);
|
||||
let stream = exec_resume_load_error(id, ExecOutputFormat::StreamJson);
|
||||
assert!(!stream.contains(id), "{stream}");
|
||||
assert!(
|
||||
stream.ends_with("Run `codewhale sessions` to list ids."),
|
||||
"{stream}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_shows_plain_value_errors_with_a_fix_and_hides_the_rest() {
|
||||
let invalid = crate::config::Config {
|
||||
verbosity: Some("chatty".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
.validate()
|
||||
.expect_err("unknown verbosity");
|
||||
let wrapped = invalid.context("Failed to load config file /tmp/config.toml");
|
||||
let text = doctor_config_error_text(&wrapped);
|
||||
assert_eq!(
|
||||
text,
|
||||
"doctor configuration validation failed: Invalid verbosity (value not shown): expected normal or concise.\nfix: codewhale config set verbosity normal"
|
||||
);
|
||||
assert!(!text.contains("chatty"), "{text}");
|
||||
|
||||
let opaque = anyhow::anyhow!("TOML parse error near api_key = \"sk-live-secret\"");
|
||||
let text = doctor_config_error_text(&opaque);
|
||||
assert!(text.contains("details omitted"), "{text}");
|
||||
assert!(!text.contains("sk-live-secret"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_review_budget_counts_unicode_characters_without_cutting_input() {
|
||||
let diff = format!("{}+鲸鱼\n", review_test_diff());
|
||||
|
||||
@@ -497,11 +497,7 @@ impl LlmError {
|
||||
status,
|
||||
message: body.to_string(),
|
||||
}
|
||||
} else if body_lower.contains("context_length")
|
||||
|| body_lower.contains("token")
|
||||
|| body_lower.contains("too long")
|
||||
|| body_lower.contains("maximum")
|
||||
{
|
||||
} else if is_context_length_message(&body_lower) {
|
||||
LlmError::ContextLengthError(body.to_string())
|
||||
} else if body_lower.contains("content_policy")
|
||||
|| body_lower.contains("safety")
|
||||
@@ -730,6 +726,24 @@ fn looks_like_authentication_failure(body: &str) -> bool {
|
||||
/// Quota exhaustion is a durable account state, not a generic rate-limit
|
||||
/// synonym. Accept only explicit provider evidence at the HTTP/parser boundary;
|
||||
/// callers holding a stringified error must never promote it to this type.
|
||||
/// A 400 is a context overflow only when it says so. Bare "token",
|
||||
/// "too long" or "maximum" also appear in ordinary invalid-request errors
|
||||
/// (`max_tokens must be ...`, a field value too long), which compaction or a
|
||||
/// bigger window cannot fix. `lower` must already be lowercase.
|
||||
fn is_context_length_message(lower: &str) -> bool {
|
||||
[
|
||||
"context_length",
|
||||
"context length",
|
||||
"context window",
|
||||
"prompt is too long",
|
||||
"maximum context",
|
||||
"tokens exceed",
|
||||
"exceeds the maximum number of tokens",
|
||||
]
|
||||
.iter()
|
||||
.any(|phrase| lower.contains(phrase))
|
||||
}
|
||||
|
||||
fn has_explicit_quota_evidence(body: &str) -> bool {
|
||||
explicit_quota_code(body).is_some()
|
||||
|| has_explicit_quota_code_marker(body)
|
||||
|
||||
@@ -158,6 +158,33 @@ fn http_response_boundary_classifies_status_contract() {
|
||||
LlmError::from_http_response(400, "invalid json"),
|
||||
LlmError::InvalidRequest { status: 400, .. }
|
||||
));
|
||||
for context in [
|
||||
"This model's maximum context length is 131072 tokens.",
|
||||
"prompt is too long: 250000 tokens > 200000 maximum",
|
||||
"input tokens exceed the configured limit",
|
||||
"The input token count (2000000) exceeds the maximum number of tokens allowed (1048576).",
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
LlmError::from_http_response(400, context),
|
||||
LlmError::ContextLengthError(_)
|
||||
),
|
||||
"{context}"
|
||||
);
|
||||
}
|
||||
for invalid in [
|
||||
"max_tokens must be less than or equal to 8192",
|
||||
"Invalid 'messages[1].name': string too long. Expected a maximum length of 64.",
|
||||
"invalid token in JSON body",
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
LlmError::from_http_response(400, invalid),
|
||||
LlmError::InvalidRequest { status: 400, .. }
|
||||
),
|
||||
"{invalid}"
|
||||
);
|
||||
}
|
||||
// "Unsupported parameter: max_output_tokens" names a *token* field, which
|
||||
// the generic keyword rules misread as a context-window overflow. It is a
|
||||
// request-shape error, and retrying or compacting cannot fix it.
|
||||
|
||||
@@ -58,7 +58,7 @@ fn model_identity(config: &Config, key: &str) -> Result<ProviderIdentity> {
|
||||
})
|
||||
.map_or(id, |provider| provider.as_str())
|
||||
};
|
||||
config.resolve_provider_pin_identity(selector)
|
||||
config.resolve_provider_selection_identity(selector)
|
||||
} else {
|
||||
config.active_provider_identity(config.api_provider())
|
||||
}
|
||||
@@ -369,7 +369,7 @@ pub fn set_document(
|
||||
let config = parse_config(&doc.to_string())?;
|
||||
if key == "provider" {
|
||||
let identity = config
|
||||
.resolve_provider_pin_identity(value)
|
||||
.resolve_provider_selection_identity(value)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
persistence::set_document_value(
|
||||
doc,
|
||||
|
||||
+175
-35
@@ -249,24 +249,50 @@ impl std::fmt::Debug for ValidatedRuntimeRoute {
|
||||
}
|
||||
}
|
||||
|
||||
/// Who reads a route preflight failure. The interactive app can run slash
|
||||
/// commands; `codewhale exec` and other headless callers only have the CLI.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RouteErrorSurface {
|
||||
Interactive,
|
||||
Headless,
|
||||
}
|
||||
|
||||
impl ResolvedRuntimeRoute {
|
||||
pub(crate) fn preflight(mut self) -> Result<Self, String> {
|
||||
if self.preflighted_client.is_none() {
|
||||
self.preflighted_client = Some(
|
||||
CodewhaleClient::from_candidate(&self.config, &self.candidate).map_err(|err| {
|
||||
format_provider_route_preflight_error(&self.identity.key, &self.model, &err)
|
||||
format_provider_route_preflight_error(
|
||||
&self.identity.key,
|
||||
&self.model,
|
||||
&err,
|
||||
RouteErrorSurface::Interactive,
|
||||
)
|
||||
})?,
|
||||
);
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub(crate) fn validate(mut self) -> Result<ValidatedRuntimeRoute, String> {
|
||||
pub(crate) fn validate(self) -> Result<ValidatedRuntimeRoute, String> {
|
||||
self.validate_for(RouteErrorSurface::Interactive)
|
||||
}
|
||||
|
||||
/// [`Self::validate`] with next steps worded for `surface`.
|
||||
pub(crate) fn validate_for(
|
||||
mut self,
|
||||
surface: RouteErrorSurface,
|
||||
) -> Result<ValidatedRuntimeRoute, String> {
|
||||
let client = match self.preflighted_client.take() {
|
||||
Some(client) => client,
|
||||
None => {
|
||||
CodewhaleClient::from_candidate(&self.config, &self.candidate).map_err(|err| {
|
||||
format_provider_route_preflight_error(&self.identity.key, &self.model, &err)
|
||||
format_provider_route_preflight_error(
|
||||
&self.identity.key,
|
||||
&self.model,
|
||||
&err,
|
||||
surface,
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
@@ -289,34 +315,61 @@ fn format_provider_route_preflight_error(
|
||||
identity_key: &str,
|
||||
model: &str,
|
||||
err: &anyhow::Error,
|
||||
surface: RouteErrorSurface,
|
||||
) -> String {
|
||||
let reason = err.to_string().trim().to_string();
|
||||
let mut message = format!(
|
||||
"{}. Failed to configure provider route {} / {}.",
|
||||
reason, identity_key, model
|
||||
);
|
||||
if let Some(next_step) = classify_provider_route_preflight_next_step(identity_key, &reason) {
|
||||
message.push_str(" Next step: ");
|
||||
let reason = err.to_string();
|
||||
let reason = reason.trim();
|
||||
// Multi-line guidance ends in a command to copy; a period appended to it
|
||||
// would break the paste, so it gets a line break instead.
|
||||
let multi_line = reason.contains('\n');
|
||||
let mut message = if multi_line {
|
||||
format!("{reason}\nFailed to configure provider route {identity_key} / {model}.")
|
||||
} else {
|
||||
format!(
|
||||
"{}. Failed to configure provider route {identity_key} / {model}.",
|
||||
reason.trim_end_matches('.')
|
||||
)
|
||||
};
|
||||
if let Some(next_step) =
|
||||
classify_provider_route_preflight_next_step(identity_key, reason, surface)
|
||||
{
|
||||
message.push_str(if multi_line { "\n" } else { " " });
|
||||
message.push_str("Next step: ");
|
||||
message.push_str(&next_step);
|
||||
}
|
||||
message
|
||||
}
|
||||
|
||||
fn classify_provider_route_preflight_next_step(identity_key: &str, reason: &str) -> Option<String> {
|
||||
fn classify_provider_route_preflight_next_step(
|
||||
identity_key: &str,
|
||||
reason: &str,
|
||||
surface: RouteErrorSurface,
|
||||
) -> Option<String> {
|
||||
let headless = surface == RouteErrorSurface::Headless;
|
||||
let lower = reason.to_ascii_lowercase();
|
||||
if lower
|
||||
.contains("codex oauth credentials are only available on the official openai codex route")
|
||||
{
|
||||
return Some(format!(
|
||||
"Run /provider setup {identity_key} and remove its custom base URL; Codex OAuth only works on the official route."
|
||||
));
|
||||
return Some(if headless {
|
||||
format!(
|
||||
"Remove the custom base_url from [providers.{identity_key}]; Codex OAuth only works on the official route."
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Run /provider setup {identity_key} and remove its custom base URL; Codex OAuth only works on the official route."
|
||||
)
|
||||
});
|
||||
}
|
||||
if lower.contains("openai codex oauth credentials are unavailable")
|
||||
|| lower.contains("codex access token")
|
||||
{
|
||||
return Some(format!(
|
||||
"Run `codewhale auth chatgpt` or /provider setup {identity_key} to Sign in with ChatGPT; Codex CLI import remains an explicit alternative."
|
||||
));
|
||||
return Some(if headless {
|
||||
"Run `codewhale auth chatgpt` to Sign in with ChatGPT; Codex CLI import remains an explicit alternative.".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"Run `codewhale auth chatgpt` or /provider setup {identity_key} to Sign in with ChatGPT; Codex CLI import remains an explicit alternative."
|
||||
)
|
||||
});
|
||||
}
|
||||
if lower.contains("api key not found")
|
||||
|| lower.contains("access token")
|
||||
@@ -325,9 +378,26 @@ fn classify_provider_route_preflight_next_step(identity_key: &str, reason: &str)
|
||||
|| lower.contains("missing")
|
||||
|| lower.contains("unsupported")))
|
||||
{
|
||||
return Some(format!(
|
||||
"Run /auth or /provider setup {identity_key} to configure credentials."
|
||||
));
|
||||
if !headless {
|
||||
return Some(format!(
|
||||
"Run /auth or /provider setup {identity_key} to configure credentials."
|
||||
));
|
||||
}
|
||||
// The reason usually names its own `codewhale auth set` command
|
||||
// already; repeating it would make two instructions out of one.
|
||||
if lower.contains("codewhale auth set") {
|
||||
return None;
|
||||
}
|
||||
return Some(
|
||||
match ApiProvider::parse(identity_key).filter(|p| *p != ApiProvider::Custom) {
|
||||
Some(provider) => {
|
||||
format!("Run `codewhale auth set --provider {}`.", provider.as_str())
|
||||
}
|
||||
None => format!(
|
||||
"Add api_key or api_key_env to [providers.{identity_key}] in config.toml."
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
if lower.contains("tls certificate")
|
||||
|| lower.contains("ssl_cert_file")
|
||||
@@ -336,9 +406,11 @@ fn classify_provider_route_preflight_next_step(identity_key: &str, reason: &str)
|
||||
|| lower.contains("base url")
|
||||
|| lower.contains("invalid url")
|
||||
{
|
||||
return Some(format!(
|
||||
"Run /provider setup {identity_key} to fix base URL/TLS settings."
|
||||
));
|
||||
return Some(if headless {
|
||||
format!("Fix base_url/TLS settings in [providers.{identity_key}] of config.toml.")
|
||||
} else {
|
||||
format!("Run /provider setup {identity_key} to fix base URL/TLS settings.")
|
||||
});
|
||||
}
|
||||
if lower.contains("provider")
|
||||
&& lower.contains("model")
|
||||
@@ -347,10 +419,13 @@ fn classify_provider_route_preflight_next_step(identity_key: &str, reason: &str)
|
||||
|| lower.contains("unknown")
|
||||
|| lower.contains("not found"))
|
||||
{
|
||||
return Some(
|
||||
return Some(if headless {
|
||||
"Pass a model this provider serves with --model (`codewhale models` lists them)."
|
||||
.to_string()
|
||||
} else {
|
||||
"Run /models (or open the model picker) and choose a model valid for this provider."
|
||||
.to_string(),
|
||||
);
|
||||
.to_string()
|
||||
});
|
||||
}
|
||||
if lower.contains("fleet") || lower.contains("profile") || lower.contains("partial route") {
|
||||
return Some(
|
||||
@@ -358,9 +433,11 @@ fn classify_provider_route_preflight_next_step(identity_key: &str, reason: &str)
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Some(format!(
|
||||
"Run /provider setup {identity_key} to review this route configuration."
|
||||
))
|
||||
Some(if headless {
|
||||
"Run `codewhale doctor` to review this route configuration.".to_string()
|
||||
} else {
|
||||
format!("Run /provider setup {identity_key} to review this route configuration.")
|
||||
})
|
||||
}
|
||||
|
||||
impl ValidatedRuntimeRoute {
|
||||
@@ -1158,7 +1235,12 @@ mod tests {
|
||||
let err = anyhow::anyhow!(
|
||||
"Custom provider 'lm-studio' API key not found. Run 'codewhale auth set --provider custom'."
|
||||
);
|
||||
let formatted = format_provider_route_preflight_error("lm-studio", "local-model", &err);
|
||||
let formatted = format_provider_route_preflight_error(
|
||||
"lm-studio",
|
||||
"local-model",
|
||||
&err,
|
||||
RouteErrorSurface::Interactive,
|
||||
);
|
||||
|
||||
assert!(formatted.starts_with("Custom provider 'lm-studio' API key not found."));
|
||||
assert!(formatted.contains("Failed to configure provider route lm-studio / local-model."));
|
||||
@@ -1167,11 +1249,60 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_route_preflight_headless_missing_key_is_one_cli_message() {
|
||||
let single = anyhow::anyhow!(
|
||||
"Custom provider 'lm-studio' API key not found. Run 'codewhale auth set --provider custom'."
|
||||
);
|
||||
let formatted = format_provider_route_preflight_error(
|
||||
"lm-studio",
|
||||
"local-model",
|
||||
&single,
|
||||
RouteErrorSurface::Headless,
|
||||
);
|
||||
assert!(!formatted.contains(".."), "{formatted}");
|
||||
assert!(!formatted.contains("/auth"), "{formatted}");
|
||||
assert!(!formatted.contains("/provider"), "{formatted}");
|
||||
assert!(!formatted.contains("Next step"), "{formatted}");
|
||||
|
||||
let bare = anyhow::anyhow!("DeepSeek API key not found.");
|
||||
let formatted = format_provider_route_preflight_error(
|
||||
"deepseek",
|
||||
"deepseek-v4-flash",
|
||||
&bare,
|
||||
RouteErrorSurface::Headless,
|
||||
);
|
||||
assert_eq!(
|
||||
formatted,
|
||||
"DeepSeek API key not found. Failed to configure provider route deepseek / deepseek-v4-flash. Next step: Run `codewhale auth set --provider deepseek`."
|
||||
);
|
||||
|
||||
let multi = anyhow::anyhow!(
|
||||
"DeepSeek API key not found.\n\n codewhale auth set --provider deepseek"
|
||||
);
|
||||
let formatted = format_provider_route_preflight_error(
|
||||
"deepseek",
|
||||
"deepseek-v4-flash",
|
||||
&multi,
|
||||
RouteErrorSurface::Headless,
|
||||
);
|
||||
assert!(
|
||||
formatted.contains(
|
||||
"\n codewhale auth set --provider deepseek\nFailed to configure provider route"
|
||||
),
|
||||
"{formatted}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_route_preflight_codex_oauth_errors_surface_the_right_next_step() {
|
||||
let missing = anyhow::anyhow!("OpenAI Codex OAuth credentials are unavailable.");
|
||||
let missing_formatted =
|
||||
format_provider_route_preflight_error("openai-codex", "gpt-5.6-sol", &missing);
|
||||
let missing_formatted = format_provider_route_preflight_error(
|
||||
"openai-codex",
|
||||
"gpt-5.6-sol",
|
||||
&missing,
|
||||
RouteErrorSurface::Interactive,
|
||||
);
|
||||
assert!(missing_formatted.contains(
|
||||
"Next step: Run `codewhale auth chatgpt` or /provider setup openai-codex to Sign in with ChatGPT; Codex CLI import remains an explicit alternative."
|
||||
));
|
||||
@@ -1179,8 +1310,12 @@ mod tests {
|
||||
let custom = anyhow::anyhow!(
|
||||
"Codex OAuth credentials are only available on the official OpenAI Codex route"
|
||||
);
|
||||
let custom_formatted =
|
||||
format_provider_route_preflight_error("openai-codex", "gpt-5.6-sol", &custom);
|
||||
let custom_formatted = format_provider_route_preflight_error(
|
||||
"openai-codex",
|
||||
"gpt-5.6-sol",
|
||||
&custom,
|
||||
RouteErrorSurface::Interactive,
|
||||
);
|
||||
assert!(custom_formatted.contains(
|
||||
"Next step: Run /provider setup openai-codex and remove its custom base URL; Codex OAuth only works on the official route."
|
||||
));
|
||||
@@ -1191,7 +1326,12 @@ mod tests {
|
||||
let err = anyhow::anyhow!(
|
||||
"TLS certificate verification cannot be disabled for provider custom; configure SSL_CERT_FILE with a trusted custom CA bundle instead"
|
||||
);
|
||||
let formatted = format_provider_route_preflight_error("lm-studio", "local-model", &err);
|
||||
let formatted = format_provider_route_preflight_error(
|
||||
"lm-studio",
|
||||
"local-model",
|
||||
&err,
|
||||
RouteErrorSurface::Interactive,
|
||||
);
|
||||
|
||||
assert!(
|
||||
formatted
|
||||
|
||||
@@ -138,9 +138,16 @@ api_key = "doctor-json-arbitrary-secret"
|
||||
let report: serde_json::Value =
|
||||
serde_json::from_slice(&output.stdout).expect("machine-readable doctor error");
|
||||
assert_eq!(report["error"]["kind"], "config_validation");
|
||||
// A plain value error names the key and the valid values, never the
|
||||
// rejected value itself (it may be a pasted secret).
|
||||
let message = report["error"]["message"].as_str().expect("message");
|
||||
assert!(
|
||||
message.starts_with("Invalid provider (value not shown): expected deepseek"),
|
||||
"{message}"
|
||||
);
|
||||
assert_eq!(
|
||||
report["error"]["message"],
|
||||
"configuration validation failed; details omitted because configuration errors may contain credential material"
|
||||
report["error"]["fix"],
|
||||
"codewhale config set provider deepseek"
|
||||
);
|
||||
let all_output = format!(
|
||||
"{}\n{}",
|
||||
|
||||
Reference in new issue
Block a user