mirror of
https://github.com/instructkr/claw-code.git
synced 2026-06-17 16:46:14 -04:00
Compare commits
50 Commits
feat/provi
...
54269da157
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54269da157 | ||
|
|
f741a42507 | ||
|
|
6b3e2d8854 | ||
|
|
1a8f73da01 | ||
|
|
7d9f11b91f | ||
|
|
8e1bca6b99 | ||
|
|
8d0308eecb | ||
|
|
4d10caebc6 | ||
|
|
414526c1bd | ||
|
|
2a2e205414 | ||
|
|
c55c510883 | ||
|
|
3fe0caf348 | ||
|
|
47086c1c14 | ||
|
|
e579902782 | ||
|
|
ca8950c26b | ||
|
|
b1d76983d2 | ||
|
|
c1b1ce465e | ||
|
|
8e25611064 | ||
|
|
eb044f0a02 | ||
|
|
75476c9005 | ||
|
|
e4c3871882 | ||
|
|
beb09df4b8 | ||
|
|
811b7b4c24 | ||
|
|
8a9300ea96 | ||
|
|
e7e0fd2dbf | ||
|
|
da451c66db | ||
|
|
ad38032ab8 | ||
|
|
7173f2d6c6 | ||
|
|
a0b4156174 | ||
|
|
3bf45fc44a | ||
|
|
af58b6a7c7 | ||
|
|
514c3da7ad | ||
|
|
5c69713158 | ||
|
|
939d0dbaa3 | ||
|
|
bfd5772716 | ||
|
|
e0c3ff1673 | ||
|
|
252536be74 | ||
|
|
275b58546d | ||
|
|
7f53d82b17 | ||
|
|
adcea6bceb | ||
|
|
b1491791df | ||
|
|
8dc65805c1 | ||
|
|
a9904fe693 | ||
|
|
ff1df4c7ac | ||
|
|
efa24edf21 | ||
|
|
8339391611 | ||
|
|
172a2ad50a | ||
|
|
647ff379a4 | ||
|
|
79da4b8a63 | ||
|
|
7d90283cf9 |
29
README.md
29
README.md
@@ -45,22 +45,31 @@ The canonical implementation lives in [`rust/`](./rust), and the current source
|
||||
|
||||
## Quick start
|
||||
|
||||
> [!NOTE]
|
||||
> **`cargo install clawcode` will not work** — this package is not published on crates.io. Build from source as shown below.
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
# 1. Clone and build
|
||||
git clone https://github.com/ultraworkers/claw-code
|
||||
cd claw-code/rust
|
||||
cargo build --workspace
|
||||
./target/debug/claw --help
|
||||
./target/debug/claw prompt "summarize this repository"
|
||||
```
|
||||
|
||||
Authenticate with either an API key or the built-in OAuth flow:
|
||||
|
||||
```bash
|
||||
# 2. Set your API key (Anthropic API key — not a Claude subscription)
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
# or
|
||||
cd rust
|
||||
./target/debug/claw login
|
||||
|
||||
# 3. Verify everything is wired correctly
|
||||
./target/debug/claw doctor
|
||||
|
||||
# 4. Run a prompt
|
||||
./target/debug/claw prompt "say hello"
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> **Windows (PowerShell):** the binary is `claw.exe`, not `claw`. Use `.\target\debug\claw.exe` or run `cargo run -- prompt "say hello"` to skip the path lookup.
|
||||
|
||||
> [!NOTE]
|
||||
> **Auth:** claw requires an **API key** (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.) — Claude subscription login is not a supported auth path.
|
||||
|
||||
Run the workspace test suite:
|
||||
|
||||
```bash
|
||||
|
||||
55
ROADMAP.md
55
ROADMAP.md
File diff suppressed because one or more lines are too long
14
USAGE.md
14
USAGE.md
@@ -109,6 +109,20 @@ cd rust
|
||||
./target/debug/claw logout
|
||||
```
|
||||
|
||||
### Which env var goes where
|
||||
|
||||
`claw` accepts two Anthropic credential env vars and they are **not interchangeable** — the HTTP header Anthropic expects differs per credential shape. Putting the wrong value in the wrong slot is the most common 401 we see.
|
||||
|
||||
| Credential shape | Env var | HTTP header | Typical source |
|
||||
|---|---|---|---|
|
||||
| `sk-ant-*` API key | `ANTHROPIC_API_KEY` | `x-api-key: sk-ant-...` | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| OAuth access token (opaque) | `ANTHROPIC_AUTH_TOKEN` | `Authorization: Bearer ...` | `claw login` or an Anthropic-compatible proxy that mints Bearer tokens |
|
||||
| OpenRouter key (`sk-or-v1-*`) | `OPENAI_API_KEY` + `OPENAI_BASE_URL=https://openrouter.ai/api/v1` | `Authorization: Bearer ...` | [openrouter.ai/keys](https://openrouter.ai/keys) |
|
||||
|
||||
**Why this matters:** if you paste an `sk-ant-*` key into `ANTHROPIC_AUTH_TOKEN`, Anthropic's API will return `401 Invalid bearer token` because `sk-ant-*` keys are rejected over the Bearer header. The fix is a one-line env var swap — move the key to `ANTHROPIC_API_KEY`. Recent `claw` builds detect this exact shape (401 + `sk-ant-*` in the Bearer slot) and append a hint to the error message pointing at the fix.
|
||||
|
||||
**If you meant a different provider:** if `claw` reports missing Anthropic credentials but you already have `OPENAI_API_KEY`, `XAI_API_KEY`, or `DASHSCOPE_API_KEY` exported, you most likely forgot to prefix the model name with the provider's routing prefix. Use `--model openai/gpt-4.1-mini` (OpenAI-compat / OpenRouter / Ollama), `--model grok` (xAI), or `--model qwen-plus` (DashScope) and the prefix router will select the right backend regardless of the ambient credentials. The error message now includes a hint that names the detected env var.
|
||||
|
||||
## Local Models
|
||||
|
||||
`claw` can talk to local servers and provider gateways through either Anthropic-compatible or OpenAI-compatible endpoints. Use `ANTHROPIC_BASE_URL` with `ANTHROPIC_AUTH_TOKEN` for Anthropic-compatible services, or `OPENAI_BASE_URL` with `OPENAI_API_KEY` for OpenAI-compatible services. OAuth is Anthropic-only, so when `OPENAI_BASE_URL` is set you should use API-key style auth instead of `claw login`.
|
||||
|
||||
@@ -31,9 +31,18 @@ impl ProviderClient {
|
||||
ProviderKind::Xai => Ok(Self::Xai(OpenAiCompatClient::from_env(
|
||||
OpenAiCompatConfig::xai(),
|
||||
)?)),
|
||||
ProviderKind::OpenAi => Ok(Self::OpenAi(OpenAiCompatClient::from_env(
|
||||
OpenAiCompatConfig::openai(),
|
||||
)?)),
|
||||
ProviderKind::OpenAi => {
|
||||
// DashScope models (qwen-*) also return ProviderKind::OpenAi because they
|
||||
// speak the OpenAI wire format, but they need the DashScope config which
|
||||
// reads DASHSCOPE_API_KEY and points at dashscope.aliyuncs.com.
|
||||
let config = match providers::metadata_for_model(&resolved_model) {
|
||||
Some(meta) if meta.auth_env == "DASHSCOPE_API_KEY" => {
|
||||
OpenAiCompatConfig::dashscope()
|
||||
}
|
||||
_ => OpenAiCompatConfig::openai(),
|
||||
};
|
||||
Ok(Self::OpenAi(OpenAiCompatClient::from_env(config)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,8 +144,21 @@ pub fn read_xai_base_url() -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::ProviderClient;
|
||||
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
|
||||
|
||||
/// Serializes every test in this module that mutates process-wide
|
||||
/// environment variables so concurrent test threads cannot observe
|
||||
/// each other's partially-applied state.
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_existing_and_grok_aliases() {
|
||||
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6");
|
||||
@@ -152,4 +174,68 @@ mod tests {
|
||||
ProviderKind::Anthropic
|
||||
);
|
||||
}
|
||||
|
||||
/// Snapshot-restore guard for a single environment variable. Mirrors
|
||||
/// the pattern used in `providers/mod.rs` tests: captures the original
|
||||
/// value on construction, applies the override, and restores on drop so
|
||||
/// tests leave the process env untouched even when they panic.
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
original: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: Option<&str>) -> Self {
|
||||
let original = std::env::var_os(key);
|
||||
match value {
|
||||
Some(value) => std::env::set_var(key, value),
|
||||
None => std::env::remove_var(key),
|
||||
}
|
||||
Self { key, original }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.original.take() {
|
||||
Some(value) => std::env::set_var(self.key, value),
|
||||
None => std::env::remove_var(self.key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashscope_model_uses_dashscope_config_not_openai() {
|
||||
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
|
||||
// which reads OPENAI_API_KEY and points at api.openai.com, when it should
|
||||
// use OpenAiCompatConfig::dashscope() which reads DASHSCOPE_API_KEY and
|
||||
// points at dashscope.aliyuncs.com.
|
||||
let _lock = env_lock();
|
||||
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
|
||||
let _openai = EnvVarGuard::set("OPENAI_API_KEY", None);
|
||||
|
||||
let client = ProviderClient::from_model("qwen-plus");
|
||||
|
||||
// Must succeed (not fail with "missing OPENAI_API_KEY")
|
||||
assert!(
|
||||
client.is_ok(),
|
||||
"qwen-plus with DASHSCOPE_API_KEY set should build successfully, got: {:?}",
|
||||
client.err()
|
||||
);
|
||||
|
||||
// Verify it's the OpenAi variant pointed at the DashScope base URL.
|
||||
match client.unwrap() {
|
||||
ProviderClient::OpenAi(openai_client) => {
|
||||
assert!(
|
||||
openai_client.base_url().contains("dashscope.aliyuncs.com"),
|
||||
"qwen-plus should route to DashScope base URL (contains 'dashscope.aliyuncs.com'), got: {}",
|
||||
openai_client.base_url()
|
||||
);
|
||||
}
|
||||
other => panic!(
|
||||
"Expected ProviderClient::OpenAi for qwen-plus, got: {:?}",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ pub enum ApiError {
|
||||
MissingCredentials {
|
||||
provider: &'static str,
|
||||
env_vars: &'static [&'static str],
|
||||
/// Optional, runtime-computed hint appended to the error Display
|
||||
/// output. Populated when the provider resolver can infer what the
|
||||
/// user probably intended (e.g. an OpenAI key is set but Anthropic
|
||||
/// was selected because no Anthropic credentials exist).
|
||||
hint: Option<String>,
|
||||
},
|
||||
ContextWindowExceeded {
|
||||
model: String,
|
||||
@@ -66,7 +71,29 @@ impl ApiError {
|
||||
provider: &'static str,
|
||||
env_vars: &'static [&'static str],
|
||||
) -> Self {
|
||||
Self::MissingCredentials { provider, env_vars }
|
||||
Self::MissingCredentials {
|
||||
provider,
|
||||
env_vars,
|
||||
hint: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `MissingCredentials` error carrying an extra, runtime-computed
|
||||
/// hint string that the Display impl appends after the canonical "missing
|
||||
/// <provider> credentials" message. Used by the provider resolver to
|
||||
/// suggest the likely fix when the user has credentials for a different
|
||||
/// provider already in the environment.
|
||||
#[must_use]
|
||||
pub fn missing_credentials_with_hint(
|
||||
provider: &'static str,
|
||||
env_vars: &'static [&'static str],
|
||||
hint: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::MissingCredentials {
|
||||
provider,
|
||||
env_vars,
|
||||
hint: Some(hint.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `Self::Json` enriched with the provider name, the model that
|
||||
@@ -204,7 +231,11 @@ impl ApiError {
|
||||
impl Display for ApiError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingCredentials { provider, env_vars } => {
|
||||
Self::MissingCredentials {
|
||||
provider,
|
||||
env_vars,
|
||||
hint,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"missing {provider} credentials; export {} before calling the {provider} API",
|
||||
@@ -223,6 +254,9 @@ impl Display for ApiError {
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if let Some(hint) = hint {
|
||||
write!(f, " — hint: {hint}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Self::ContextWindowExceeded {
|
||||
@@ -483,4 +517,56 @@ mod tests {
|
||||
assert_eq!(error.safe_failure_class(), "context_window");
|
||||
assert_eq!(error.request_id(), Some("req_ctx_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_credentials_without_hint_renders_the_canonical_message() {
|
||||
// given
|
||||
let error = ApiError::missing_credentials(
|
||||
"Anthropic",
|
||||
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
|
||||
);
|
||||
|
||||
// when
|
||||
let rendered = error.to_string();
|
||||
|
||||
// then
|
||||
assert!(
|
||||
rendered.starts_with(
|
||||
"missing Anthropic credentials; export ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY before calling the Anthropic API"
|
||||
),
|
||||
"rendered error should lead with the canonical missing-credential message: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains(" — hint: "),
|
||||
"no hint should be appended when none is supplied: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_credentials_with_hint_appends_the_hint_after_base_message() {
|
||||
// given
|
||||
let error = ApiError::missing_credentials_with_hint(
|
||||
"Anthropic",
|
||||
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
|
||||
"I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it.",
|
||||
);
|
||||
|
||||
// when
|
||||
let rendered = error.to_string();
|
||||
|
||||
// then
|
||||
assert!(
|
||||
rendered.starts_with("missing Anthropic credentials;"),
|
||||
"hint should be appended, not replace the base message: {rendered}"
|
||||
);
|
||||
let hint_marker = " — hint: I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it.";
|
||||
assert!(
|
||||
rendered.ends_with(hint_marker),
|
||||
"rendered error should end with the hint: {rendered}"
|
||||
);
|
||||
// Classification semantics are unaffected by the presence of a hint.
|
||||
assert_eq!(error.safe_failure_class(), "provider_auth");
|
||||
assert!(!error.is_retryable());
|
||||
assert_eq!(error.request_id(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ use crate::error::ApiError;
|
||||
use crate::http_client::build_http_client_or_default;
|
||||
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
|
||||
|
||||
use super::{model_token_limit, resolve_model_alias, Provider, ProviderFuture};
|
||||
use super::{
|
||||
anthropic_missing_credentials, model_token_limit, resolve_model_alias, Provider, ProviderFuture,
|
||||
};
|
||||
use crate::sse::SseParser;
|
||||
use crate::types::{MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage};
|
||||
|
||||
@@ -49,10 +51,7 @@ impl AuthSource {
|
||||
}),
|
||||
(Some(api_key), None) => Ok(Self::ApiKey(api_key)),
|
||||
(None, Some(bearer_token)) => Ok(Self::BearerToken(bearer_token)),
|
||||
(None, None) => Err(ApiError::missing_credentials(
|
||||
"Anthropic",
|
||||
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
|
||||
)),
|
||||
(None, None) => Err(anthropic_missing_credentials()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,6 +435,7 @@ impl AnthropicClient {
|
||||
last_error = Some(error);
|
||||
}
|
||||
Err(error) => {
|
||||
let error = enrich_bearer_auth_error(error, &self.auth);
|
||||
self.record_request_failure(attempts, &error);
|
||||
return Err(error);
|
||||
}
|
||||
@@ -643,10 +643,7 @@ impl AuthSource {
|
||||
}
|
||||
}
|
||||
Ok(Some(token_set)) => Ok(Self::BearerToken(token_set.access_token)),
|
||||
Ok(None) => Err(ApiError::missing_credentials(
|
||||
"Anthropic",
|
||||
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
|
||||
)),
|
||||
Ok(None) => Err(anthropic_missing_credentials()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
@@ -690,10 +687,7 @@ where
|
||||
}
|
||||
|
||||
let Some(token_set) = load_saved_oauth_token()? else {
|
||||
return Err(ApiError::missing_credentials(
|
||||
"Anthropic",
|
||||
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
|
||||
));
|
||||
return Err(anthropic_missing_credentials());
|
||||
};
|
||||
if !oauth_token_is_expired(&token_set) {
|
||||
return Ok(AuthSource::BearerToken(token_set.access_token));
|
||||
@@ -790,10 +784,7 @@ fn read_api_key() -> Result<String, ApiError> {
|
||||
auth.api_key()
|
||||
.or_else(|| auth.bearer_token())
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or(ApiError::missing_credentials(
|
||||
"Anthropic",
|
||||
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
|
||||
))
|
||||
.ok_or_else(anthropic_missing_credentials)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -934,6 +925,85 @@ const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
|
||||
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
|
||||
}
|
||||
|
||||
/// Anthropic API keys (`sk-ant-*`) are accepted over the `x-api-key` header
|
||||
/// and rejected with HTTP 401 "Invalid bearer token" when sent as a Bearer
|
||||
/// token via `ANTHROPIC_AUTH_TOKEN`. This happens often enough in the wild
|
||||
/// (users copy-paste an `sk-ant-...` key into `ANTHROPIC_AUTH_TOKEN` because
|
||||
/// the env var name sounds auth-related) that a bare 401 error is useless.
|
||||
/// When we detect this exact shape, append a hint to the error message that
|
||||
/// points the user at the one-line fix.
|
||||
const SK_ANT_BEARER_HINT: &str = "sk-ant-* keys go in ANTHROPIC_API_KEY (x-api-key header), not ANTHROPIC_AUTH_TOKEN (Bearer header). Move your key to ANTHROPIC_API_KEY.";
|
||||
|
||||
fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
||||
let ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
} = error
|
||||
else {
|
||||
return error;
|
||||
};
|
||||
if status.as_u16() != 401 {
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
};
|
||||
}
|
||||
let Some(bearer_token) = auth.bearer_token() else {
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
};
|
||||
};
|
||||
if !bearer_token.starts_with("sk-ant-") {
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
};
|
||||
}
|
||||
// Only append the hint when the AuthSource is pure BearerToken. If both
|
||||
// api_key and bearer_token are present (`ApiKeyAndBearer`), the x-api-key
|
||||
// header is already being sent alongside the Bearer header and the 401
|
||||
// is coming from a different cause — adding the hint would be misleading.
|
||||
if auth.api_key().is_some() {
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
};
|
||||
}
|
||||
let enriched_message = match message {
|
||||
Some(existing) => Some(format!("{existing} — hint: {SK_ANT_BEARER_HINT}")),
|
||||
None => Some(format!("hint: {SK_ANT_BEARER_HINT}")),
|
||||
};
|
||||
ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message: enriched_message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove beta-only body fields that the standard `/v1/messages` and
|
||||
/// `/v1/messages/count_tokens` endpoints reject as `Extra inputs are not
|
||||
/// permitted`. The `betas` opt-in is communicated via the `anthropic-beta`
|
||||
@@ -1538,4 +1608,163 @@ mod tests {
|
||||
Some("claude-sonnet-4-6")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_bearer_auth_error_appends_sk_ant_hint_on_401_with_pure_bearer_token() {
|
||||
// given
|
||||
let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string());
|
||||
let error = crate::error::ApiError::Api {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid bearer token".to_string()),
|
||||
request_id: Some("req_varleg_001".to_string()),
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
};
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
|
||||
// then
|
||||
let rendered = enriched.to_string();
|
||||
assert!(
|
||||
rendered.contains("Invalid bearer token"),
|
||||
"existing provider message should be preserved: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains(
|
||||
"sk-ant-* keys go in ANTHROPIC_API_KEY (x-api-key header), not ANTHROPIC_AUTH_TOKEN (Bearer header). Move your key to ANTHROPIC_API_KEY."
|
||||
),
|
||||
"rendered error should include the sk-ant-* hint: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("[trace req_varleg_001]"),
|
||||
"request id should still flow through the enriched error: {rendered}"
|
||||
);
|
||||
match enriched {
|
||||
crate::error::ApiError::Api { status, .. } => {
|
||||
assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
other => panic!("expected Api variant, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_bearer_auth_error_leaves_non_401_errors_unchanged() {
|
||||
// given
|
||||
let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string());
|
||||
let error = crate::error::ApiError::Api {
|
||||
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
error_type: Some("api_error".to_string()),
|
||||
message: Some("internal server error".to_string()),
|
||||
request_id: None,
|
||||
body: String::new(),
|
||||
retryable: true,
|
||||
};
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
|
||||
// then
|
||||
let rendered = enriched.to_string();
|
||||
assert!(
|
||||
!rendered.contains("sk-ant-*"),
|
||||
"non-401 errors must not be annotated with the bearer hint: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("internal server error"),
|
||||
"original message must be preserved verbatim: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_bearer_auth_error_ignores_401_when_bearer_token_is_not_sk_ant() {
|
||||
// given
|
||||
let auth = AuthSource::BearerToken("oauth-access-token-opaque".to_string());
|
||||
let error = crate::error::ApiError::Api {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid bearer token".to_string()),
|
||||
request_id: None,
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
};
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
|
||||
// then
|
||||
let rendered = enriched.to_string();
|
||||
assert!(
|
||||
!rendered.contains("sk-ant-*"),
|
||||
"oauth-style bearer tokens must not trigger the sk-ant-* hint: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_bearer_auth_error_skips_hint_when_api_key_header_is_also_present() {
|
||||
// given
|
||||
let auth = AuthSource::ApiKeyAndBearer {
|
||||
api_key: "sk-ant-api03-legitimate".to_string(),
|
||||
bearer_token: "sk-ant-api03-deadbeef".to_string(),
|
||||
};
|
||||
let error = crate::error::ApiError::Api {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid bearer token".to_string()),
|
||||
request_id: None,
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
};
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
|
||||
// then
|
||||
let rendered = enriched.to_string();
|
||||
assert!(
|
||||
!rendered.contains("sk-ant-*"),
|
||||
"hint should be suppressed when x-api-key header is already being sent: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_bearer_auth_error_ignores_401_when_auth_source_has_no_bearer() {
|
||||
// given
|
||||
let auth = AuthSource::ApiKey("sk-ant-api03-legitimate".to_string());
|
||||
let error = crate::error::ApiError::Api {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid x-api-key".to_string()),
|
||||
request_id: None,
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
};
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
|
||||
// then
|
||||
let rendered = enriched.to_string();
|
||||
assert!(
|
||||
!rendered.contains("sk-ant-*"),
|
||||
"bearer hint must not apply when AuthSource is ApiKey-only: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_bearer_auth_error_passes_non_api_errors_through_unchanged() {
|
||||
// given
|
||||
let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string());
|
||||
let error = crate::error::ApiError::InvalidSseFrame("unterminated event");
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
|
||||
// then
|
||||
assert!(matches!(
|
||||
enriched,
|
||||
crate::error::ApiError::InvalidSseFrame(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +291,73 @@ fn estimate_serialized_tokens<T: Serialize>(value: &T) -> u32 {
|
||||
.map_or(0, |bytes| (bytes.len() / 4 + 1) as u32)
|
||||
}
|
||||
|
||||
/// Env var names used by other provider backends. When Anthropic auth
|
||||
/// resolution fails we sniff these so we can hint the user that their
|
||||
/// credentials probably belong to a different provider and suggest the
|
||||
/// model-prefix routing fix that would select it.
|
||||
const FOREIGN_PROVIDER_ENV_VARS: &[(&str, &str, &str)] = &[
|
||||
(
|
||||
"OPENAI_API_KEY",
|
||||
"OpenAI-compat",
|
||||
"prefix your model name with `openai/` (e.g. `--model openai/gpt-4.1-mini`) so prefix routing selects the OpenAI-compatible provider, and set `OPENAI_BASE_URL` if you are pointing at OpenRouter/Ollama/a local server",
|
||||
),
|
||||
(
|
||||
"XAI_API_KEY",
|
||||
"xAI",
|
||||
"use an xAI model alias (e.g. `--model grok` or `--model grok-mini`) so the prefix router selects the xAI backend",
|
||||
),
|
||||
(
|
||||
"DASHSCOPE_API_KEY",
|
||||
"Alibaba DashScope",
|
||||
"prefix your model name with `qwen/` or `qwen-` (e.g. `--model qwen-plus`) so prefix routing selects the DashScope backend",
|
||||
),
|
||||
];
|
||||
|
||||
/// Check whether an env var is set to a non-empty value either in the real
|
||||
/// process environment or in the working-directory `.env` file. Mirrors the
|
||||
/// credential discovery path used by `read_env_non_empty` so the hint text
|
||||
/// stays truthful when users rely on `.env` instead of a real export.
|
||||
fn env_or_dotenv_present(key: &str) -> bool {
|
||||
match std::env::var(key) {
|
||||
Ok(value) if !value.is_empty() => true,
|
||||
Ok(_) | Err(std::env::VarError::NotPresent) => {
|
||||
dotenv_value(key).is_some_and(|value| !value.is_empty())
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce a hint string describing the first foreign provider credential
|
||||
/// that is present in the environment when Anthropic auth resolution has
|
||||
/// just failed. Returns `None` when no foreign credential is set, in which
|
||||
/// case the caller should fall back to the plain `missing_credentials`
|
||||
/// error without a hint.
|
||||
pub(crate) fn anthropic_missing_credentials_hint() -> Option<String> {
|
||||
for (env_var, provider_label, fix_hint) in FOREIGN_PROVIDER_ENV_VARS {
|
||||
if env_or_dotenv_present(env_var) {
|
||||
return Some(format!(
|
||||
"I see {env_var} is set — if you meant to use the {provider_label} provider, {fix_hint}."
|
||||
));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build an Anthropic-specific `MissingCredentials` error, attaching a
|
||||
/// hint suggesting the probable fix whenever a different provider's
|
||||
/// credentials are already present in the environment. Anthropic call
|
||||
/// sites should prefer this helper over `ApiError::missing_credentials`
|
||||
/// so users who mistyped a model name or forgot the prefix get a useful
|
||||
/// signal instead of a generic "missing Anthropic credentials" wall.
|
||||
pub(crate) fn anthropic_missing_credentials() -> ApiError {
|
||||
const PROVIDER: &str = "Anthropic";
|
||||
const ENV_VARS: &[&str] = &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"];
|
||||
match anthropic_missing_credentials_hint() {
|
||||
Some(hint) => ApiError::missing_credentials_with_hint(PROVIDER, ENV_VARS, hint),
|
||||
None => ApiError::missing_credentials(PROVIDER, ENV_VARS),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `.env` file body into key/value pairs using a minimal `KEY=VALUE`
|
||||
/// grammar. Lines that are blank, start with `#`, or do not contain `=` are
|
||||
/// ignored. Surrounding double or single quotes are stripped from the value.
|
||||
@@ -348,6 +415,9 @@ pub(crate) fn dotenv_value(key: &str) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::ffi::OsString;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::error::ApiError;
|
||||
@@ -356,11 +426,52 @@ mod tests {
|
||||
};
|
||||
|
||||
use super::{
|
||||
detect_provider_kind, load_dotenv_file, max_tokens_for_model,
|
||||
max_tokens_for_model_with_override, model_token_limit, parse_dotenv,
|
||||
preflight_message_request, resolve_model_alias, ProviderKind,
|
||||
anthropic_missing_credentials, anthropic_missing_credentials_hint, detect_provider_kind,
|
||||
load_dotenv_file, max_tokens_for_model, max_tokens_for_model_with_override,
|
||||
model_token_limit, parse_dotenv, preflight_message_request, resolve_model_alias,
|
||||
ProviderKind,
|
||||
};
|
||||
|
||||
/// Serializes every test in this module that mutates process-wide
|
||||
/// environment variables so concurrent test threads cannot observe
|
||||
/// each other's partially-applied state while probing the foreign
|
||||
/// provider credential sniffer.
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Snapshot-restore guard for a single environment variable. Captures
|
||||
/// the original value on construction, applies the requested override
|
||||
/// (set or remove), and restores the original on drop so tests leave
|
||||
/// the process env untouched even when they panic mid-assertion.
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
original: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: Option<&str>) -> Self {
|
||||
let original = std::env::var_os(key);
|
||||
match value {
|
||||
Some(value) => std::env::set_var(key, value),
|
||||
None => std::env::remove_var(key),
|
||||
}
|
||||
Self { key, original }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.original.take() {
|
||||
Some(value) => std::env::set_var(self.key, value),
|
||||
None => std::env::remove_var(self.key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_grok_aliases() {
|
||||
assert_eq!(resolve_model_alias("grok"), "grok-3");
|
||||
@@ -649,4 +760,225 @@ NO_EQUALS_LINE
|
||||
|
||||
let _ = std::fs::remove_dir_all(&temp_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_missing_credentials_hint_is_none_when_no_foreign_creds_present() {
|
||||
// given
|
||||
let _lock = env_lock();
|
||||
let _openai = EnvVarGuard::set("OPENAI_API_KEY", None);
|
||||
let _xai = EnvVarGuard::set("XAI_API_KEY", None);
|
||||
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", None);
|
||||
|
||||
// when
|
||||
let hint = anthropic_missing_credentials_hint();
|
||||
|
||||
// then
|
||||
assert!(
|
||||
hint.is_none(),
|
||||
"no hint should be produced when every foreign provider env var is absent, got {hint:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_missing_credentials_hint_detects_openai_api_key_and_recommends_openai_prefix() {
|
||||
// given
|
||||
let _lock = env_lock();
|
||||
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("sk-openrouter-varleg"));
|
||||
let _xai = EnvVarGuard::set("XAI_API_KEY", None);
|
||||
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", None);
|
||||
|
||||
// when
|
||||
let hint = anthropic_missing_credentials_hint()
|
||||
.expect("OPENAI_API_KEY presence should produce a hint");
|
||||
|
||||
// then
|
||||
assert!(
|
||||
hint.contains("OPENAI_API_KEY is set"),
|
||||
"hint should name the detected env var so users recognize it: {hint}"
|
||||
);
|
||||
assert!(
|
||||
hint.contains("OpenAI-compat"),
|
||||
"hint should identify the target provider: {hint}"
|
||||
);
|
||||
assert!(
|
||||
hint.contains("openai/"),
|
||||
"hint should mention the `openai/` prefix routing fix: {hint}"
|
||||
);
|
||||
assert!(
|
||||
hint.contains("OPENAI_BASE_URL"),
|
||||
"hint should mention OPENAI_BASE_URL so OpenRouter users see the full picture: {hint}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_missing_credentials_hint_detects_xai_api_key() {
|
||||
// given
|
||||
let _lock = env_lock();
|
||||
let _openai = EnvVarGuard::set("OPENAI_API_KEY", None);
|
||||
let _xai = EnvVarGuard::set("XAI_API_KEY", Some("xai-test-key"));
|
||||
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", None);
|
||||
|
||||
// when
|
||||
let hint = anthropic_missing_credentials_hint()
|
||||
.expect("XAI_API_KEY presence should produce a hint");
|
||||
|
||||
// then
|
||||
assert!(
|
||||
hint.contains("XAI_API_KEY is set"),
|
||||
"hint should name XAI_API_KEY: {hint}"
|
||||
);
|
||||
assert!(
|
||||
hint.contains("xAI"),
|
||||
"hint should identify the xAI provider: {hint}"
|
||||
);
|
||||
assert!(
|
||||
hint.contains("grok"),
|
||||
"hint should suggest a grok-prefixed model alias: {hint}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_missing_credentials_hint_detects_dashscope_api_key() {
|
||||
// given
|
||||
let _lock = env_lock();
|
||||
let _openai = EnvVarGuard::set("OPENAI_API_KEY", None);
|
||||
let _xai = EnvVarGuard::set("XAI_API_KEY", None);
|
||||
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("sk-dashscope-test"));
|
||||
|
||||
// when
|
||||
let hint = anthropic_missing_credentials_hint()
|
||||
.expect("DASHSCOPE_API_KEY presence should produce a hint");
|
||||
|
||||
// then
|
||||
assert!(
|
||||
hint.contains("DASHSCOPE_API_KEY is set"),
|
||||
"hint should name DASHSCOPE_API_KEY: {hint}"
|
||||
);
|
||||
assert!(
|
||||
hint.contains("DashScope"),
|
||||
"hint should identify the DashScope provider: {hint}"
|
||||
);
|
||||
assert!(
|
||||
hint.contains("qwen"),
|
||||
"hint should suggest a qwen-prefixed model alias: {hint}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_missing_credentials_hint_prefers_openai_when_multiple_foreign_creds_set() {
|
||||
// given
|
||||
let _lock = env_lock();
|
||||
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("sk-openrouter-varleg"));
|
||||
let _xai = EnvVarGuard::set("XAI_API_KEY", Some("xai-test-key"));
|
||||
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("sk-dashscope-test"));
|
||||
|
||||
// when
|
||||
let hint = anthropic_missing_credentials_hint()
|
||||
.expect("multiple foreign creds should still produce a hint");
|
||||
|
||||
// then
|
||||
assert!(
|
||||
hint.contains("OPENAI_API_KEY"),
|
||||
"OpenAI should be prioritized because it is the most common misrouting pattern (OpenRouter users), got: {hint}"
|
||||
);
|
||||
assert!(
|
||||
!hint.contains("XAI_API_KEY"),
|
||||
"only the first detected provider should be named to keep the hint focused, got: {hint}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_missing_credentials_builds_error_with_canonical_env_vars_and_no_hint_when_clean() {
|
||||
// given
|
||||
let _lock = env_lock();
|
||||
let _openai = EnvVarGuard::set("OPENAI_API_KEY", None);
|
||||
let _xai = EnvVarGuard::set("XAI_API_KEY", None);
|
||||
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", None);
|
||||
|
||||
// when
|
||||
let error = anthropic_missing_credentials();
|
||||
|
||||
// then
|
||||
match &error {
|
||||
ApiError::MissingCredentials {
|
||||
provider,
|
||||
env_vars,
|
||||
hint,
|
||||
} => {
|
||||
assert_eq!(*provider, "Anthropic");
|
||||
assert_eq!(*env_vars, &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"]);
|
||||
assert!(
|
||||
hint.is_none(),
|
||||
"clean environment should not generate a hint, got {hint:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected MissingCredentials variant, got {other:?}"),
|
||||
}
|
||||
let rendered = error.to_string();
|
||||
assert!(
|
||||
!rendered.contains(" — hint: "),
|
||||
"rendered error should be a plain missing-creds message: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_missing_credentials_builds_error_with_hint_when_openai_key_is_set() {
|
||||
// given
|
||||
let _lock = env_lock();
|
||||
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("sk-openrouter-varleg"));
|
||||
let _xai = EnvVarGuard::set("XAI_API_KEY", None);
|
||||
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", None);
|
||||
|
||||
// when
|
||||
let error = anthropic_missing_credentials();
|
||||
|
||||
// then
|
||||
match &error {
|
||||
ApiError::MissingCredentials {
|
||||
provider,
|
||||
env_vars,
|
||||
hint,
|
||||
} => {
|
||||
assert_eq!(*provider, "Anthropic");
|
||||
assert_eq!(*env_vars, &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"]);
|
||||
let hint_value = hint.as_deref().expect("hint should be populated");
|
||||
assert!(
|
||||
hint_value.contains("OPENAI_API_KEY is set"),
|
||||
"hint should name the detected env var: {hint_value}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected MissingCredentials variant, got {other:?}"),
|
||||
}
|
||||
let rendered = error.to_string();
|
||||
assert!(
|
||||
rendered.starts_with("missing Anthropic credentials;"),
|
||||
"canonical base message should still lead the rendered error: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains(" — hint: I see OPENAI_API_KEY is set"),
|
||||
"rendered error should carry the env-driven hint: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_missing_credentials_hint_ignores_empty_string_values() {
|
||||
// given
|
||||
let _lock = env_lock();
|
||||
// An empty value is semantically equivalent to "not set" for the
|
||||
// credential discovery path, so the sniffer must treat it that way
|
||||
// to avoid false-positive hints for users who intentionally cleared
|
||||
// a stale export with `OPENAI_API_KEY=`.
|
||||
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some(""));
|
||||
let _xai = EnvVarGuard::set("XAI_API_KEY", None);
|
||||
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", None);
|
||||
|
||||
// when
|
||||
let hint = anthropic_missing_credentials_hint();
|
||||
|
||||
// then
|
||||
assert!(
|
||||
hint.is_none(),
|
||||
"empty env var should not trigger the hint sniffer, got {hint:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,11 @@ impl OpenAiCompatClient {
|
||||
const fn config(&self) -> OpenAiCompatConfig {
|
||||
self.config
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
#[must_use]
|
||||
pub fn new(api_key: impl Into<String>, config: OpenAiCompatConfig) -> Self {
|
||||
Self {
|
||||
@@ -721,6 +726,24 @@ fn is_reasoning_model(model: &str) -> bool {
|
||||
|| canonical.contains("thinking")
|
||||
}
|
||||
|
||||
/// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
|
||||
/// The prefix is used only to select transport; the backend expects the
|
||||
/// bare model id.
|
||||
fn strip_routing_prefix(model: &str) -> &str {
|
||||
if let Some(pos) = model.find('/') {
|
||||
let prefix = &model[..pos];
|
||||
// Only strip if the prefix before "/" is a known routing prefix,
|
||||
// not if "/" appears in the middle of the model name for other reasons.
|
||||
if matches!(prefix, "openai" | "xai" | "grok" | "qwen") {
|
||||
&model[pos + 1..]
|
||||
} else {
|
||||
model
|
||||
}
|
||||
} else {
|
||||
model
|
||||
}
|
||||
}
|
||||
|
||||
fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatConfig) -> Value {
|
||||
let mut messages = Vec::new();
|
||||
if let Some(system) = request.system.as_ref().filter(|value| !value.is_empty()) {
|
||||
@@ -733,9 +756,21 @@ fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatC
|
||||
messages.extend(translate_message(message));
|
||||
}
|
||||
|
||||
// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire.
|
||||
let wire_model = strip_routing_prefix(&request.model);
|
||||
|
||||
// gpt-5* requires `max_completion_tokens`; older OpenAI models accept both.
|
||||
// We send the correct field based on the wire model name so gpt-5.x requests
|
||||
// don't fail with "unknown field max_tokens".
|
||||
let max_tokens_key = if wire_model.starts_with("gpt-5") {
|
||||
"max_completion_tokens"
|
||||
} else {
|
||||
"max_tokens"
|
||||
};
|
||||
|
||||
let mut payload = json!({
|
||||
"model": request.model,
|
||||
"max_tokens": request.max_tokens,
|
||||
"model": wire_model,
|
||||
max_tokens_key: request.max_tokens,
|
||||
"messages": messages,
|
||||
"stream": request.stream,
|
||||
});
|
||||
@@ -775,6 +810,10 @@ fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatC
|
||||
payload["stop"] = json!(stop);
|
||||
}
|
||||
}
|
||||
// reasoning_effort for OpenAI-compatible reasoning models (o4-mini, o3, etc.)
|
||||
if let Some(effort) = &request.reasoning_effort {
|
||||
payload["reasoning_effort"] = json!(effort);
|
||||
}
|
||||
|
||||
payload
|
||||
}
|
||||
@@ -843,13 +882,45 @@ fn flatten_tool_result_content(content: &[ToolResultContentBlock]) -> String {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Recursively ensure every object-type node in a JSON Schema has
|
||||
/// `"properties"` (at least `{}`) and `"additionalProperties": false`.
|
||||
/// The OpenAI `/responses` endpoint validates schemas strictly and rejects
|
||||
/// objects that omit these fields; `/chat/completions` is lenient but also
|
||||
/// accepts them, so we normalise unconditionally.
|
||||
fn normalize_object_schema(schema: &mut Value) {
|
||||
if let Some(obj) = schema.as_object_mut() {
|
||||
if obj.get("type").and_then(Value::as_str) == Some("object") {
|
||||
obj.entry("properties").or_insert_with(|| json!({}));
|
||||
obj.entry("additionalProperties")
|
||||
.or_insert(Value::Bool(false));
|
||||
}
|
||||
// Recurse into properties values
|
||||
if let Some(props) = obj.get_mut("properties") {
|
||||
if let Some(props_obj) = props.as_object_mut() {
|
||||
let keys: Vec<String> = props_obj.keys().cloned().collect();
|
||||
for k in keys {
|
||||
if let Some(v) = props_obj.get_mut(&k) {
|
||||
normalize_object_schema(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recurse into items (arrays)
|
||||
if let Some(items) = obj.get_mut("items") {
|
||||
normalize_object_schema(items);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_tool_definition(tool: &ToolDefinition) -> Value {
|
||||
let mut parameters = tool.input_schema.clone();
|
||||
normalize_object_schema(&mut parameters);
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.input_schema,
|
||||
"parameters": parameters,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1117,6 +1188,76 @@ mod tests {
|
||||
assert_eq!(payload["tool_choice"], json!("auto"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_object_gets_strict_fields_for_responses_endpoint() {
|
||||
// OpenAI /responses endpoint rejects object schemas missing
|
||||
// "properties" and "additionalProperties". Verify normalize_object_schema
|
||||
// fills them in so the request shape is strict-validator-safe.
|
||||
use super::normalize_object_schema;
|
||||
|
||||
// Bare object — no properties at all
|
||||
let mut schema = json!({"type": "object"});
|
||||
normalize_object_schema(&mut schema);
|
||||
assert_eq!(schema["properties"], json!({}));
|
||||
assert_eq!(schema["additionalProperties"], json!(false));
|
||||
|
||||
// Nested object inside properties
|
||||
let mut schema2 = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"type": "object", "properties": {"lat": {"type": "number"}}}
|
||||
}
|
||||
});
|
||||
normalize_object_schema(&mut schema2);
|
||||
assert_eq!(schema2["additionalProperties"], json!(false));
|
||||
assert_eq!(
|
||||
schema2["properties"]["location"]["additionalProperties"],
|
||||
json!(false)
|
||||
);
|
||||
|
||||
// Existing properties/additionalProperties should not be overwritten
|
||||
let mut schema3 = json!({
|
||||
"type": "object",
|
||||
"properties": {"x": {"type": "string"}},
|
||||
"additionalProperties": true
|
||||
});
|
||||
normalize_object_schema(&mut schema3);
|
||||
assert_eq!(
|
||||
schema3["additionalProperties"],
|
||||
json!(true),
|
||||
"must not overwrite existing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_is_included_when_set() {
|
||||
let payload = build_chat_completion_request(
|
||||
&MessageRequest {
|
||||
model: "o4-mini".to_string(),
|
||||
max_tokens: 1024,
|
||||
messages: vec![InputMessage::user_text("think hard")],
|
||||
reasoning_effort: Some("high".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
OpenAiCompatConfig::openai(),
|
||||
);
|
||||
assert_eq!(payload["reasoning_effort"], json!("high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_omitted_when_not_set() {
|
||||
let payload = build_chat_completion_request(
|
||||
&MessageRequest {
|
||||
model: "gpt-4o".to_string(),
|
||||
max_tokens: 64,
|
||||
messages: vec![InputMessage::user_text("hello")],
|
||||
..Default::default()
|
||||
},
|
||||
OpenAiCompatConfig::openai(),
|
||||
);
|
||||
assert!(payload.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_streaming_requests_include_usage_opt_in() {
|
||||
let payload = build_chat_completion_request(
|
||||
@@ -1234,6 +1375,7 @@ mod tests {
|
||||
frequency_penalty: Some(0.5),
|
||||
presence_penalty: Some(0.3),
|
||||
stop: Some(vec!["\n".to_string()]),
|
||||
reasoning_effort: None,
|
||||
};
|
||||
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||
assert_eq!(payload["temperature"], 0.7);
|
||||
@@ -1318,4 +1460,45 @@ mod tests {
|
||||
assert!(payload.get("presence_penalty").is_none());
|
||||
assert!(payload.get("stop").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpt5_uses_max_completion_tokens_not_max_tokens() {
|
||||
// gpt-5* models require `max_completion_tokens`; legacy `max_tokens` causes
|
||||
// a request-validation failure. Verify the correct key is emitted.
|
||||
let request = MessageRequest {
|
||||
model: "gpt-5.2".to_string(),
|
||||
max_tokens: 512,
|
||||
messages: vec![],
|
||||
stream: false,
|
||||
..Default::default()
|
||||
};
|
||||
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||
assert_eq!(
|
||||
payload["max_completion_tokens"],
|
||||
json!(512),
|
||||
"gpt-5.2 should emit max_completion_tokens"
|
||||
);
|
||||
assert!(
|
||||
payload.get("max_tokens").is_none(),
|
||||
"gpt-5.2 must not emit max_tokens"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_gpt5_uses_max_tokens() {
|
||||
// Older OpenAI models expect `max_tokens`; verify gpt-4o is unaffected.
|
||||
let request = MessageRequest {
|
||||
model: "gpt-4o".to_string(),
|
||||
max_tokens: 512,
|
||||
messages: vec![],
|
||||
stream: false,
|
||||
..Default::default()
|
||||
};
|
||||
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||
assert_eq!(payload["max_tokens"], json!(512));
|
||||
assert!(
|
||||
payload.get("max_completion_tokens").is_none(),
|
||||
"gpt-4o must not emit max_completion_tokens"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@ pub struct MessageRequest {
|
||||
pub presence_penalty: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop: Option<Vec<String>>,
|
||||
/// Reasoning effort level for OpenAI-compatible reasoning models (e.g. `o4-mini`).
|
||||
/// Accepted values: `"low"`, `"medium"`, `"high"`. Omitted when `None`.
|
||||
/// Silently ignored by backends that do not support it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort: Option<String>,
|
||||
}
|
||||
|
||||
impl MessageRequest {
|
||||
|
||||
@@ -22,7 +22,9 @@ fn provider_client_reports_missing_xai_credentials_for_grok_models() {
|
||||
.expect_err("grok requests without XAI_API_KEY should fail fast");
|
||||
|
||||
match error {
|
||||
ApiError::MissingCredentials { provider, env_vars } => {
|
||||
ApiError::MissingCredentials {
|
||||
provider, env_vars, ..
|
||||
} => {
|
||||
assert_eq!(provider, "xAI");
|
||||
assert_eq!(env_vars, &["XAI_API_KEY"]);
|
||||
}
|
||||
|
||||
@@ -1938,6 +1938,42 @@ pub fn suggest_slash_commands(input: &str, limit: usize) -> Vec<String> {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
/// Render the slash-command help section, optionally excluding stub commands
|
||||
/// (commands that are registered in the spec list but not yet implemented).
|
||||
/// Pass an empty slice to include all commands.
|
||||
pub fn render_slash_command_help_filtered(exclude: &[&str]) -> String {
|
||||
let mut lines = vec![
|
||||
"Slash commands".to_string(),
|
||||
" Start here /status, /diff, /agents, /skills, /commit".to_string(),
|
||||
" [resume] also works with --resume SESSION.jsonl".to_string(),
|
||||
String::new(),
|
||||
];
|
||||
|
||||
let categories = ["Session", "Tools", "Config", "Debug"];
|
||||
|
||||
for category in categories {
|
||||
lines.push(category.to_string());
|
||||
for spec in slash_command_specs()
|
||||
.iter()
|
||||
.filter(|spec| slash_command_category(spec.name) == category)
|
||||
.filter(|spec| !exclude.contains(&spec.name))
|
||||
{
|
||||
lines.push(format_slash_command_help_line(spec));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
lines
|
||||
.into_iter()
|
||||
.rev()
|
||||
.skip_while(String::is_empty)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub fn render_slash_command_help() -> String {
|
||||
let mut lines = vec![
|
||||
"Slash commands".to_string(),
|
||||
|
||||
@@ -337,7 +337,28 @@ impl CommandWithStdin {
|
||||
let mut child = self.command.spawn()?;
|
||||
if let Some(mut child_stdin) = child.stdin.take() {
|
||||
use std::io::Write as _;
|
||||
child_stdin.write_all(stdin)?;
|
||||
// Tolerate BrokenPipe: a hook script that runs to completion
|
||||
// (or exits early without reading stdin) closes its stdin
|
||||
// before the parent finishes writing the JSON payload, and
|
||||
// the kernel raises EPIPE on the parent's write_all. That is
|
||||
// not a hook failure — the child still exited cleanly and we
|
||||
// still need to wait_with_output() to capture stdout/stderr
|
||||
// and the real exit code. Other write errors (e.g. EIO,
|
||||
// permission, OOM) still propagate.
|
||||
//
|
||||
// This was the root cause of the Linux CI flake on
|
||||
// hooks::tests::collects_and_runs_hooks_from_enabled_plugins
|
||||
// (ROADMAP #25, runs 24120271422 / 24120538408 / 24121392171
|
||||
// / 24121776826): the test hook scripts run in microseconds
|
||||
// and the parent's stdin write races against child exit.
|
||||
// macOS pipes happen to buffer the small payload before the
|
||||
// child exits; Linux pipes do not, so the race shows up
|
||||
// deterministically on ubuntu runners.
|
||||
match child_stdin.write_all(stdin) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::BrokenPipe => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
child.wait_with_output()
|
||||
}
|
||||
@@ -359,6 +380,18 @@ mod tests {
|
||||
std::env::temp_dir().join(format!("plugins-hook-runner-{label}-{nanos}"))
|
||||
}
|
||||
|
||||
fn make_executable(path: &Path) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = fs::Permissions::from_mode(0o755);
|
||||
fs::set_permissions(path, perms)
|
||||
.unwrap_or_else(|e| panic!("chmod +x {}: {e}", path.display()));
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = path;
|
||||
}
|
||||
|
||||
fn write_hook_plugin(
|
||||
root: &Path,
|
||||
name: &str,
|
||||
@@ -368,21 +401,30 @@ mod tests {
|
||||
) {
|
||||
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
|
||||
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
|
||||
|
||||
let pre_path = root.join("hooks").join("pre.sh");
|
||||
fs::write(
|
||||
root.join("hooks").join("pre.sh"),
|
||||
&pre_path,
|
||||
format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"),
|
||||
)
|
||||
.expect("write pre hook");
|
||||
make_executable(&pre_path);
|
||||
|
||||
let post_path = root.join("hooks").join("post.sh");
|
||||
fs::write(
|
||||
root.join("hooks").join("post.sh"),
|
||||
&post_path,
|
||||
format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
|
||||
)
|
||||
.expect("write post hook");
|
||||
make_executable(&post_path);
|
||||
|
||||
let failure_path = root.join("hooks").join("failure.sh");
|
||||
fs::write(
|
||||
root.join("hooks").join("failure.sh"),
|
||||
&failure_path,
|
||||
format!("#!/bin/sh\nprintf '%s\\n' '{failure_message}'\n"),
|
||||
)
|
||||
.expect("write failure hook");
|
||||
make_executable(&failure_path);
|
||||
fs::write(
|
||||
root.join(".claude-plugin").join("plugin.json"),
|
||||
format!(
|
||||
@@ -496,4 +538,27 @@ mod tests {
|
||||
.iter()
|
||||
.any(|message| message == "later plugin hook"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn generated_hook_scripts_are_executable() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
// given
|
||||
let root = temp_dir("exec-guard");
|
||||
write_hook_plugin(&root, "exec-check", "pre", "post", "fail");
|
||||
|
||||
// then
|
||||
for script in ["pre.sh", "post.sh", "failure.sh"] {
|
||||
let path = root.join("hooks").join(script);
|
||||
let mode = fs::metadata(&path)
|
||||
.unwrap_or_else(|e| panic!("{script} metadata: {e}"))
|
||||
.permissions()
|
||||
.mode();
|
||||
assert!(
|
||||
mode & 0o111 != 0,
|
||||
"{script} must have at least one execute bit set, got mode {mode:#o}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,6 +504,10 @@ where
|
||||
&self.session
|
||||
}
|
||||
|
||||
pub fn api_client_mut(&mut self) -> &mut C {
|
||||
&mut self.api_client
|
||||
}
|
||||
|
||||
pub fn session_mut(&mut self) -> &mut Session {
|
||||
&mut self.session
|
||||
}
|
||||
|
||||
59
rust/crates/rusty-claude-cli/build.rs
Normal file
59
rust/crates/rusty-claude-cli/build.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use std::env;
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
// Get git SHA (short hash)
|
||||
let git_sha = Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| {
|
||||
if output.status.success() {
|
||||
String::from_utf8(output.stdout).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
println!("cargo:rustc-env=GIT_SHA={}", git_sha);
|
||||
|
||||
// TARGET is always set by Cargo during build
|
||||
let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_string());
|
||||
println!("cargo:rustc-env=TARGET={}", target);
|
||||
|
||||
// Build date from SOURCE_DATE_EPOCH (reproducible builds) or current UTC date.
|
||||
// Intentionally ignoring time component to keep output deterministic within a day.
|
||||
let build_date = std::env::var("SOURCE_DATE_EPOCH")
|
||||
.ok()
|
||||
.and_then(|epoch| epoch.parse::<i64>().ok())
|
||||
.map(|_ts| {
|
||||
// Use SOURCE_DATE_EPOCH to derive date via chrono if available;
|
||||
// for simplicity we just use the env var as a signal and fall back
|
||||
// to build-time env. In practice CI sets this via workflow.
|
||||
std::env::var("BUILD_DATE").unwrap_or_else(|_| "unknown".to_string())
|
||||
})
|
||||
.or_else(|| std::env::var("BUILD_DATE").ok())
|
||||
.unwrap_or_else(|| {
|
||||
// Fall back to current date via `date` command
|
||||
Command::new("date")
|
||||
.args(["+%Y-%m-%d"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
String::from_utf8(o.stdout).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
});
|
||||
println!("cargo:rustc-env=BUILD_DATE={build_date}");
|
||||
|
||||
// Rerun if git state changes
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=.git/refs");
|
||||
}
|
||||
@@ -26,16 +26,17 @@ use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||
use api::{
|
||||
detect_provider_kind, oauth_token_is_expired, resolve_startup_auth_source, AnthropicClient,
|
||||
AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
|
||||
MessageResponse, OutputContentBlock, PromptCache, ProviderKind, StreamEvent as ApiStreamEvent,
|
||||
ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||
MessageResponse, OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient,
|
||||
ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
|
||||
ToolResultContentBlock,
|
||||
};
|
||||
|
||||
use commands::{
|
||||
classify_skills_slash_command, handle_agents_slash_command, handle_agents_slash_command_json,
|
||||
handle_mcp_slash_command, handle_mcp_slash_command_json, handle_plugins_slash_command,
|
||||
handle_skills_slash_command, handle_skills_slash_command_json, render_slash_command_help,
|
||||
resume_supported_slash_commands, slash_command_specs, validate_slash_command_input,
|
||||
SkillSlashDispatch, SlashCommand,
|
||||
render_slash_command_help_filtered, resolve_skill_invocation, resume_supported_slash_commands,
|
||||
slash_command_specs, validate_slash_command_input, SkillSlashDispatch, SlashCommand,
|
||||
};
|
||||
use compat_harness::{extract_manifest, UpstreamPaths};
|
||||
use init::initialize_repo;
|
||||
@@ -66,7 +67,12 @@ fn max_tokens_for_model(model: &str) -> u32 {
|
||||
64_000
|
||||
}
|
||||
}
|
||||
const DEFAULT_DATE: &str = "2026-03-31";
|
||||
// Build-time constants injected by build.rs (fall back to static values when
|
||||
// build.rs hasn't run, e.g. in doc-test or unusual toolchain environments).
|
||||
const DEFAULT_DATE: &str = match option_env!("BUILD_DATE") {
|
||||
Some(d) => d,
|
||||
None => "unknown",
|
||||
};
|
||||
const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const BUILD_TARGET: Option<&str> = option_env!("TARGET");
|
||||
@@ -104,7 +110,22 @@ type RuntimePluginStateBuildOutput = (
|
||||
fn main() {
|
||||
if let Err(error) = run() {
|
||||
let message = error.to_string();
|
||||
if message.contains("`claw --help`") {
|
||||
// When --output-format json is active, emit errors as JSON so downstream
|
||||
// tools can parse failures the same way they parse successes (ROADMAP #42).
|
||||
let argv: Vec<String> = std::env::args().collect();
|
||||
let json_output = argv
|
||||
.windows(2)
|
||||
.any(|w| w[0] == "--output-format" && w[1] == "json")
|
||||
|| argv.iter().any(|a| a == "--output-format=json");
|
||||
if json_output {
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"type": "error",
|
||||
"error": message,
|
||||
})
|
||||
);
|
||||
} else if message.contains("`claw --help`") {
|
||||
eprintln!("error: {message}");
|
||||
} else {
|
||||
eprintln!(
|
||||
@@ -203,6 +224,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
permission_mode,
|
||||
compact,
|
||||
base_commit,
|
||||
reasoning_effort,
|
||||
} => {
|
||||
run_stale_base_preflight(base_commit.as_deref());
|
||||
// Only consume piped stdin as prompt context when the permission
|
||||
@@ -216,11 +238,9 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
None
|
||||
};
|
||||
let effective_prompt = merge_prompt_with_stdin(&prompt, stdin_context.as_deref());
|
||||
LiveCli::new(model, true, allowed_tools, permission_mode)?.run_turn_with_output(
|
||||
&effective_prompt,
|
||||
output_format,
|
||||
compact,
|
||||
)?;
|
||||
let mut cli = LiveCli::new(model, true, allowed_tools, permission_mode)?;
|
||||
cli.set_reasoning_effort(reasoning_effort);
|
||||
cli.run_turn_with_output(&effective_prompt, output_format, compact)?;
|
||||
}
|
||||
CliAction::Login { output_format } => run_login(output_format)?,
|
||||
CliAction::Logout { output_format } => run_logout(output_format)?,
|
||||
@@ -237,7 +257,14 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
base_commit,
|
||||
} => run_repl(model, allowed_tools, permission_mode, base_commit)?,
|
||||
reasoning_effort,
|
||||
} => run_repl(
|
||||
model,
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
base_commit,
|
||||
reasoning_effort,
|
||||
)?,
|
||||
CliAction::HelpTopic(topic) => print_help_topic(topic),
|
||||
CliAction::Help { output_format } => print_help(output_format)?,
|
||||
}
|
||||
@@ -298,6 +325,7 @@ enum CliAction {
|
||||
permission_mode: PermissionMode,
|
||||
compact: bool,
|
||||
base_commit: Option<String>,
|
||||
reasoning_effort: Option<String>,
|
||||
},
|
||||
Login {
|
||||
output_format: CliOutputFormat,
|
||||
@@ -324,6 +352,7 @@ enum CliAction {
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
permission_mode: PermissionMode,
|
||||
base_commit: Option<String>,
|
||||
reasoning_effort: Option<String>,
|
||||
},
|
||||
HelpTopic(LocalHelpTopic),
|
||||
// prompt-mode formatting is only supported for non-interactive runs
|
||||
@@ -367,7 +396,8 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
let mut allowed_tool_values = Vec::new();
|
||||
let mut compact = false;
|
||||
let mut base_commit: Option<String> = None;
|
||||
let mut rest = Vec::new();
|
||||
let mut reasoning_effort: Option<String> = None;
|
||||
let mut rest: Vec<String> = Vec::new();
|
||||
let mut index = 0;
|
||||
|
||||
while index < args.len() {
|
||||
@@ -376,6 +406,31 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
wants_help = true;
|
||||
index += 1;
|
||||
}
|
||||
"--help" | "-h"
|
||||
if !rest.is_empty()
|
||||
&& matches!(
|
||||
rest[0].as_str(),
|
||||
"prompt"
|
||||
| "login"
|
||||
| "logout"
|
||||
| "version"
|
||||
| "state"
|
||||
| "init"
|
||||
| "export"
|
||||
| "commit"
|
||||
| "pr"
|
||||
| "issue"
|
||||
) =>
|
||||
{
|
||||
// `--help` following a subcommand that would otherwise forward
|
||||
// the arg to the API (e.g. `claw prompt --help`) should show
|
||||
// top-level help instead. Subcommands that consume their own
|
||||
// args (agents, mcp, plugins, skills) and local help-topic
|
||||
// subcommands (status, sandbox, doctor) must NOT be intercepted
|
||||
// here — they handle --help in their own dispatch paths.
|
||||
wants_help = true;
|
||||
index += 1;
|
||||
}
|
||||
"--version" | "-V" => {
|
||||
wants_version = true;
|
||||
index += 1;
|
||||
@@ -432,6 +487,28 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
base_commit = Some(flag[14..].to_string());
|
||||
index += 1;
|
||||
}
|
||||
"--reasoning-effort" => {
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
.ok_or_else(|| "missing value for --reasoning-effort".to_string())?;
|
||||
if !matches!(value.as_str(), "low" | "medium" | "high") {
|
||||
return Err(format!(
|
||||
"invalid value for --reasoning-effort: '{value}'; must be low, medium, or high"
|
||||
));
|
||||
}
|
||||
reasoning_effort = Some(value.clone());
|
||||
index += 2;
|
||||
}
|
||||
flag if flag.starts_with("--reasoning-effort=") => {
|
||||
let value = &flag[19..];
|
||||
if !matches!(value, "low" | "medium" | "high") {
|
||||
return Err(format!(
|
||||
"invalid value for --reasoning-effort: '{value}'; must be low, medium, or high"
|
||||
));
|
||||
}
|
||||
reasoning_effort = Some(value.to_string());
|
||||
index += 1;
|
||||
}
|
||||
"-p" => {
|
||||
// Claw Code compat: -p "prompt" = one-shot prompt
|
||||
let prompt = args[index + 1..].join(" ");
|
||||
@@ -447,6 +524,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
.unwrap_or_else(default_permission_mode),
|
||||
compact,
|
||||
base_commit: base_commit.clone(),
|
||||
reasoning_effort: reasoning_effort.clone(),
|
||||
});
|
||||
}
|
||||
"--print" => {
|
||||
@@ -505,6 +583,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
base_commit,
|
||||
reasoning_effort: reasoning_effort.clone(),
|
||||
});
|
||||
}
|
||||
if rest.first().map(String::as_str) == Some("--resume") {
|
||||
@@ -543,6 +622,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
permission_mode,
|
||||
compact,
|
||||
base_commit,
|
||||
reasoning_effort: reasoning_effort.clone(),
|
||||
}),
|
||||
SkillSlashDispatch::Local => Ok(CliAction::Skills {
|
||||
args,
|
||||
@@ -568,6 +648,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
permission_mode,
|
||||
compact,
|
||||
base_commit: base_commit.clone(),
|
||||
reasoning_effort: reasoning_effort.clone(),
|
||||
})
|
||||
}
|
||||
other if other.starts_with('/') => parse_direct_slash_cli_action(
|
||||
@@ -578,6 +659,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
permission_mode,
|
||||
compact,
|
||||
base_commit,
|
||||
reasoning_effort,
|
||||
),
|
||||
_other => Ok(CliAction::Prompt {
|
||||
prompt: rest.join(" "),
|
||||
@@ -587,6 +669,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
permission_mode,
|
||||
compact,
|
||||
base_commit,
|
||||
reasoning_effort: reasoning_effort.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -680,6 +763,7 @@ fn parse_direct_slash_cli_action(
|
||||
permission_mode: PermissionMode,
|
||||
compact: bool,
|
||||
base_commit: Option<String>,
|
||||
reasoning_effort: Option<String>,
|
||||
) -> Result<CliAction, String> {
|
||||
let raw = rest.join(" ");
|
||||
match SlashCommand::parse(&raw) {
|
||||
@@ -707,6 +791,7 @@ fn parse_direct_slash_cli_action(
|
||||
permission_mode,
|
||||
compact,
|
||||
base_commit,
|
||||
reasoning_effort: reasoning_effort.clone(),
|
||||
}),
|
||||
SkillSlashDispatch::Local => Ok(CliAction::Skills {
|
||||
args,
|
||||
@@ -1342,16 +1427,16 @@ fn run_worker_state(output_format: CliOutputFormat) -> Result<(), Box<dyn std::e
|
||||
let cwd = env::current_dir()?;
|
||||
let state_path = cwd.join(".claw").join("worker-state.json");
|
||||
if !state_path.exists() {
|
||||
match output_format {
|
||||
CliOutputFormat::Text => {
|
||||
println!("No worker state file found at {}", state_path.display())
|
||||
}
|
||||
CliOutputFormat::Json => println!(
|
||||
"{}",
|
||||
serde_json::json!({"error": "no_state_file", "path": state_path.display().to_string()})
|
||||
),
|
||||
}
|
||||
return Ok(());
|
||||
// Emit a structured error, then return Err so the process exits 1.
|
||||
// Callers (scripts, CI) need a non-zero exit to detect "no state" without
|
||||
// parsing prose output.
|
||||
// Let the error propagate to main() which will format it correctly
|
||||
// (prose for text mode, JSON envelope for --output-format json).
|
||||
return Err(format!(
|
||||
"no worker state file found at {} — run a worker first",
|
||||
state_path.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let raw = std::fs::read_to_string(&state_path)?;
|
||||
match output_format {
|
||||
@@ -2756,10 +2841,12 @@ fn run_repl(
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
permission_mode: PermissionMode,
|
||||
base_commit: Option<String>,
|
||||
reasoning_effort: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
run_stale_base_preflight(base_commit.as_deref());
|
||||
let resolved_model = resolve_repl_model(model);
|
||||
let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?;
|
||||
cli.set_reasoning_effort(reasoning_effort);
|
||||
let mut editor =
|
||||
input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default());
|
||||
println!("{}", cli.startup_banner());
|
||||
@@ -2790,6 +2877,26 @@ fn run_repl(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Bare-word skill dispatch: if the first token of the input
|
||||
// matches a known skill name, invoke it as `/skills <input>`
|
||||
// rather than forwarding raw text to the LLM (ROADMAP #36).
|
||||
let bare_first_token = trimmed.split_whitespace().next().unwrap_or_default();
|
||||
let looks_like_skill_name = !bare_first_token.is_empty()
|
||||
&& !bare_first_token.starts_with('/')
|
||||
&& bare_first_token
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_');
|
||||
if looks_like_skill_name {
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
if let Ok(SkillSlashDispatch::Invoke(prompt)) =
|
||||
resolve_skill_invocation(&cwd, Some(&trimmed))
|
||||
{
|
||||
editor.push_history(input);
|
||||
cli.record_prompt_history(&trimmed);
|
||||
cli.run_turn(&prompt)?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
editor.push_history(input);
|
||||
cli.record_prompt_history(&trimmed);
|
||||
cli.run_turn(&trimmed)?;
|
||||
@@ -3342,6 +3449,12 @@ impl LiveCli {
|
||||
Ok(cli)
|
||||
}
|
||||
|
||||
fn set_reasoning_effort(&mut self, effort: Option<String>) {
|
||||
if let Some(rt) = self.runtime.runtime.as_mut() {
|
||||
rt.api_client_mut().set_reasoning_effort(effort);
|
||||
}
|
||||
}
|
||||
|
||||
fn startup_banner(&self) -> String {
|
||||
let cwd = env::current_dir().map_or_else(
|
||||
|_| "<unknown>".to_string(),
|
||||
@@ -4644,7 +4757,7 @@ fn render_repl_help() -> String {
|
||||
" Browse sessions /session list".to_string(),
|
||||
" Show prompt history /history [count]".to_string(),
|
||||
String::new(),
|
||||
render_slash_command_help(),
|
||||
render_slash_command_help_filtered(STUB_COMMANDS),
|
||||
]
|
||||
.join(
|
||||
"
|
||||
@@ -6348,9 +6461,15 @@ impl runtime::PermissionPrompter for CliPermissionPrompter {
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
|
||||
// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI,
|
||||
// OpenAI, or DashScope at construction time based on
|
||||
// `detect_provider_kind(&model)`. The struct name is kept to avoid
|
||||
// churning `BuiltRuntime` and every Deref/DerefMut site that references
|
||||
// it. See ROADMAP #29 for the provider-dispatch routing fix.
|
||||
struct AnthropicRuntimeClient {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
client: AnthropicClient,
|
||||
client: ApiProviderClient,
|
||||
session_id: String,
|
||||
model: String,
|
||||
enable_tools: bool,
|
||||
@@ -6358,6 +6477,7 @@ struct AnthropicRuntimeClient {
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
tool_registry: GlobalToolRegistry,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
reasoning_effort: Option<String>,
|
||||
}
|
||||
|
||||
impl AnthropicRuntimeClient {
|
||||
@@ -6370,11 +6490,51 @@ impl AnthropicRuntimeClient {
|
||||
tool_registry: GlobalToolRegistry,
|
||||
progress_reporter: Option<InternalPromptProgressReporter>,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
// Dispatch to the correct provider at construction time.
|
||||
// `ApiProviderClient` (exposed by the api crate as
|
||||
// `ProviderClient`) is an enum over Anthropic / xAI / OpenAI
|
||||
// variants, where xAI and OpenAI both use the OpenAI-compat
|
||||
// wire format under the hood. We consult
|
||||
// `detect_provider_kind(&resolved_model)` so model-name prefix
|
||||
// routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over
|
||||
// env-var presence.
|
||||
//
|
||||
// For Anthropic we build the client directly instead of going
|
||||
// through `ApiProviderClient::from_model_with_anthropic_auth`
|
||||
// so we can explicitly apply `api::read_base_url()` — that
|
||||
// reads `ANTHROPIC_BASE_URL` and is required for the local
|
||||
// mock-server test harness
|
||||
// (`crates/rusty-claude-cli/tests/compact_output.rs`) to point
|
||||
// claw at its fake Anthropic endpoint. We also attach a
|
||||
// session-scoped prompt cache on the Anthropic path; the
|
||||
// prompt cache is Anthropic-only so non-Anthropic variants
|
||||
// skip it.
|
||||
let resolved_model = api::resolve_model_alias(&model);
|
||||
let client = match detect_provider_kind(&resolved_model) {
|
||||
ProviderKind::Anthropic => {
|
||||
let auth = resolve_cli_auth_source()?;
|
||||
let inner = AnthropicClient::from_auth(auth)
|
||||
.with_base_url(api::read_base_url())
|
||||
.with_prompt_cache(PromptCache::new(session_id));
|
||||
ApiProviderClient::Anthropic(inner)
|
||||
}
|
||||
ProviderKind::Xai | ProviderKind::OpenAi => {
|
||||
// The api crate's `ProviderClient::from_model_with_anthropic_auth`
|
||||
// with `None` for the anthropic auth routes via
|
||||
// `detect_provider_kind` and builds an
|
||||
// `OpenAiCompatClient::from_env` with the matching
|
||||
// `OpenAiCompatConfig` (openai / xai / dashscope).
|
||||
// That reads the correct API-key env var and BASE_URL
|
||||
// override internally, so this one call covers OpenAI,
|
||||
// OpenRouter, xAI, DashScope, Ollama, and any other
|
||||
// OpenAI-compat endpoint users configure via
|
||||
// `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`.
|
||||
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
runtime: tokio::runtime::Runtime::new()?,
|
||||
client: AnthropicClient::from_auth(resolve_cli_auth_source()?)
|
||||
.with_base_url(api::read_base_url())
|
||||
.with_prompt_cache(PromptCache::new(session_id)),
|
||||
client,
|
||||
session_id: session_id.to_string(),
|
||||
model,
|
||||
enable_tools,
|
||||
@@ -6382,8 +6542,13 @@ impl AnthropicRuntimeClient {
|
||||
allowed_tools,
|
||||
tool_registry,
|
||||
progress_reporter,
|
||||
reasoning_effort: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_reasoning_effort(&mut self, effort: Option<String>) {
|
||||
self.reasoning_effort = effort;
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
|
||||
@@ -6429,6 +6594,7 @@ impl ApiClient for AnthropicRuntimeClient {
|
||||
.then(|| filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())),
|
||||
tool_choice: self.enable_tools.then_some(ToolChoice::Auto),
|
||||
stream: true,
|
||||
reasoning_effort: self.reasoning_effort.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -6805,6 +6971,51 @@ fn collect_prompt_cache_events(summary: &runtime::TurnSummary) -> Vec<serde_json
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Slash commands that are registered in the spec list but not yet implemented
|
||||
/// in this build. Used to filter both REPL completions and help output so the
|
||||
/// discovery surface only shows commands that actually work (ROADMAP #39).
|
||||
const STUB_COMMANDS: &[&str] = &[
|
||||
"login",
|
||||
"logout",
|
||||
"vim",
|
||||
"upgrade",
|
||||
"stats",
|
||||
"share",
|
||||
"feedback",
|
||||
"files",
|
||||
"fast",
|
||||
"exit",
|
||||
"summary",
|
||||
"desktop",
|
||||
"brief",
|
||||
"advisor",
|
||||
"stickers",
|
||||
"insights",
|
||||
"thinkback",
|
||||
"release-notes",
|
||||
"security-review",
|
||||
"keybindings",
|
||||
"privacy-settings",
|
||||
"plan",
|
||||
"review",
|
||||
"tasks",
|
||||
"theme",
|
||||
"voice",
|
||||
"usage",
|
||||
"rename",
|
||||
"copy",
|
||||
"hooks",
|
||||
"context",
|
||||
"color",
|
||||
"effort",
|
||||
"branch",
|
||||
"rewind",
|
||||
"ide",
|
||||
"tag",
|
||||
"output-style",
|
||||
"add-dir",
|
||||
];
|
||||
|
||||
fn slash_command_completion_candidates_with_sessions(
|
||||
model: &str,
|
||||
active_session_id: Option<&str>,
|
||||
@@ -6813,9 +7024,14 @@ fn slash_command_completion_candidates_with_sessions(
|
||||
let mut completions = BTreeSet::new();
|
||||
|
||||
for spec in slash_command_specs() {
|
||||
if STUB_COMMANDS.contains(&spec.name) {
|
||||
continue;
|
||||
}
|
||||
completions.insert(format!("/{}", spec.name));
|
||||
for alias in spec.aliases {
|
||||
completions.insert(format!("/{alias}"));
|
||||
if !STUB_COMMANDS.contains(alias) {
|
||||
completions.insert(format!("/{alias}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7404,7 +7620,12 @@ fn response_to_events(
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn push_prompt_cache_record(client: &AnthropicClient, events: &mut Vec<AssistantEvent>) {
|
||||
fn push_prompt_cache_record(client: &ApiProviderClient, events: &mut Vec<AssistantEvent>) {
|
||||
// `ApiProviderClient::take_last_prompt_cache_record` is a pass-through
|
||||
// to the Anthropic variant and returns `None` for OpenAI-compat /
|
||||
// xAI variants, which do not have a prompt cache. So this helper
|
||||
// remains a no-op on non-Anthropic providers without any extra
|
||||
// branching here.
|
||||
if let Some(record) = client.take_last_prompt_cache_record() {
|
||||
if let Some(event) = prompt_cache_record_to_runtime_event(record) {
|
||||
events.push(AssistantEvent::PromptCache(event));
|
||||
@@ -7699,7 +7920,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
|
||||
)?;
|
||||
writeln!(out)?;
|
||||
writeln!(out, "Interactive slash commands:")?;
|
||||
writeln!(out, "{}", render_slash_command_help())?;
|
||||
writeln!(out, "{}", render_slash_command_help_filtered(STUB_COMMANDS))?;
|
||||
writeln!(out)?;
|
||||
let resume_commands = resume_supported_slash_commands()
|
||||
.into_iter()
|
||||
@@ -7793,7 +8014,7 @@ mod tests {
|
||||
summarize_tool_payload_for_markdown, validate_no_args, write_mcp_server_fixture, CliAction,
|
||||
CliOutputFormat, CliToolExecutor, GitWorkspaceSummary, InternalPromptProgressEvent,
|
||||
InternalPromptProgressState, LiveCli, LocalHelpTopic, PromptHistoryEntry, SlashCommand,
|
||||
StatusUsage, DEFAULT_MODEL, LATEST_SESSION_REFERENCE,
|
||||
StatusUsage, DEFAULT_MODEL, LATEST_SESSION_REFERENCE, STUB_COMMANDS,
|
||||
};
|
||||
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
|
||||
use plugins::{
|
||||
@@ -8117,6 +8338,7 @@ mod tests {
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::DangerFullAccess,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -8280,6 +8502,7 @@ mod tests {
|
||||
permission_mode: PermissionMode::DangerFullAccess,
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -8369,6 +8592,7 @@ mod tests {
|
||||
permission_mode: PermissionMode::DangerFullAccess,
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -8398,6 +8622,7 @@ mod tests {
|
||||
permission_mode: PermissionMode::DangerFullAccess,
|
||||
compact: true,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -8439,6 +8664,7 @@ mod tests {
|
||||
permission_mode: PermissionMode::DangerFullAccess,
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -8516,6 +8742,7 @@ mod tests {
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::ReadOnly,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -8535,6 +8762,7 @@ mod tests {
|
||||
allowed_tools: None,
|
||||
permission_mode: PermissionMode::DangerFullAccess,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -8563,6 +8791,7 @@ mod tests {
|
||||
permission_mode: PermissionMode::DangerFullAccess,
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -8588,6 +8817,7 @@ mod tests {
|
||||
),
|
||||
permission_mode: PermissionMode::DangerFullAccess,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -8697,6 +8927,7 @@ mod tests {
|
||||
permission_mode: crate::default_permission_mode(),
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -9080,6 +9311,7 @@ mod tests {
|
||||
permission_mode: crate::default_permission_mode(),
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -9146,6 +9378,7 @@ mod tests {
|
||||
permission_mode: crate::default_permission_mode(),
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -9171,6 +9404,7 @@ mod tests {
|
||||
permission_mode: crate::default_permission_mode(),
|
||||
compact: false,
|
||||
base_commit: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
);
|
||||
let error = parse_args(&["/status".to_string()])
|
||||
@@ -10926,6 +11160,58 @@ UU conflicted.rs",
|
||||
let _ = fs::remove_dir_all(source_root);
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_reasoning_effort_value() {
|
||||
let err = parse_args(&[
|
||||
"--reasoning-effort".to_string(),
|
||||
"turbo".to_string(),
|
||||
"prompt".to_string(),
|
||||
"hello".to_string(),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.contains("invalid value for --reasoning-effort"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
assert!(err.contains("turbo"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_reasoning_effort_values() {
|
||||
for value in ["low", "medium", "high"] {
|
||||
let result = parse_args(&[
|
||||
"--reasoning-effort".to_string(),
|
||||
value.to_string(),
|
||||
"prompt".to_string(),
|
||||
"hello".to_string(),
|
||||
]);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"--reasoning-effort {value} should be accepted, got: {:?}",
|
||||
result
|
||||
);
|
||||
if let Ok(CliAction::Prompt {
|
||||
reasoning_effort, ..
|
||||
}) = result
|
||||
{
|
||||
assert_eq!(reasoning_effort.as_deref(), Some(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stub_commands_absent_from_repl_completions() {
|
||||
let candidates =
|
||||
slash_command_completion_candidates_with_sessions("claude-3-5-sonnet", None, vec![]);
|
||||
for stub in STUB_COMMANDS {
|
||||
let with_slash = format!("/{stub}");
|
||||
assert!(
|
||||
!candidates.contains(&with_slash),
|
||||
"stub command {with_slash} should not appear in REPL completions"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_mcp_server_fixture(script_path: &Path) {
|
||||
|
||||
@@ -6249,6 +6249,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn web_search_extracts_and_filters_results() {
|
||||
// Serialize env-var mutation so this test cannot race with the sibling
|
||||
// web_search_handles_generic_links_and_invalid_base_url test that also
|
||||
// sets CLAWD_WEB_SEARCH_BASE_URL. Without the lock, parallel test
|
||||
// runners can interleave the set/remove calls and cause assertion
|
||||
// failures on the wrong port.
|
||||
let _guard = env_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let server = TestServer::spawn(Arc::new(|request_line: &str| {
|
||||
assert!(request_line.contains("GET /search?q=rust+web+search "));
|
||||
HttpResponse::html(
|
||||
|
||||
Reference in New Issue
Block a user