mirror of
https://github.com/instructkr/claw-code.git
synced 2026-11-15 00:01:11 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
508b4d862d | ||
|
|
f51a33a896 | ||
|
|
6a29e08883 | ||
|
|
7041894439 | ||
|
|
333298ae53 | ||
|
|
fe2b64ae9e |
No files matched your search
@@ -29,7 +29,7 @@ jobs:
|
||||
bin: claw
|
||||
artifact_name: claw-linux-x64
|
||||
- name: macos-arm64
|
||||
os: macos-14
|
||||
os: macos-15
|
||||
bin: claw
|
||||
artifact_name: claw-macos-arm64
|
||||
- name: windows-x64
|
||||
|
||||
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.13] - 2026-11-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Anthropic and OpenAI-compatible providers now retry HTTP 529 (overloaded) responses and transient gateway HTTP 400 responses (empty or unparseable body, connection reset, broken pipe, empty reply) with the existing bounded backoff, instead of failing the request immediately (#3381)
|
||||
|
||||
## [0.1.12] - 2026-11-08
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -191,6 +191,51 @@ curl -sS "https://api.uberllm.dev/v1/models" \
|
||||
| jq -r '.data[] | "\(.id)\t\(.max_output_tokens)"'
|
||||
```
|
||||
|
||||
## Standard Compute
|
||||
|
||||
[Standard Compute](https://standardcompute.com) is an OpenAI-compatible gateway. Set `OPENAI_BASE_URL` to `https://api.stdcmpt.com/v1` and `OPENAI_API_KEY` to your Standard Compute key.
|
||||
|
||||
```bash
|
||||
export STANDARDCOMPUTE_API_KEY="your-standardcompute-api-key"
|
||||
export OPENAI_BASE_URL="https://api.stdcmpt.com/v1"
|
||||
export OPENAI_API_KEY="$STANDARDCOMPUTE_API_KEY"
|
||||
claw --model "local/StandardCompute" prompt "Reply exactly HELLO_WORLD_123"
|
||||
```
|
||||
|
||||
`StandardCompute` is the routed model: the router picks from the models enabled on your account using your quality preference, so harder tasks go to stronger models and simpler ones to cheaper models ([routing docs](https://docs.standardcompute.com/models#smart-routing)). It is a bare name on a remote gateway, so it needs the `local/` hatch, which selects the OpenAI-compatible transport and is removed before the request.
|
||||
|
||||
To pin one model, use a vendor-prefixed id from the catalog with the same hatch, for example `--model "local/deepseek/deepseek-v4-flash"`. A bare vendor prefix can pick a native route instead of the gateway.
|
||||
|
||||
The public model list does not need a key:
|
||||
|
||||
```bash
|
||||
curl -sS "https://api.stdcmpt.com/v1/models" | jq -r '.data[].id'
|
||||
```
|
||||
|
||||
See the [Standard Compute docs](https://docs.standardcompute.com) for keys and account setup.
|
||||
|
||||
## HopBase
|
||||
|
||||
[HopBase](https://hop-base.com) is an OpenAI-compatible API for Claude, GPT, GLM, DeepSeek, Qwen, and other model families. Set `OPENAI_BASE_URL` to `https://api.hop-base.com/v1` and `OPENAI_API_KEY` to your HopBase key.
|
||||
|
||||
```bash
|
||||
export HOPBASE_API_KEY="your-hopbase-api-key"
|
||||
export OPENAI_BASE_URL="https://api.hop-base.com/v1"
|
||||
export OPENAI_API_KEY="$HOPBASE_API_KEY"
|
||||
claw --model "local/glm-5.3-flash" prompt "Reply exactly HELLO_WORLD_123"
|
||||
```
|
||||
|
||||
HopBase ids are bare names without a vendor prefix (`glm-5.3-flash`, `gpt-5.6-luna`, `claude-sonnet-5-5`). Put `local/` in front of every one: the hatch selects the OpenAI-compatible transport and is removed, so the id goes out unchanged. Without it, a bare `claude-` id picks the native Anthropic route, and a bare `qwen-` or `kimi-` id goes to DashScope.
|
||||
|
||||
The model list needs your key and shows the ids that key can call:
|
||||
|
||||
```bash
|
||||
curl -sS "https://api.hop-base.com/v1/models" \
|
||||
-H "Authorization: Bearer $OPENAI_API_KEY" | jq -r '.data[].id'
|
||||
```
|
||||
|
||||
See the [HopBase docs](https://hop-base.com/en/docs) for keys and account setup.
|
||||
|
||||
## Local skills install from disk
|
||||
|
||||
Skills are discovered from Claw skill roots such as `.claw/skills/` in a workspace and `~/.claw/skills/` for user-level installs. Legacy `.codex/skills/` roots may also be scanned for compatibility, but new local Claw projects should prefer `.claw/skills/`.
|
||||
|
||||
Generated
+11
-11
@@ -93,7 +93,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "api"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"reqwest",
|
||||
@@ -478,7 +478,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "claw-analog"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"api",
|
||||
"clap",
|
||||
@@ -498,7 +498,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "claw-rag-service"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"blake3",
|
||||
@@ -531,7 +531,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "commands"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"plugins",
|
||||
"runtime",
|
||||
@@ -540,7 +540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "compat-harness"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"commands",
|
||||
"runtime",
|
||||
@@ -1568,7 +1568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mock-anthropic-service"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"api",
|
||||
"serde_json",
|
||||
@@ -1761,7 +1761,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "plugins"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2136,7 +2136,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "runtime"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"glob",
|
||||
@@ -2263,7 +2263,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "rusty-claude-cli"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"api",
|
||||
"commands",
|
||||
@@ -2624,7 +2624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "telemetry"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2904,7 +2904,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tools"
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
dependencies = [
|
||||
"api",
|
||||
"aspect-core",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.12"
|
||||
version = "0.1.13"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
@@ -889,7 +889,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
let request_id = request_id_from_headers(&headers).map(String::into_boxed_str);
|
||||
let body = response.text().await.unwrap_or_else(|_| String::new());
|
||||
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
|
||||
let retryable = is_retryable_status(status);
|
||||
let retryable = is_retryable_status(status) || is_retryable_400(status, &body);
|
||||
let retry_after = parse_retry_after(&headers, status).map(Box::new);
|
||||
|
||||
Err(ApiError::Api {
|
||||
@@ -923,7 +923,10 @@ fn parse_retry_after(
|
||||
}
|
||||
|
||||
const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
|
||||
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
|
||||
matches!(
|
||||
status.as_u16(),
|
||||
408 | 409 | 429 | 500 | 502 | 503 | 504 | 529
|
||||
)
|
||||
}
|
||||
|
||||
/// Some providers return HTTP 400 with an unparseable body when a gateway
|
||||
@@ -1146,6 +1149,56 @@ mod tests {
|
||||
format!("http://{address}/oauth/token")
|
||||
}
|
||||
|
||||
fn spawn_status_server(status: u16, body: &'static str) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||
let address = listener.local_addr().expect("local addr");
|
||||
thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("accept connection");
|
||||
let mut buffer = [0_u8; 4096];
|
||||
let _ = stream.read(&mut buffer).expect("read request");
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} STATUS\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.expect("write response");
|
||||
});
|
||||
format!("http://{address}/v1/messages")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expect_success_marks_529_overloaded_retryable() {
|
||||
let url = spawn_status_server(
|
||||
529,
|
||||
r#"{"error":{"type":"overloaded_error","message":"Overloaded"}}"#,
|
||||
);
|
||||
let response = reqwest::get(&url).await.expect("response");
|
||||
let error = super::expect_success(response)
|
||||
.await
|
||||
.expect_err("529 should error");
|
||||
assert!(error.is_retryable(), "529 overloaded must be retryable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expect_success_marks_gateway_flake_400_retryable() {
|
||||
let url = spawn_status_server(400, "HTTP 400 from backend (no parseable body)");
|
||||
let response = reqwest::get(&url).await.expect("response");
|
||||
let error = super::expect_success(response)
|
||||
.await
|
||||
.expect_err("400 should error");
|
||||
assert!(
|
||||
error.is_retryable(),
|
||||
"gateway-flake 400 body must be retryable"
|
||||
);
|
||||
let url = spawn_status_server(400, "invalid request: max_tokens must be positive");
|
||||
let response = reqwest::get(&url).await.expect("response");
|
||||
let error = super::expect_success(response)
|
||||
.await
|
||||
.expect_err("400 should error");
|
||||
assert!(!error.is_retryable(), "a real 400 must not be retried");
|
||||
}
|
||||
#[test]
|
||||
fn read_api_key_requires_presence() {
|
||||
let _guard = env_lock();
|
||||
@@ -1445,6 +1498,40 @@ mod tests {
|
||||
assert!(!super::is_retryable_status(
|
||||
reqwest::StatusCode::UNAUTHORIZED
|
||||
));
|
||||
// Anthropic returns the non-standard 529 when the API is overloaded;
|
||||
// it is transient and must be retried like a 503.
|
||||
assert!(super::is_retryable_status(
|
||||
reqwest::StatusCode::from_u16(529).expect("529 is a valid status")
|
||||
));
|
||||
assert!(!super::is_retryable_status(
|
||||
reqwest::StatusCode::BAD_REQUEST
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_flake_400_bodies_are_retryable() {
|
||||
// Gateways and proxies surface transient network failures as HTTP 400
|
||||
// with an unparseable body; is_retryable_400 detects them so
|
||||
// expect_success marks the resulting error retryable.
|
||||
for body in [
|
||||
"HTTP 400 from backend (no parseable body)",
|
||||
"upstream connection reset by peer",
|
||||
"Broken pipe while reading response",
|
||||
"Empty reply from server",
|
||||
] {
|
||||
assert!(
|
||||
super::is_retryable_400(reqwest::StatusCode::BAD_REQUEST, body),
|
||||
"body should be retryable: {body}"
|
||||
);
|
||||
}
|
||||
assert!(!super::is_retryable_400(
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"invalid request: max_tokens must be positive"
|
||||
));
|
||||
assert!(!super::is_retryable_400(
|
||||
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"connection reset"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1763,7 +1763,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
let request_id = request_id_from_headers(&headers).map(String::into_boxed_str);
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let parsed_error = serde_json::from_str::<ErrorEnvelope>(&body).ok();
|
||||
let retryable = is_retryable_status(status);
|
||||
let retryable = is_retryable_status(status) || is_retryable_400(status, &body);
|
||||
let retry_after = parse_retry_after(&headers, status).map(Box::new);
|
||||
|
||||
let suggested_action = suggested_action_for_status(status);
|
||||
@@ -1799,7 +1799,10 @@ fn parse_retry_after(
|
||||
}
|
||||
|
||||
const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
|
||||
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
|
||||
matches!(
|
||||
status.as_u16(),
|
||||
408 | 409 | 429 | 500 | 502 | 503 | 504 | 529
|
||||
)
|
||||
}
|
||||
|
||||
/// Some providers return HTTP 400 with an unparseable body when a gateway
|
||||
@@ -1858,9 +1861,9 @@ impl StringExt for String {
|
||||
mod tests {
|
||||
use super::{
|
||||
build_chat_completion_request, chat_completions_endpoint, is_reasoning_model,
|
||||
model_requires_reasoning_content_in_history, normalize_finish_reason, normalize_response,
|
||||
openai_tool_choice, parse_tool_arguments, OpenAiCompatClient, OpenAiCompatConfig,
|
||||
StreamState,
|
||||
is_retryable_400, is_retryable_status, model_requires_reasoning_content_in_history,
|
||||
normalize_finish_reason, normalize_response, openai_tool_choice, parse_tool_arguments,
|
||||
OpenAiCompatClient, OpenAiCompatConfig, StreamState,
|
||||
};
|
||||
use crate::error::ApiError;
|
||||
use crate::test_env_lock as env_lock;
|
||||
@@ -1872,6 +1875,97 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
fn spawn_status_server(status: u16, body: &'static str) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||
let address = listener.local_addr().expect("local addr");
|
||||
thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("accept connection");
|
||||
let mut buffer = [0_u8; 4096];
|
||||
let _ = stream.read(&mut buffer).expect("read request");
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} STATUS\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.expect("write response");
|
||||
});
|
||||
format!("http://{address}/v1/messages")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expect_success_marks_529_overloaded_retryable() {
|
||||
let url = spawn_status_server(
|
||||
529,
|
||||
r#"{"error":{"type":"overloaded_error","message":"Overloaded"}}"#,
|
||||
);
|
||||
let response = reqwest::get(&url).await.expect("response");
|
||||
let error = super::expect_success(response)
|
||||
.await
|
||||
.expect_err("529 should error");
|
||||
assert!(error.is_retryable(), "529 overloaded must be retryable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expect_success_marks_gateway_flake_400_retryable() {
|
||||
let url = spawn_status_server(400, "HTTP 400 from backend (no parseable body)");
|
||||
let response = reqwest::get(&url).await.expect("response");
|
||||
let error = super::expect_success(response)
|
||||
.await
|
||||
.expect_err("400 should error");
|
||||
assert!(
|
||||
error.is_retryable(),
|
||||
"gateway-flake 400 body must be retryable"
|
||||
);
|
||||
let url = spawn_status_server(400, "invalid request: max_tokens must be positive");
|
||||
let response = reqwest::get(&url).await.expect("response");
|
||||
let error = super::expect_success(response)
|
||||
.await
|
||||
.expect_err("400 should error");
|
||||
assert!(!error.is_retryable(), "a real 400 must not be retried");
|
||||
}
|
||||
#[test]
|
||||
fn retryable_statuses_are_detected() {
|
||||
assert!(is_retryable_status(reqwest::StatusCode::TOO_MANY_REQUESTS));
|
||||
assert!(is_retryable_status(
|
||||
reqwest::StatusCode::INTERNAL_SERVER_ERROR
|
||||
));
|
||||
assert!(!is_retryable_status(reqwest::StatusCode::UNAUTHORIZED));
|
||||
// Several OpenAI-compatible gateways (and Anthropic) signal overload
|
||||
// with the non-standard 529; it is transient and must be retried.
|
||||
assert!(is_retryable_status(
|
||||
reqwest::StatusCode::from_u16(529).expect("529 is a valid status")
|
||||
));
|
||||
assert!(!is_retryable_status(reqwest::StatusCode::BAD_REQUEST));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_flake_400_bodies_are_retryable() {
|
||||
for body in [
|
||||
"HTTP 400 from backend (no parseable body)",
|
||||
"upstream connection reset by peer",
|
||||
"Broken pipe while reading response",
|
||||
"Empty reply from server",
|
||||
] {
|
||||
assert!(
|
||||
is_retryable_400(reqwest::StatusCode::BAD_REQUEST, body),
|
||||
"body should be retryable: {body}"
|
||||
);
|
||||
}
|
||||
assert!(!is_retryable_400(
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"invalid request: max_tokens must be positive"
|
||||
));
|
||||
assert!(!is_retryable_400(
|
||||
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"connection reset"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_translation_uses_openai_compatible_shape() {
|
||||
|
||||
@@ -82,7 +82,7 @@ async fn send_message_posts_json_and_parses_response() {
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("user-agent").map(String::as_str),
|
||||
Some("claude-code/0.1.12")
|
||||
Some("claude-code/0.1.13")
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("anthropic-beta").map(String::as_str),
|
||||
|
||||
Reference in new issue
Block a user