Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
244ce22
feat: auto-compact and retry on context window errors
TheArchitectit Apr 23, 2026
4119825
Add support referral links
TheArchitectit Apr 25, 2026
442ee92
Update referral links: add codes, remove stale details
TheArchitectit Apr 25, 2026
096e3b5
Fix bold formatting in referral links
TheArchitectit Apr 25, 2026
1244704
feat: add interactive provider wizard with /setup, claw setup, and Ct…
TheArchitectit Apr 26, 2026
d321d39
fix: Ctrl+P provider swap with visual feedback + clippy cleanup
TheArchitectit Apr 26, 2026
b99e7a5
fix: /resume latest finds sessions across all workspaces
TheArchitectit Apr 27, 2026
35ca0a4
fix: scan project-local .claw/sessions/ as well as global root
TheArchitectit Apr 27, 2026
7b9c622
fix: /resume latest skips current empty session
TheArchitectit Apr 27, 2026
2c55a7b
fix: write model at top-level in settings.json, not nested under prov…
TheArchitectit Apr 27, 2026
7ba448b
feat: add fast model prompt to setup wizard
TheArchitectit Apr 29, 2026
6ec4255
fix: make id field optional in OpenAI response parsing
TheArchitectit Apr 30, 2026
305fbc7
chore: add install script for rebuild and link
TheArchitectit Apr 30, 2026
832f57c
fix: detect HTML responses in streaming path
TheArchitectit Apr 30, 2026
99ca13e
fix: detect raw JSON errors in streaming path
TheArchitectit Apr 30, 2026
1cd6d9e
fix: support reasoning_content and thinking fields in streaming
TheArchitectit Apr 30, 2026
d563f65
fix: make delta field optional in ChunkChoice
TheArchitectit Apr 30, 2026
9f58553
fix: bounds check in compact boundary loop
TheArchitectit Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,14 @@ Claw Code is built in the open alongside the broader UltraWorkers toolchain:

- This repository does **not** claim ownership of the original Claude Code source material.
- This repository is **not affiliated with, endorsed by, or maintained by Anthropic**.

---

### ☕ Support This Project

Help keep this project going — use a referral link below and both of us get credits!

