Compare commits

...
Author SHA1 Message Date
Victor He 508b4d862d docs: document HopBase OpenAI-compatible gateway setup (#3384) 2026-11-10 14:59:17 +09:00
henrikdukefossandClaude Opus 5.5 f51a33a896 docs: document Standard Compute OpenAI-compatible gateway setup (#3383)
* docs: document Standard Compute OpenAI-compatible gateway setup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: align Standard Compute routing sentence with its docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:02:36 +00:00
Bellman 6a29e08883 Merge pull request #3382 from ultraworkers/release/v0.1.13
release: v0.1.13
2026-11-09 12:07:40 +09:00
clawdbot 7041894439 release: v0.1.13 2026-11-09 03:01:44 +00:00
generaltony007andgeneraltony007 333298ae53 fix(api): retry 529 overloaded and gateway-flake 400 responses (#3381)
* fix(api): retry 529 + gateway-400 in anthropic provider

Co-authored-by: generaltony007 <144252170+GeneralTony007@users.noreply.github.com>

* fix(api): retry 529 + gateway-400 in openai_compat provider

Co-authored-by: generaltony007 <144252170+GeneralTony007@users.noreply.github.com>

* fix(api): correct EOF whitespace in openai_compat

Co-authored-by: generaltony007 <144252170+GeneralTony007@users.noreply.github.com>

---------

Co-authored-by: generaltony007 <144252170+GeneralTony007@users.noreply.github.com>
2026-11-09 02:07:20 +00:00
Bellmanandclawdbot fe2b64ae9e ci(release): build macOS arm64 on macos-15; macos-14 runners are no longer supported (#3380)
Co-authored-by: clawdbot <clawdbot@ultraworkers.dev>
2026-11-08 20:21:40 +00:00
8 changed files with 253 additions and 21 deletions

No files matched your search

+1 -1
View File
@@ -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
+6
View File
@@ -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
+45
View File
@@ -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/`.
+11 -11
View File
@@ -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
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.1.12"
version = "0.1.13"
edition = "2021"
license = "MIT"
publish = false
+89 -2
View File
@@ -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]
+99 -5
View File
@@ -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() {
+1 -1
View File
@@ -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),