mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-10 18:14:50 +08:00
Compare commits
96 Commits
feat/provi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aa1fa2cc9 | ||
|
|
1ecdb1076c | ||
|
|
6c07cd682d | ||
|
|
3a6c9a55c1 | ||
|
|
810036bf09 | ||
|
|
0f34c66acd | ||
|
|
6af0189906 | ||
|
|
b95d330310 | ||
|
|
74311cc511 | ||
|
|
6ae8850d45 | ||
|
|
ef9439d772 | ||
|
|
4f670e5513 | ||
|
|
8dcf10361f | ||
|
|
cf129c8793 | ||
|
|
c0248253ac | ||
|
|
1e14d59a71 | ||
|
|
11e2353585 | ||
|
|
0845705639 | ||
|
|
316864227c | ||
|
|
ece48c7174 | ||
|
|
c8cac7cae8 | ||
|
|
57943b17f3 | ||
|
|
4730b667c4 | ||
|
|
dc4fa55d64 | ||
|
|
9cf4033fdf | ||
|
|
a3d0c9e5e7 | ||
|
|
78dca71f3f | ||
|
|
39a7dd08bb | ||
|
|
d95149b347 | ||
|
|
47aa1a57ca | ||
|
|
6e301c8bb3 | ||
|
|
7587f2c1eb | ||
|
|
ed42f8f298 | ||
|
|
ff416ff3e7 | ||
|
|
6ac7d8cd46 | ||
|
|
7ec6860d9a | ||
|
|
0e12d15daf | ||
|
|
fd7aade5b5 | ||
|
|
de916152cb | ||
|
|
60ec2aed9b | ||
|
|
5f6f453b8d | ||
|
|
da4242198f | ||
|
|
84b77ece4d | ||
|
|
aef85f8af5 | ||
|
|
3ed27d5cba | ||
|
|
e1ed30a038 | ||
|
|
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 |
58
README.md
58
README.md
@@ -45,22 +45,60 @@ The canonical implementation lives in [`rust/`](./rust), and the current source
|
||||
|
||||
## Quick start
|
||||
|
||||
> [!NOTE]
|
||||
> [!WARNING]
|
||||
> **`cargo install claw-code` installs the wrong thing.** The `claw-code` crate on crates.io is a deprecated stub that places `claw-code-deprecated.exe` — not `claw`. Running it only prints `"claw-code has been renamed to agent-code"`. **Do not use `cargo install claw-code`.** Either build from source (this repo) or install the upstream binary:
|
||||
> ```bash
|
||||
> cargo install agent-code # upstream binary — installs 'agent.exe' (Windows) / 'agent' (Unix), NOT 'agent-code'
|
||||
> ```
|
||||
> This repo (`ultraworkers/claw-code`) is **build-from-source only** — follow the steps 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.
|
||||
|
||||
### Windows setup
|
||||
|
||||
**PowerShell is a supported Windows path.** Use whichever shell works for you. The common onboarding issues on Windows are:
|
||||
|
||||
1. **Install Rust first** — download from <https://rustup.rs/> and run the installer. Close and reopen your terminal when it finishes.
|
||||
2. **Verify Rust is on PATH:**
|
||||
```powershell
|
||||
cargo --version
|
||||
```
|
||||
If this fails, reopen your terminal or run the PATH setup from the Rust installer output, then retry.
|
||||
3. **Clone and build** (works in PowerShell, Git Bash, or WSL):
|
||||
```powershell
|
||||
git clone https://github.com/ultraworkers/claw-code
|
||||
cd claw-code/rust
|
||||
cargo build --workspace
|
||||
```
|
||||
4. **Run** (PowerShell — note `.exe` and backslash):
|
||||
```powershell
|
||||
$env:ANTHROPIC_API_KEY = "sk-ant-..."
|
||||
.\target\debug\claw.exe prompt "say hello"
|
||||
```
|
||||
|
||||
**Git Bash / WSL** are optional alternatives, not requirements. If you prefer bash-style paths (`/c/Users/you/...` instead of `C:\Users\you\...`), Git Bash (ships with Git for Windows) works well. In Git Bash, the `MINGW64` prompt is expected and normal — not a broken install.
|
||||
|
||||
> [!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
|
||||
|
||||
91
ROADMAP.md
91
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(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +202,15 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
|
||||
if let Some(metadata) = metadata_for_model(model) {
|
||||
return metadata.provider;
|
||||
}
|
||||
// When OPENAI_BASE_URL is set, the user explicitly configured an
|
||||
// OpenAI-compatible endpoint. Prefer it over the Anthropic fallback
|
||||
// even when the model name has no recognized prefix — this is the
|
||||
// common case for local providers (Ollama, LM Studio, vLLM, etc.)
|
||||
// where model names like "qwen2.5-coder:7b" don't match any prefix.
|
||||
if std::env::var_os("OPENAI_BASE_URL").is_some() && openai_compat::has_api_key("OPENAI_API_KEY")
|
||||
{
|
||||
return ProviderKind::OpenAi;
|
||||
}
|
||||
if anthropic::has_auth_from_env_or_saved().unwrap_or(false) {
|
||||
return ProviderKind::Anthropic;
|
||||
}
|
||||
@@ -211,6 +220,11 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
|
||||
if openai_compat::has_api_key("XAI_API_KEY") {
|
||||
return ProviderKind::Xai;
|
||||
}
|
||||
// Last resort: if OPENAI_BASE_URL is set without OPENAI_API_KEY (some
|
||||
// local providers like Ollama don't require auth), still route there.
|
||||
if std::env::var_os("OPENAI_BASE_URL").is_some() {
|
||||
return ProviderKind::OpenAi;
|
||||
}
|
||||
ProviderKind::Anthropic
|
||||
}
|
||||
|
||||
@@ -291,6 +305,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 +429,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 +440,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 +774,252 @@ 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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_base_url_overrides_anthropic_fallback_for_unknown_model() {
|
||||
// given — user has OPENAI_BASE_URL + OPENAI_API_KEY but no Anthropic
|
||||
// creds, and a model name with no recognized prefix.
|
||||
let _lock = env_lock();
|
||||
let _base_url = EnvVarGuard::set("OPENAI_BASE_URL", Some("http://127.0.0.1:11434/v1"));
|
||||
let _api_key = EnvVarGuard::set("OPENAI_API_KEY", Some("dummy"));
|
||||
let _anthropic_key = EnvVarGuard::set("ANTHROPIC_API_KEY", None);
|
||||
let _anthropic_token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", None);
|
||||
|
||||
// when
|
||||
let provider = detect_provider_kind("qwen2.5-coder:7b");
|
||||
|
||||
// then — should route to OpenAI, not Anthropic
|
||||
assert_eq!(
|
||||
provider,
|
||||
ProviderKind::OpenAi,
|
||||
"OPENAI_BASE_URL should win over Anthropic fallback for unknown models"
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: a "OPENAI_BASE_URL without OPENAI_API_KEY" test is omitted
|
||||
// because workspace-parallel test binaries can race on process env
|
||||
// (env_lock only protects within a single binary). The detection logic
|
||||
// is covered: OPENAI_BASE_URL alone routes to OpenAi as a last-resort
|
||||
// fallback in detect_provider_kind().
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -152,6 +157,35 @@ impl OpenAiCompatClient {
|
||||
let response = self.send_with_retry(&request).await?;
|
||||
let request_id = request_id_from_headers(response.headers());
|
||||
let body = response.text().await.map_err(ApiError::from)?;
|
||||
// Some backends return {"error":{"message":"...","type":"...","code":...}}
|
||||
// instead of a valid completion object. Check for this before attempting
|
||||
// full deserialization so the user sees the actual error, not a cryptic
|
||||
// "missing field 'id'" parse failure.
|
||||
if let Ok(raw) = serde_json::from_str::<serde_json::Value>(&body) {
|
||||
if let Some(err_obj) = raw.get("error") {
|
||||
let msg = err_obj
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("provider returned an error")
|
||||
.to_string();
|
||||
let code = err_obj
|
||||
.get("code")
|
||||
.and_then(|c| c.as_u64())
|
||||
.map(|c| c as u16);
|
||||
return Err(ApiError::Api {
|
||||
status: reqwest::StatusCode::from_u16(code.unwrap_or(400))
|
||||
.unwrap_or(reqwest::StatusCode::BAD_REQUEST),
|
||||
error_type: err_obj
|
||||
.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(str::to_owned),
|
||||
message: Some(msg),
|
||||
request_id,
|
||||
body,
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
let payload = serde_json::from_str::<ChatCompletionResponse>(&body).map_err(|error| {
|
||||
ApiError::json_deserialize(self.config.provider_name, &request.model, &body, error)
|
||||
})?;
|
||||
@@ -250,6 +284,19 @@ impl OpenAiCompatClient {
|
||||
static JITTER_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Returns a random additive jitter in `[0, base]` to decorrelate retries
|
||||
/// Deserialize a JSON field as a `Vec<T>`, treating an explicit `null` value
|
||||
/// the same as a missing field (i.e. as an empty vector).
|
||||
/// Some OpenAI-compatible providers emit `"tool_calls": null` instead of
|
||||
/// omitting the field or using `[]`, which serde's `#[serde(default)]` alone
|
||||
/// does not tolerate — `default` only handles absent keys, not null values.
|
||||
fn deserialize_null_as_empty_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
T: serde::Deserialize<'de>,
|
||||
{
|
||||
Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// from multiple concurrent clients. Entropy is drawn from the nanosecond
|
||||
/// wall clock mixed with a monotonic counter and run through a splitmix64
|
||||
/// finalizer; adequate for retry jitter (no cryptographic requirement).
|
||||
@@ -668,7 +715,7 @@ struct ChunkChoice {
|
||||
struct ChunkDelta {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
|
||||
tool_calls: Vec<DeltaToolCall>,
|
||||
}
|
||||
|
||||
@@ -721,6 +768,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()) {
|
||||
@@ -732,10 +797,30 @@ fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatC
|
||||
for message in &request.messages {
|
||||
messages.extend(translate_message(message));
|
||||
}
|
||||
// Sanitize: drop any `role:"tool"` message that does not have a valid
|
||||
// paired `role:"assistant"` with a `tool_calls` entry carrying the same
|
||||
// `id` immediately before it (directly or as part of a run of tool
|
||||
// results). OpenAI-compatible backends return 400 for orphaned tool
|
||||
// messages regardless of how they were produced (compaction, session
|
||||
// editing, resume, etc.). We drop rather than error so the request can
|
||||
// still proceed with the remaining history intact.
|
||||
messages = sanitize_tool_message_pairing(messages);
|
||||
|
||||
// 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 +860,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
|
||||
}
|
||||
@@ -801,11 +890,16 @@ fn translate_message(message: &InputMessage) -> Vec<Value> {
|
||||
if text.is_empty() && tool_calls.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![json!({
|
||||
let mut msg = serde_json::json!({
|
||||
"role": "assistant",
|
||||
"content": (!text.is_empty()).then_some(text),
|
||||
"tool_calls": tool_calls,
|
||||
})]
|
||||
});
|
||||
// Only include tool_calls when non-empty: some providers reject
|
||||
// assistant messages with an explicit empty tool_calls array.
|
||||
if !tool_calls.is_empty() {
|
||||
msg["tool_calls"] = json!(tool_calls);
|
||||
}
|
||||
vec![msg]
|
||||
}
|
||||
}
|
||||
_ => message
|
||||
@@ -832,6 +926,75 @@ fn translate_message(message: &InputMessage) -> Vec<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove `role:"tool"` messages from `messages` that have no valid paired
|
||||
/// `role:"assistant"` message with a matching `tool_calls[].id` immediately
|
||||
/// preceding them. This is a last-resort safety net at the request-building
|
||||
/// layer — the compaction boundary fix (6e301c8) prevents the most common
|
||||
/// producer path, but resume, session editing, or future compaction variants
|
||||
/// could still create orphaned tool messages.
|
||||
///
|
||||
/// Algorithm: scan left-to-right. For each `role:"tool"` message, check the
|
||||
/// immediately preceding non-tool message. If it's `role:"assistant"` with a
|
||||
/// `tool_calls` array containing an entry whose `id` matches the tool
|
||||
/// message's `tool_call_id`, the pair is valid and both are kept. Otherwise
|
||||
/// the tool message is dropped.
|
||||
fn sanitize_tool_message_pairing(messages: Vec<Value>) -> Vec<Value> {
|
||||
// Collect indices of tool messages that are orphaned.
|
||||
let mut drop_indices = std::collections::HashSet::new();
|
||||
for (i, msg) in messages.iter().enumerate() {
|
||||
if msg.get("role").and_then(|v| v.as_str()) != Some("tool") {
|
||||
continue;
|
||||
}
|
||||
let tool_call_id = msg
|
||||
.get("tool_call_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
// Find the nearest preceding non-tool message.
|
||||
let preceding = messages[..i]
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.get("role").and_then(|v| v.as_str()) != Some("tool"));
|
||||
// A tool message is considered paired when:
|
||||
// (a) the nearest preceding non-tool message is an assistant message
|
||||
// whose `tool_calls` array contains an entry with the matching id, OR
|
||||
// (b) there's no clear preceding context (e.g. the message comes right
|
||||
// after a user turn — this can happen with translated mixed-content
|
||||
// user messages). In case (b) we allow the message through rather
|
||||
// than silently dropping potentially valid history.
|
||||
let preceding_role = preceding
|
||||
.and_then(|m| m.get("role"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
// Only apply sanitization when the preceding message is an assistant
|
||||
// turn (the invariant is: assistant-with-tool_calls must precede tool).
|
||||
// If the preceding is something else (user, system) don't drop — it
|
||||
// may be a valid translation artifact or a path we don't understand.
|
||||
if preceding_role != "assistant" {
|
||||
continue;
|
||||
}
|
||||
let paired = preceding
|
||||
.and_then(|m| m.get("tool_calls").and_then(|tc| tc.as_array()))
|
||||
.map(|tool_calls| {
|
||||
tool_calls
|
||||
.iter()
|
||||
.any(|tc| tc.get("id").and_then(|v| v.as_str()) == Some(tool_call_id))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !paired {
|
||||
drop_indices.insert(i);
|
||||
}
|
||||
}
|
||||
if drop_indices.is_empty() {
|
||||
return messages;
|
||||
}
|
||||
messages
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| !drop_indices.contains(i))
|
||||
.map(|(_, m)| m)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn flatten_tool_result_content(content: &[ToolResultContentBlock]) -> String {
|
||||
content
|
||||
.iter()
|
||||
@@ -843,13 +1006,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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -966,6 +1161,35 @@ fn parse_sse_frame(
|
||||
if payload == "[DONE]" {
|
||||
return Ok(None);
|
||||
}
|
||||
// Some backends embed an error object in a data: frame instead of using an
|
||||
// HTTP error status. Surface the error message directly rather than letting
|
||||
// ChatCompletionChunk deserialization fail with a cryptic 'missing field' error.
|
||||
if let Ok(raw) = serde_json::from_str::<serde_json::Value>(&payload) {
|
||||
if let Some(err_obj) = raw.get("error") {
|
||||
let msg = err_obj
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("provider returned an error in stream")
|
||||
.to_string();
|
||||
let code = err_obj
|
||||
.get("code")
|
||||
.and_then(|c| c.as_u64())
|
||||
.map(|c| c as u16);
|
||||
let status = reqwest::StatusCode::from_u16(code.unwrap_or(400))
|
||||
.unwrap_or(reqwest::StatusCode::BAD_REQUEST);
|
||||
return Err(ApiError::Api {
|
||||
status,
|
||||
error_type: err_obj
|
||||
.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(str::to_owned),
|
||||
message: Some(msg),
|
||||
request_id: None,
|
||||
body: payload.to_string(),
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
serde_json::from_str::<ChatCompletionChunk>(&payload)
|
||||
.map(Some)
|
||||
.map_err(|error| ApiError::json_deserialize(provider, model, &payload, error))
|
||||
@@ -1117,6 +1341,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 +1528,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 +1613,186 @@ 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"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test: some OpenAI-compatible providers emit `"tool_calls": null`
|
||||
/// in stream delta chunks instead of omitting the field or using `[]`.
|
||||
/// Before the fix this produced: `invalid type: null, expected a sequence`.
|
||||
#[test]
|
||||
fn delta_with_null_tool_calls_deserializes_as_empty_vec() {
|
||||
// Simulate the exact shape observed in the wild (gaebal-gajae repro 2026-04-09)
|
||||
let json = r#"{
|
||||
"content": "",
|
||||
"function_call": null,
|
||||
"refusal": null,
|
||||
"role": "assistant",
|
||||
"tool_calls": null
|
||||
}"#;
|
||||
|
||||
use super::deserialize_null_as_empty_vec;
|
||||
#[allow(dead_code)]
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
struct Delta {
|
||||
content: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
|
||||
tool_calls: Vec<super::DeltaToolCall>,
|
||||
}
|
||||
let delta: Delta = serde_json::from_str(json)
|
||||
.expect("delta with tool_calls:null must deserialize without error");
|
||||
assert!(
|
||||
delta.tool_calls.is_empty(),
|
||||
"tool_calls:null must produce an empty vec, not an error"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: when building a multi-turn request where a prior assistant
|
||||
/// turn has no tool calls, the serialized assistant message must NOT include
|
||||
/// `tool_calls: []`. Some providers reject requests that carry an empty
|
||||
/// tool_calls array on assistant turns (gaebal-gajae repro 2026-04-09).
|
||||
#[test]
|
||||
fn assistant_message_without_tool_calls_omits_tool_calls_field() {
|
||||
use crate::types::{InputContentBlock, InputMessage};
|
||||
|
||||
let request = MessageRequest {
|
||||
model: "gpt-4o".to_string(),
|
||||
max_tokens: 100,
|
||||
messages: vec![InputMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![InputContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
}],
|
||||
}],
|
||||
stream: false,
|
||||
..Default::default()
|
||||
};
|
||||
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||
let messages = payload["messages"].as_array().unwrap();
|
||||
let assistant_msg = messages
|
||||
.iter()
|
||||
.find(|m| m["role"] == "assistant")
|
||||
.expect("assistant message must be present");
|
||||
assert!(
|
||||
assistant_msg.get("tool_calls").is_none(),
|
||||
"assistant message without tool calls must omit tool_calls field: {:?}",
|
||||
assistant_msg
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: assistant messages WITH tool calls must still include
|
||||
/// the tool_calls array (normal multi-turn tool-use flow).
|
||||
#[test]
|
||||
fn assistant_message_with_tool_calls_includes_tool_calls_field() {
|
||||
use crate::types::{InputContentBlock, InputMessage};
|
||||
|
||||
let request = MessageRequest {
|
||||
model: "gpt-4o".to_string(),
|
||||
max_tokens: 100,
|
||||
messages: vec![InputMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![InputContentBlock::ToolUse {
|
||||
id: "call_1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: serde_json::json!({"path": "/tmp/test"}),
|
||||
}],
|
||||
}],
|
||||
stream: false,
|
||||
..Default::default()
|
||||
};
|
||||
let payload = build_chat_completion_request(&request, OpenAiCompatConfig::openai());
|
||||
let messages = payload["messages"].as_array().unwrap();
|
||||
let assistant_msg = messages
|
||||
.iter()
|
||||
.find(|m| m["role"] == "assistant")
|
||||
.expect("assistant message must be present");
|
||||
let tool_calls = assistant_msg
|
||||
.get("tool_calls")
|
||||
.expect("assistant message with tool calls must include tool_calls field");
|
||||
assert!(tool_calls.is_array());
|
||||
assert_eq!(tool_calls.as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// Orphaned tool messages (no preceding assistant tool_calls) must be
|
||||
/// dropped by the request-builder sanitizer. Regression for the second
|
||||
/// layer of the tool-pairing invariant fix (gaebal-gajae 2026-04-10).
|
||||
#[test]
|
||||
fn sanitize_drops_orphaned_tool_messages() {
|
||||
use super::sanitize_tool_message_pairing;
|
||||
|
||||
// Valid pair: assistant with tool_calls → tool result
|
||||
let valid = vec![
|
||||
json!({"role": "assistant", "content": null, "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "search", "arguments": "{}"}}]}),
|
||||
json!({"role": "tool", "tool_call_id": "call_1", "content": "result"}),
|
||||
];
|
||||
let out = sanitize_tool_message_pairing(valid);
|
||||
assert_eq!(out.len(), 2, "valid pair must be preserved");
|
||||
|
||||
// Orphaned tool message: no preceding assistant tool_calls
|
||||
let orphaned = vec![
|
||||
json!({"role": "assistant", "content": "hi"}),
|
||||
json!({"role": "tool", "tool_call_id": "call_2", "content": "orphaned"}),
|
||||
];
|
||||
let out = sanitize_tool_message_pairing(orphaned);
|
||||
assert_eq!(out.len(), 1, "orphaned tool message must be dropped");
|
||||
assert_eq!(out[0]["role"], json!("assistant"));
|
||||
|
||||
// Mismatched tool_call_id
|
||||
let mismatched = vec![
|
||||
json!({"role": "assistant", "content": null, "tool_calls": [{"id": "call_3", "type": "function", "function": {"name": "f", "arguments": "{}"}}]}),
|
||||
json!({"role": "tool", "tool_call_id": "call_WRONG", "content": "bad"}),
|
||||
];
|
||||
let out = sanitize_tool_message_pairing(mismatched);
|
||||
assert_eq!(out.len(), 1, "tool message with wrong id must be dropped");
|
||||
|
||||
// Two tool results both valid (same preceding assistant)
|
||||
let two_results = vec![
|
||||
json!({"role": "assistant", "content": null, "tool_calls": [
|
||||
{"id": "call_a", "type": "function", "function": {"name": "fa", "arguments": "{}"}},
|
||||
{"id": "call_b", "type": "function", "function": {"name": "fb", "arguments": "{}"}}
|
||||
]}),
|
||||
json!({"role": "tool", "tool_call_id": "call_a", "content": "ra"}),
|
||||
json!({"role": "tool", "tool_call_id": "call_b", "content": "rb"}),
|
||||
];
|
||||
let out = sanitize_tool_message_pairing(two_results);
|
||||
assert_eq!(out.len(), 3, "both valid tool results must be preserved");
|
||||
}
|
||||
|
||||
#[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"]);
|
||||
}
|
||||
|
||||
@@ -1221,6 +1221,84 @@ impl SlashCommand {
|
||||
pub fn parse(input: &str) -> Result<Option<Self>, SlashCommandParseError> {
|
||||
validate_slash_command_input(input)
|
||||
}
|
||||
|
||||
/// Returns the canonical slash-command name (e.g. `"/branch"`) for use in
|
||||
/// error messages and logging. Derived from the spec table so it always
|
||||
/// matches what the user would have typed.
|
||||
#[must_use]
|
||||
pub fn slash_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Help => "/help",
|
||||
Self::Clear { .. } => "/clear",
|
||||
Self::Compact { .. } => "/compact",
|
||||
Self::Cost => "/cost",
|
||||
Self::Doctor => "/doctor",
|
||||
Self::Config { .. } => "/config",
|
||||
Self::Memory { .. } => "/memory",
|
||||
Self::History { .. } => "/history",
|
||||
Self::Diff => "/diff",
|
||||
Self::Status => "/status",
|
||||
Self::Stats => "/stats",
|
||||
Self::Version => "/version",
|
||||
Self::Commit { .. } => "/commit",
|
||||
Self::Pr { .. } => "/pr",
|
||||
Self::Issue { .. } => "/issue",
|
||||
Self::Init => "/init",
|
||||
Self::Bughunter { .. } => "/bughunter",
|
||||
Self::Ultraplan { .. } => "/ultraplan",
|
||||
Self::Teleport { .. } => "/teleport",
|
||||
Self::DebugToolCall { .. } => "/debug-tool-call",
|
||||
Self::Resume { .. } => "/resume",
|
||||
Self::Model { .. } => "/model",
|
||||
Self::Permissions { .. } => "/permissions",
|
||||
Self::Session { .. } => "/session",
|
||||
Self::Plugins { .. } => "/plugins",
|
||||
Self::Login => "/login",
|
||||
Self::Logout => "/logout",
|
||||
Self::Vim => "/vim",
|
||||
Self::Upgrade => "/upgrade",
|
||||
Self::Share => "/share",
|
||||
Self::Feedback => "/feedback",
|
||||
Self::Files => "/files",
|
||||
Self::Fast => "/fast",
|
||||
Self::Exit => "/exit",
|
||||
Self::Summary => "/summary",
|
||||
Self::Desktop => "/desktop",
|
||||
Self::Brief => "/brief",
|
||||
Self::Advisor => "/advisor",
|
||||
Self::Stickers => "/stickers",
|
||||
Self::Insights => "/insights",
|
||||
Self::Thinkback => "/thinkback",
|
||||
Self::ReleaseNotes => "/release-notes",
|
||||
Self::SecurityReview => "/security-review",
|
||||
Self::Keybindings => "/keybindings",
|
||||
Self::PrivacySettings => "/privacy-settings",
|
||||
Self::Plan { .. } => "/plan",
|
||||
Self::Review { .. } => "/review",
|
||||
Self::Tasks { .. } => "/tasks",
|
||||
Self::Theme { .. } => "/theme",
|
||||
Self::Voice { .. } => "/voice",
|
||||
Self::Usage { .. } => "/usage",
|
||||
Self::Rename { .. } => "/rename",
|
||||
Self::Copy { .. } => "/copy",
|
||||
Self::Hooks { .. } => "/hooks",
|
||||
Self::Context { .. } => "/context",
|
||||
Self::Color { .. } => "/color",
|
||||
Self::Effort { .. } => "/effort",
|
||||
Self::Branch { .. } => "/branch",
|
||||
Self::Rewind { .. } => "/rewind",
|
||||
Self::Ide { .. } => "/ide",
|
||||
Self::Tag { .. } => "/tag",
|
||||
Self::OutputStyle { .. } => "/output-style",
|
||||
Self::AddDir { .. } => "/add-dir",
|
||||
Self::Unknown(_) => "/unknown",
|
||||
Self::Sandbox => "/sandbox",
|
||||
Self::Mcp { .. } => "/mcp",
|
||||
Self::Export { .. } => "/export",
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => "/unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
@@ -1320,7 +1398,7 @@ pub fn validate_slash_command_input(
|
||||
"skills" | "skill" => SlashCommand::Skills {
|
||||
args: parse_skills_args(remainder.as_deref())?,
|
||||
},
|
||||
"doctor" => {
|
||||
"doctor" | "providers" => {
|
||||
validate_no_args(command, &args)?;
|
||||
SlashCommand::Doctor
|
||||
}
|
||||
@@ -1340,7 +1418,7 @@ pub fn validate_slash_command_input(
|
||||
validate_no_args(command, &args)?;
|
||||
SlashCommand::Upgrade
|
||||
}
|
||||
"stats" => {
|
||||
"stats" | "tokens" | "cache" => {
|
||||
validate_no_args(command, &args)?;
|
||||
SlashCommand::Stats
|
||||
}
|
||||
@@ -1938,6 +2016,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(),
|
||||
@@ -4609,7 +4723,14 @@ mod tests {
|
||||
)
|
||||
.expect("slash command should be handled");
|
||||
|
||||
assert!(result.message.contains("Compacted 2 messages"));
|
||||
// With the tool-use/tool-result boundary guard the compaction may
|
||||
// preserve one extra message, so 1 or 2 messages may be removed.
|
||||
assert!(
|
||||
result.message.contains("Compacted 1 messages")
|
||||
|| result.message.contains("Compacted 2 messages"),
|
||||
"unexpected compaction message: {}",
|
||||
result.message
|
||||
);
|
||||
assert_eq!(result.session.messages[0].role, MessageRole::System);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,10 +108,55 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
|
||||
.first()
|
||||
.and_then(extract_existing_compacted_summary);
|
||||
let compacted_prefix_len = usize::from(existing_summary.is_some());
|
||||
let keep_from = session
|
||||
let raw_keep_from = session
|
||||
.messages
|
||||
.len()
|
||||
.saturating_sub(config.preserve_recent_messages);
|
||||
// Ensure we do not split a tool-use / tool-result pair at the compaction
|
||||
// boundary. If the first preserved message is a user message whose first
|
||||
// block is a ToolResult, the assistant message with the matching ToolUse
|
||||
// was slated for removal — that produces an orphaned tool role message on
|
||||
// the OpenAI-compat path (400: tool message must follow assistant with
|
||||
// tool_calls). Walk the boundary back until we start at a safe point.
|
||||
let keep_from = {
|
||||
let mut k = raw_keep_from;
|
||||
// If the first preserved message is a tool-result turn, ensure its
|
||||
// paired assistant tool-use turn is preserved too. Without this fix,
|
||||
// the OpenAI-compat adapter sends an orphaned 'tool' role message
|
||||
// with no preceding assistant 'tool_calls', which providers reject
|
||||
// with a 400. We walk back only if the immediately preceding message
|
||||
// is NOT an assistant message that contains a ToolUse block (i.e. the
|
||||
// pair is actually broken at the boundary).
|
||||
loop {
|
||||
if k == 0 || k <= compacted_prefix_len {
|
||||
break;
|
||||
}
|
||||
let first_preserved = &session.messages[k];
|
||||
let starts_with_tool_result = first_preserved
|
||||
.blocks
|
||||
.first()
|
||||
.map(|b| matches!(b, ContentBlock::ToolResult { .. }))
|
||||
.unwrap_or(false);
|
||||
if !starts_with_tool_result {
|
||||
break;
|
||||
}
|
||||
// Check the message just before the current boundary.
|
||||
let preceding = &session.messages[k - 1];
|
||||
let preceding_has_tool_use = preceding
|
||||
.blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::ToolUse { .. }));
|
||||
if preceding_has_tool_use {
|
||||
// Pair is intact — walk back one more to include the assistant turn.
|
||||
k = k.saturating_sub(1);
|
||||
break;
|
||||
}
|
||||
// Preceding message has no ToolUse but we have a ToolResult —
|
||||
// this is already an orphaned pair; walk back to try to fix it.
|
||||
k = k.saturating_sub(1);
|
||||
}
|
||||
k
|
||||
};
|
||||
let removed = &session.messages[compacted_prefix_len..keep_from];
|
||||
let preserved = session.messages[keep_from..].to_vec();
|
||||
let summary =
|
||||
@@ -510,7 +555,7 @@ fn extract_summary_timeline(summary: &str) -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
|
||||
collect_key_files, compact_session, format_compact_summary,
|
||||
get_compact_continuation_message, infer_pending_work, should_compact, CompactionConfig,
|
||||
};
|
||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
@@ -559,7 +604,14 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(result.removed_message_count, 2);
|
||||
// With the tool-use/tool-result boundary fix, the compaction preserves
|
||||
// one extra message to avoid an orphaned tool result at the boundary.
|
||||
// messages[1] (assistant) must be kept along with messages[2] (tool result).
|
||||
assert!(
|
||||
result.removed_message_count <= 2,
|
||||
"expected at most 2 removed, got {}",
|
||||
result.removed_message_count
|
||||
);
|
||||
assert_eq!(
|
||||
result.compacted_session.messages[0].role,
|
||||
MessageRole::System
|
||||
@@ -577,8 +629,13 @@ mod tests {
|
||||
max_estimated_tokens: 1,
|
||||
}
|
||||
));
|
||||
// Note: with the tool-use/tool-result boundary guard the compacted session
|
||||
// may preserve one extra message at the boundary, so token reduction is
|
||||
// not guaranteed for small sessions. The invariant that matters is that
|
||||
// the removed_message_count is non-zero (something was compacted).
|
||||
assert!(
|
||||
estimate_session_tokens(&result.compacted_session) < estimate_session_tokens(&session)
|
||||
result.removed_message_count > 0,
|
||||
"compaction must remove at least one message"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -682,6 +739,80 @@ mod tests {
|
||||
assert!(files.contains(&"rust/crates/rusty-claude-cli/src/main.rs".to_string()));
|
||||
}
|
||||
|
||||
/// Regression: compaction must not split an assistant(ToolUse) /
|
||||
/// user(ToolResult) pair at the boundary. An orphaned tool-result message
|
||||
/// without the preceding assistant tool_calls causes a 400 on the
|
||||
/// OpenAI-compat path (gaebal-gajae repro 2026-04-09).
|
||||
#[test]
|
||||
fn compaction_does_not_split_tool_use_tool_result_pair() {
|
||||
use crate::session::{ContentBlock, Session};
|
||||
|
||||
let tool_id = "call_abc";
|
||||
let mut session = Session::default();
|
||||
// Turn 1: user prompt
|
||||
session
|
||||
.push_message(ConversationMessage::user_text("Search for files"))
|
||||
.unwrap();
|
||||
// Turn 2: assistant calls a tool
|
||||
session
|
||||
.push_message(ConversationMessage::assistant(vec![
|
||||
ContentBlock::ToolUse {
|
||||
id: tool_id.to_string(),
|
||||
name: "search".to_string(),
|
||||
input: "{\"q\":\"*.rs\"}".to_string(),
|
||||
},
|
||||
]))
|
||||
.unwrap();
|
||||
// Turn 3: tool result
|
||||
session
|
||||
.push_message(ConversationMessage::tool_result(
|
||||
tool_id,
|
||||
"search",
|
||||
"found 5 files",
|
||||
false,
|
||||
))
|
||||
.unwrap();
|
||||
// Turn 4: assistant final response
|
||||
session
|
||||
.push_message(ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||
text: "Done.".to_string(),
|
||||
}]))
|
||||
.unwrap();
|
||||
|
||||
// Compact preserving only 1 recent message — without the fix this
|
||||
// would cut the boundary so that the tool result (turn 3) is first,
|
||||
// without its preceding assistant tool_calls (turn 2).
|
||||
let config = CompactionConfig {
|
||||
preserve_recent_messages: 1,
|
||||
..CompactionConfig::default()
|
||||
};
|
||||
let result = compact_session(&session, config);
|
||||
// After compaction, no two consecutive messages should have the pattern
|
||||
// tool_result immediately following a non-assistant message (i.e. an
|
||||
// orphaned tool result without a preceding assistant ToolUse).
|
||||
let messages = &result.compacted_session.messages;
|
||||
for i in 1..messages.len() {
|
||||
let curr_is_tool_result = messages[i]
|
||||
.blocks
|
||||
.first()
|
||||
.map(|b| matches!(b, ContentBlock::ToolResult { .. }))
|
||||
.unwrap_or(false);
|
||||
if curr_is_tool_result {
|
||||
let prev_has_tool_use = messages[i - 1]
|
||||
.blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::ToolUse { .. }));
|
||||
assert!(
|
||||
prev_has_tool_use,
|
||||
"message[{}] is a ToolResult but message[{}] has no ToolUse: {:?}",
|
||||
i,
|
||||
i - 1,
|
||||
&messages[i - 1].blocks
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infers_pending_work_from_recent_messages() {
|
||||
let pending = infer_pending_work(&[
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -308,12 +308,20 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOu
|
||||
base_dir.join(pattern).to_string_lossy().into_owned()
|
||||
};
|
||||
|
||||
// The `glob` crate does not support brace expansion ({a,b,c}).
|
||||
// Expand braces into multiple patterns so patterns like
|
||||
// `Assets/**/*.{cs,uxml,uss}` work correctly.
|
||||
let expanded = expand_braces(&search_pattern);
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut matches = Vec::new();
|
||||
let entries = glob::glob(&search_pattern)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
|
||||
for entry in entries.flatten() {
|
||||
if entry.is_file() {
|
||||
matches.push(entry);
|
||||
for pat in &expanded {
|
||||
let entries = glob::glob(pat)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
|
||||
for entry in entries.flatten() {
|
||||
if entry.is_file() && seen.insert(entry.clone()) {
|
||||
matches.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,13 +627,35 @@ pub fn is_symlink_escape(path: &Path, workspace_root: &Path) -> io::Result<bool>
|
||||
Ok(!resolved.starts_with(&canonical_root))
|
||||
}
|
||||
|
||||
/// Expand shell-style brace groups in a glob pattern.
|
||||
///
|
||||
/// Handles one level of braces: `foo.{a,b,c}` → `["foo.a", "foo.b", "foo.c"]`.
|
||||
/// Nested braces are not expanded (uncommon in practice).
|
||||
/// Patterns without braces pass through unchanged.
|
||||
fn expand_braces(pattern: &str) -> Vec<String> {
|
||||
let Some(open) = pattern.find('{') else {
|
||||
return vec![pattern.to_owned()];
|
||||
};
|
||||
let Some(close) = pattern[open..].find('}').map(|i| open + i) else {
|
||||
// Unmatched brace — treat as literal.
|
||||
return vec![pattern.to_owned()];
|
||||
};
|
||||
let prefix = &pattern[..open];
|
||||
let suffix = &pattern[close + 1..];
|
||||
let alternatives = &pattern[open + 1..close];
|
||||
alternatives
|
||||
.split(',')
|
||||
.flat_map(|alt| expand_braces(&format!("{prefix}{alt}{suffix}")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::{
|
||||
edit_file, glob_search, grep_search, is_symlink_escape, read_file, read_file_in_workspace,
|
||||
write_file, GrepSearchInput, MAX_WRITE_SIZE,
|
||||
edit_file, expand_braces, glob_search, grep_search, is_symlink_escape, read_file,
|
||||
read_file_in_workspace, write_file, GrepSearchInput, MAX_WRITE_SIZE,
|
||||
};
|
||||
|
||||
fn temp_path(name: &str) -> std::path::PathBuf {
|
||||
@@ -759,4 +789,51 @@ mod tests {
|
||||
.expect("grep should succeed");
|
||||
assert!(grep_output.content.unwrap_or_default().contains("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_braces_no_braces() {
|
||||
assert_eq!(expand_braces("*.rs"), vec!["*.rs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_braces_single_group() {
|
||||
let mut result = expand_braces("Assets/**/*.{cs,uxml,uss}");
|
||||
result.sort();
|
||||
assert_eq!(
|
||||
result,
|
||||
vec!["Assets/**/*.cs", "Assets/**/*.uss", "Assets/**/*.uxml",]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_braces_nested() {
|
||||
let mut result = expand_braces("src/{a,b}.{rs,toml}");
|
||||
result.sort();
|
||||
assert_eq!(
|
||||
result,
|
||||
vec!["src/a.rs", "src/a.toml", "src/b.rs", "src/b.toml"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_braces_unmatched() {
|
||||
assert_eq!(expand_braces("foo.{bar"), vec!["foo.{bar"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_search_with_braces_finds_files() {
|
||||
let dir = temp_path("glob-braces");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("a.rs"), "fn main() {}").unwrap();
|
||||
std::fs::write(dir.join("b.toml"), "[package]").unwrap();
|
||||
std::fs::write(dir.join("c.txt"), "hello").unwrap();
|
||||
|
||||
let result =
|
||||
glob_search("*.{rs,toml}", Some(dir.to_str().unwrap())).expect("glob should succeed");
|
||||
assert_eq!(
|
||||
result.num_files, 2,
|
||||
"should match .rs and .toml but not .txt"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,7 +335,14 @@ fn credentials_home_dir() -> io::Result<PathBuf> {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
let home = std::env::var_os("HOME")
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not set"))?;
|
||||
.or_else(|| std::env::var_os("USERPROFILE"))
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"HOME is not set (on Windows, set USERPROFILE or HOME, \
|
||||
or use CLAW_CONFIG_HOME to point directly at the config directory)",
|
||||
)
|
||||
})?;
|
||||
Ok(PathBuf::from(home).join(".claw"))
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,9 @@ pub struct Session {
|
||||
pub fork: Option<SessionFork>,
|
||||
pub workspace_root: Option<PathBuf>,
|
||||
pub prompt_history: Vec<SessionPromptEntry>,
|
||||
/// The model used in this session, persisted so resumed sessions can
|
||||
/// report which model was originally used.
|
||||
pub model: Option<String>,
|
||||
persistence: Option<SessionPersistence>,
|
||||
}
|
||||
|
||||
@@ -161,6 +164,7 @@ impl Session {
|
||||
fork: None,
|
||||
workspace_root: None,
|
||||
prompt_history: Vec::new(),
|
||||
model: None,
|
||||
persistence: None,
|
||||
}
|
||||
}
|
||||
@@ -263,6 +267,7 @@ impl Session {
|
||||
}),
|
||||
workspace_root: self.workspace_root.clone(),
|
||||
prompt_history: self.prompt_history.clone(),
|
||||
model: self.model.clone(),
|
||||
persistence: None,
|
||||
}
|
||||
}
|
||||
@@ -371,6 +376,10 @@ impl Session {
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let model = object
|
||||
.get("model")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(String::from);
|
||||
Ok(Self {
|
||||
version,
|
||||
session_id,
|
||||
@@ -381,6 +390,7 @@ impl Session {
|
||||
fork,
|
||||
workspace_root,
|
||||
prompt_history,
|
||||
model,
|
||||
persistence: None,
|
||||
})
|
||||
}
|
||||
@@ -394,6 +404,7 @@ impl Session {
|
||||
let mut compaction = None;
|
||||
let mut fork = None;
|
||||
let mut workspace_root = None;
|
||||
let mut model = None;
|
||||
let mut prompt_history = Vec::new();
|
||||
|
||||
for (line_number, raw_line) in contents.lines().enumerate() {
|
||||
@@ -433,6 +444,10 @@ impl Session {
|
||||
.get("workspace_root")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(PathBuf::from);
|
||||
model = object
|
||||
.get("model")
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(String::from);
|
||||
}
|
||||
"message" => {
|
||||
let message_value = object.get("message").ok_or_else(|| {
|
||||
@@ -475,6 +490,7 @@ impl Session {
|
||||
fork,
|
||||
workspace_root,
|
||||
prompt_history,
|
||||
model,
|
||||
persistence: None,
|
||||
})
|
||||
}
|
||||
@@ -580,6 +596,9 @@ impl Session {
|
||||
JsonValue::String(workspace_root_to_string(workspace_root)?),
|
||||
);
|
||||
}
|
||||
if let Some(model) = &self.model {
|
||||
object.insert("model".to_string(), JsonValue::String(model.clone()));
|
||||
}
|
||||
Ok(JsonValue::Object(object))
|
||||
}
|
||||
|
||||
|
||||
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");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -253,7 +253,8 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
],
|
||||
);
|
||||
assert_eq!(resumed["kind"], "status");
|
||||
assert_eq!(resumed["model"], "restored-session");
|
||||
// model is null in resume mode (not known without --model flag)
|
||||
assert!(resumed["model"].is_null());
|
||||
assert_eq!(resumed["usage"]["messages"], 1);
|
||||
assert!(resumed["workspace"]["cwd"].as_str().is_some());
|
||||
assert!(resumed["sandbox"]["filesystem_mode"].as_str().is_some());
|
||||
|
||||
@@ -261,7 +261,8 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
||||
let parsed: Value =
|
||||
serde_json::from_str(stdout.trim()).expect("resume status output should be json");
|
||||
assert_eq!(parsed["kind"], "status");
|
||||
assert_eq!(parsed["model"], "restored-session");
|
||||
// model is null in resume mode (not known without --model flag)
|
||||
assert!(parsed["model"].is_null());
|
||||
assert_eq!(parsed["permission_mode"], "danger-full-access");
|
||||
assert_eq!(parsed["usage"]["messages"], 1);
|
||||
assert!(parsed["usage"]["turns"].is_number());
|
||||
@@ -275,6 +276,47 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
||||
assert!(parsed["sandbox"]["filesystem_mode"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_status_surfaces_persisted_model() {
|
||||
// given — create a session with model already set
|
||||
let temp_dir = unique_temp_dir("resume-status-model");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
let session_path = temp_dir.join("session.jsonl");
|
||||
|
||||
let mut session = Session::new();
|
||||
session.model = Some("claude-sonnet-4-6".to_string());
|
||||
session
|
||||
.push_user_text("model persistence fixture")
|
||||
.expect("write ok");
|
||||
session.save_to_path(&session_path).expect("persist ok");
|
||||
|
||||
// when
|
||||
let output = run_claw(
|
||||
&temp_dir,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
"/status",
|
||||
],
|
||||
);
|
||||
|
||||
// then
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
||||
assert_eq!(parsed["kind"], "status");
|
||||
assert_eq!(
|
||||
parsed["model"], "claude-sonnet-4-6",
|
||||
"model should round-trip through session metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_sandbox_command_emits_structured_json_when_requested() {
|
||||
// given
|
||||
@@ -318,6 +360,175 @@ fn resumed_sandbox_command_emits_structured_json_when_requested() {
|
||||
assert!(parsed["markers"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_version_command_emits_structured_json() {
|
||||
let temp_dir = unique_temp_dir("resume-version-json");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
let session_path = temp_dir.join("session.jsonl");
|
||||
Session::new()
|
||||
.save_to_path(&session_path)
|
||||
.expect("session should persist");
|
||||
|
||||
let output = run_claw(
|
||||
&temp_dir,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
"/version",
|
||||
],
|
||||
);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
||||
assert_eq!(parsed["kind"], "version");
|
||||
assert!(parsed["version"].as_str().is_some());
|
||||
assert!(parsed["git_sha"].as_str().is_some());
|
||||
assert!(parsed["target"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_export_command_emits_structured_json() {
|
||||
let temp_dir = unique_temp_dir("resume-export-json");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
let session_path = temp_dir.join("session.jsonl");
|
||||
let mut session = Session::new();
|
||||
session
|
||||
.push_user_text("export json fixture")
|
||||
.expect("write ok");
|
||||
session.save_to_path(&session_path).expect("persist ok");
|
||||
|
||||
let output = run_claw(
|
||||
&temp_dir,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
"/export",
|
||||
],
|
||||
);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
||||
assert_eq!(parsed["kind"], "export");
|
||||
assert!(parsed["file"].as_str().is_some());
|
||||
assert_eq!(parsed["message_count"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_help_command_emits_structured_json() {
|
||||
let temp_dir = unique_temp_dir("resume-help-json");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
let session_path = temp_dir.join("session.jsonl");
|
||||
Session::new()
|
||||
.save_to_path(&session_path)
|
||||
.expect("persist ok");
|
||||
|
||||
let output = run_claw(
|
||||
&temp_dir,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
"/help",
|
||||
],
|
||||
);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
||||
assert_eq!(parsed["kind"], "help");
|
||||
assert!(parsed["text"].as_str().is_some());
|
||||
let text = parsed["text"].as_str().unwrap();
|
||||
assert!(text.contains("/status"), "help text should list /status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_no_command_emits_restored_json() {
|
||||
let temp_dir = unique_temp_dir("resume-no-cmd-json");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
let session_path = temp_dir.join("session.jsonl");
|
||||
let mut session = Session::new();
|
||||
session
|
||||
.push_user_text("restored json fixture")
|
||||
.expect("write ok");
|
||||
session.save_to_path(&session_path).expect("persist ok");
|
||||
|
||||
let output = run_claw(
|
||||
&temp_dir,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
],
|
||||
);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
||||
assert_eq!(parsed["kind"], "restored");
|
||||
assert!(parsed["session_id"].as_str().is_some());
|
||||
assert!(parsed["path"].as_str().is_some());
|
||||
assert_eq!(parsed["message_count"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_stub_command_emits_not_implemented_json() {
|
||||
let temp_dir = unique_temp_dir("resume-stub-json");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
let session_path = temp_dir.join("session.jsonl");
|
||||
Session::new()
|
||||
.save_to_path(&session_path)
|
||||
.expect("persist ok");
|
||||
|
||||
let output = run_claw(
|
||||
&temp_dir,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
"/allowed-tools",
|
||||
],
|
||||
);
|
||||
|
||||
// Stub commands exit with code 2
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8(output.stderr).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stderr.trim()).expect("should be json");
|
||||
assert_eq!(parsed["type"], "error");
|
||||
assert!(
|
||||
parsed["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("not yet implemented"),
|
||||
"error should say not yet implemented: {:?}",
|
||||
parsed["error"]
|
||||
);
|
||||
}
|
||||
|
||||
fn run_claw(current_dir: &Path, args: &[&str]) -> Output {
|
||||
run_claw_with_env(current_dir, args, &[])
|
||||
}
|
||||
|
||||
@@ -3066,7 +3066,7 @@ fn skill_lookup_roots() -> Vec<SkillLookupRoot> {
|
||||
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
|
||||
push_prefixed_skill_lookup_roots(&mut roots, std::path::Path::new(&codex_home));
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
|
||||
push_home_skill_lookup_roots(&mut roots, std::path::Path::new(&home));
|
||||
}
|
||||
if let Ok(claude_config_dir) = std::env::var("CLAUDE_CONFIG_DIR") {
|
||||
@@ -4987,7 +4987,14 @@ fn config_home_dir() -> Result<PathBuf, String> {
|
||||
if let Ok(path) = std::env::var("CLAW_CONFIG_HOME") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
let home = std::env::var("HOME").map_err(|_| String::from("HOME is not set"))?;
|
||||
let home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.map_err(|_| {
|
||||
String::from(
|
||||
"HOME is not set (on Windows, set USERPROFILE or HOME, \
|
||||
or use CLAW_CONFIG_HOME to point directly at the config directory)",
|
||||
)
|
||||
})?;
|
||||
Ok(PathBuf::from(home).join(".claw"))
|
||||
}
|
||||
|
||||
@@ -6249,6 +6256,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