| Service | Your Bonus | Details | Referral Code |
|---------|-----------|---------|---------------|
| [**Neuralwatt**](https://portal.neuralwatt.com/auth/register?ref=NW-ROGER-ET3Y) | $10 in credits | Spend $10+ → you get $10, we get $20 | `NW-ROGER-ET3Y` |
| [**Synthetic**](https://synthetic.new/?referral=UAWqkKQQLFkzMkY) | $10 in credits | Subscribe → both get $10 credit | `UAWqkKQQLFkzMkY` |
11 changes: 5 additions & 6 deletions rust/crates/api/src/providers/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -738,11 +738,7 @@ fn now_unix_timestamp() -> u64 {
}

fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
match std::env::var(key) {
Ok(value) if !value.is_empty() => Ok(Some(value)),
Ok(_) | Err(std::env::VarError::NotPresent) => Ok(super::dotenv_value(key)),
Err(error) => Err(ApiError::from(error)),
}
super::read_env_or_config(key)
}

#[cfg(test)]
Expand All @@ -763,7 +759,10 @@ fn read_auth_token() -> Option<String> {

#[must_use]
pub fn read_base_url() -> String {
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
super::read_env_or_config("ANTHROPIC_BASE_URL")
.ok()
.flatten()
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
}

fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option<String> {
Expand Down
56 changes: 56 additions & 0 deletions rust/crates/api/src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,9 +247,65 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
if std::env::var_os("OPENAI_BASE_URL").is_some() {
return ProviderKind::OpenAi;
}
// Fallback: check stored provider config from setup wizard.
if let Some(kind) = stored_provider_kind() {
return kind;
}
ProviderKind::Anthropic
}

/// Look up a stored provider config value by env var name.
/// Returns the stored API key or base URL when the env var matches the
/// configured provider kind, enabling the setup wizard to persist credentials
/// that work without shell env vars.
pub fn provider_config_value(key: &str) -> Option<String> {
let cwd = std::env::current_dir().ok()?;
let config = runtime::ConfigLoader::default_for(&cwd).load().ok()?;
let provider = config.provider();
let kind = provider.kind()?;
match (key, kind) {
("ANTHROPIC_API_KEY" | "ANTHROPIC_AUTH_TOKEN", "anthropic")
| ("XAI_API_KEY", "xai")
| ("OPENAI_API_KEY", "openai")
| ("DASHSCOPE_API_KEY", "dashscope") => provider.api_key().map(ToOwned::to_owned),
("ANTHROPIC_BASE_URL", "anthropic")
| ("XAI_BASE_URL", "xai")
| ("OPENAI_BASE_URL", "openai")
| ("DASHSCOPE_BASE_URL", "dashscope") => provider.base_url().map(ToOwned::to_owned),
_ => None,
}
}

/// Read an env var with a 3-tier fallback: process env -> .env file -> stored config.
/// Environment variables always take priority over stored settings.
pub fn read_env_or_config(key: &str) -> Result<Option<String>, ApiError> {
match std::env::var(key) {
Ok(value) if !value.is_empty() => return Ok(Some(value)),
Ok(_) | Err(std::env::VarError::NotPresent) => {}
Err(error) => return Err(ApiError::from(error)),
}
if let Some(value) = dotenv_value(key) {
return Ok(Some(value));
}
if let Some(value) = provider_config_value(key) {
return Ok(Some(value));
}
Ok(None)
}

/// Return the stored `ProviderKind` from config, if set.
fn stored_provider_kind() -> Option<ProviderKind> {
let cwd = std::env::current_dir().ok()?;
let config = runtime::ConfigLoader::default_for(&cwd).load().ok()?;
let kind = config.provider().kind()?;
match kind {
"anthropic" => Some(ProviderKind::Anthropic),
"xai" => Some(ProviderKind::Xai),
"openai" => Some(ProviderKind::OpenAi),
_ => None,
}
}

#[must_use]
pub const fn model_family_identity_for_kind(kind: ProviderKind) -> runtime::ModelFamilyIdentity {
match kind {
Expand Down
68 changes: 62 additions & 6 deletions rust/crates/api/src/providers/openai_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -497,10 +497,12 @@ impl StreamState {
}

for choice in chunk.choices {
// Handle reasoning/thinking from various provider fields
if let Some(reasoning) = choice
.delta
.reasoning_content
.filter(|value| !value.is_empty())
.or(choice.delta.thinking.and_then(|t| t.content).filter(|value| !value.is_empty()))
{
if !self.thinking_started {
self.thinking_started = true;
Expand Down Expand Up @@ -728,6 +730,7 @@ impl ToolCallState {

#[derive(Debug, Deserialize)]
struct ChatCompletionResponse {
#[serde(default)]
id: String,
model: String,
choices: Vec<ChatChoice>,
Expand Down Expand Up @@ -775,6 +778,7 @@ struct OpenAiUsage {

#[derive(Debug, Deserialize)]
struct ChatCompletionChunk {
#[serde(default)]
id: String,
#[serde(default)]
model: Option<String>,
Expand All @@ -786,6 +790,7 @@ struct ChatCompletionChunk {

#[derive(Debug, Deserialize)]
struct ChunkChoice {
#[serde(default)]
delta: ChunkDelta,
#[serde(default)]
finish_reason: Option<String>,
Expand All @@ -795,12 +800,21 @@ struct ChunkChoice {
struct ChunkDelta {
#[serde(default)]
content: Option<String>,
/// Some providers (GLM, DeepSeek) emit reasoning in `reasoning_content`
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
thinking: Option<ThinkingDelta>,
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
tool_calls: Vec<DeltaToolCall>,
}

#[derive(Debug, Default, Deserialize)]
struct ThinkingDelta {
#[serde(default)]
content: Option<String>,
}

#[derive(Debug, Deserialize)]
struct DeltaToolCall {
#[serde(default)]
Expand Down Expand Up @@ -1351,7 +1365,50 @@ fn parse_sse_frame(
data_lines.push(data.trim_start());
}
}
// If no SSE data lines found, check if the entire frame is raw JSON (error or otherwise)
if data_lines.is_empty() {
// Detect raw JSON error response (not SSE-framed)
if let Ok(raw) = serde_json::from_str::<serde_json::Value>(trimmed) {
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(serde_json::Value::as_u64)
.map(|c| c as u16);
let status = reqwest::StatusCode::from_u16(code.unwrap_or(500))
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
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: trimmed.chars().take(500).collect(),
retryable: false,
suggested_action: suggested_action_for_status(status),
retry_after: None,
});
}
}
// Detect HTML responses
if trimmed.starts_with('<') || trimmed.starts_with("<!") {
return Err(ApiError::Api {
status: reqwest::StatusCode::BAD_REQUEST,
error_type: Some("invalid_response".to_string()),
message: Some("provider returned HTML instead of JSON (check endpoint URL)".to_string()),
request_id: None,
body: trimmed.chars().take(200).collect(),
retryable: false,
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
retry_after: None,
});
}
return Ok(None);
}
let payload = data_lines.join("\n");
Expand Down Expand Up @@ -1394,11 +1451,7 @@ fn parse_sse_frame(
}

fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
match std::env::var(key) {
Ok(value) if !value.is_empty() => Ok(Some(value)),
Ok(_) | Err(std::env::VarError::NotPresent) => Ok(super::dotenv_value(key)),
Err(error) => Err(ApiError::from(error)),
}
super::read_env_or_config(key)
}

#[must_use]
Expand All @@ -1411,7 +1464,10 @@ pub fn has_api_key(key: &str) -> bool {

#[must_use]
pub fn read_base_url(config: OpenAiCompatConfig) -> String {
std::env::var(config.base_url_env).unwrap_or_else(|_| config.default_base_url.to_string())
super::read_env_or_config(config.base_url_env)
.ok()
.flatten()
.unwrap_or_else(|| config.default_base_url.to_string())
}

fn chat_completions_endpoint(base_url: &str) -> String {
Expand Down
16 changes: 15 additions & 1 deletion rust/crates/commands/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "setup",
aliases: &[],
summary: "Configure provider, API key, and model interactively",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "stats",
aliases: &[],
Expand Down Expand Up @@ -1140,6 +1147,7 @@ pub enum SlashCommand {
Usage {
scope: Option<String>,
},
Setup,
Rename {
name: Option<String>,
},
Expand Down Expand Up @@ -1265,6 +1273,7 @@ impl SlashCommand {
Self::Theme { .. } => "/theme",
Self::Voice { .. } => "/voice",
Self::Usage { .. } => "/usage",
Self::Setup => "/setup",
Self::Rename { .. } => "/rename",
Self::Copy { .. } => "/copy",
Self::Hooks { .. } => "/hooks",
Expand Down Expand Up @@ -1472,10 +1481,12 @@ pub fn validate_slash_command_input(
}
"plan" => SlashCommand::Plan { mode: remainder },
"review" => SlashCommand::Review { scope: remainder },
"team" => SlashCommand::Team { action: remainder },
"tasks" => SlashCommand::Tasks { args: remainder },
"theme" => SlashCommand::Theme { name: remainder },
"voice" => SlashCommand::Voice { mode: remainder },
"usage" => SlashCommand::Usage { scope: remainder },
"setup" => SlashCommand::Setup,
"rename" => SlashCommand::Rename { name: remainder },
"copy" => SlashCommand::Copy { target: remainder },
"hooks" => SlashCommand::Hooks { args: remainder },
Expand Down Expand Up @@ -2622,6 +2633,7 @@ pub fn resolve_skill_path(cwd: &Path, skill: &str) -> std::io::Result<PathBuf> {
))
}

#[allow(clippy::unnecessary_wraps)]
fn render_mcp_report_for(
loader: &ConfigLoader,
cwd: &Path,
Expand Down Expand Up @@ -2719,6 +2731,7 @@ fn render_mcp_unsupported_action_json(action: &str, hint: &str) -> Value {
})
}

#[allow(clippy::unnecessary_wraps)]
fn render_mcp_report_json_for(
loader: &ConfigLoader,
cwd: &Path,
Expand Down Expand Up @@ -4298,6 +4311,7 @@ pub fn handle_slash_command(
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. }
| SlashCommand::History { .. }
| SlashCommand::Setup
| SlashCommand::Unknown(_) => None,
}
}
Expand Down Expand Up @@ -4893,7 +4907,7 @@ mod tests {
assert!(help.contains("aliases: /skill"));
assert!(!help.contains("/login"));
assert!(!help.contains("/logout"));
assert_eq!(slash_command_specs().len(), 139);
assert_eq!(slash_command_specs().len(), 140);
assert!(resume_supported_slash_commands().len() >= 39);
}

Expand Down
2 changes: 1 addition & 1 deletion rust/crates/runtime/src/compact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
// 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 {
if k == 0 || k <= compacted_prefix_len || k >= session.messages.len() {
break;
}
let first_preserved = &session.messages[k];
Expand Down
Loading