Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
288 changes: 267 additions & 21 deletions rust/Cargo.lock
100755 → 100644

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ publish = false
serde_json = "1"

[workspace.lints.rust]
unsafe_code = "forbid"
# rusty-claude-cli needs unsafe for TUI stdout suppression (dup/dup2),
# all other crates should avoid it — override per-crate if needed.
unsafe_code = "warn"

[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
Expand Down
3 changes: 3 additions & 0 deletions rust/crates/api/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ impl ProviderClient {
Some(meta) if meta.auth_env == "DASHSCOPE_API_KEY" => {
OpenAiCompatConfig::dashscope()
}
Some(meta) if meta.auth_env == "CLAWCUSTOMOPENAI_API_KEY" => {
OpenAiCompatConfig::custom_openai()
}
_ => OpenAiCompatConfig::openai(),
};
Ok(Self::OpenAi(OpenAiCompatClient::from_env(config)?))
Expand Down
43 changes: 43 additions & 0 deletions rust/crates/api/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,49 @@ impl ApiError {
}
}

/// HTTP status code for an `Api` error, if any. Used by callers (e.g. the
/// sub-agent provider chain) to decide whether to advance to the next
/// configured model — a 404 "model not found" on the primary should fall
/// through to the next fallback rather than killing the whole chain.
#[must_use]
pub fn status_code(&self) -> Option<reqwest::StatusCode> {
match self {
Self::Api { status, .. } => Some(*status),
Self::RetriesExhausted { last_error, .. } => last_error.status_code(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. }
| Self::RequestBodySizeExceeded { .. } => None,
}
}

/// Response body (best-effort) for an `Api` error, if any.
#[must_use]
pub fn response_body(&self) -> Option<&str> {
match self {
Self::Api { body, .. } => Some(body.as_str()),
Self::RetriesExhausted { last_error, .. } => last_error.response_body(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. }
| Self::RequestBodySizeExceeded { .. } => None,
}
}

#[must_use]
pub fn safe_failure_class(&self) -> &'static str {
match self {
Expand Down
5 changes: 3 additions & 2 deletions rust/crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ pub use providers::openai_compat::{
};
pub use providers::{
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override,
model_family_identity_for, model_family_identity_for_kind, provider_diagnostics_for_model,
resolve_model_alias, ProviderDiagnostics, ProviderKind,
model_family_identity_for, model_family_identity_for_kind, model_token_limit,
provider_diagnostics_for_model, resolve_model_alias, ModelTokenLimit, ProviderDiagnostics,
ProviderKind,
};
pub use sse::{parse_frame, SseParser};
pub use types::{
Expand Down
43 changes: 39 additions & 4 deletions rust/crates/api/src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,14 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL,
});
}
if canonical.starts_with("custom/") {
return Some(ProviderMetadata {
provider: ProviderKind::OpenAi,
auth_env: "CLAWCUSTOMOPENAI_API_KEY",
base_url_env: "CLAWCUSTOMOPENAI_BASE_URL",
default_base_url: openai_compat::DEFAULT_CUSTOM_OPENAI_BASE_URL,
});
}
// Alibaba DashScope compatible-mode endpoint. Routes qwen/* and bare
// qwen-* model names (qwen-max, qwen-plus, qwen-turbo, qwen-qwq, etc.)
// to the OpenAI-compat client pointed at DashScope's /compatible-mode/v1.
Expand Down Expand Up @@ -377,6 +385,11 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
{
return ProviderKind::OpenAi;
}
// Explicit `custom/` prefix selects the Claw custom OpenAI-compat provider
// even when no other credentials are present.
if resolved_model.starts_with("custom/") {
return ProviderKind::OpenAi;
}
if anthropic::has_auth_from_env_or_saved().unwrap_or(false) {
return ProviderKind::Anthropic;
}
Expand Down Expand Up @@ -666,15 +679,22 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
max_output_tokens: 16_384,
context_window_tokens: 256_000,
}),
"qwen-max" => Some(ModelTokenLimit {
max_output_tokens: 8_192,
// Qwen models via DashScope / OpenAI-compat
"qwen3.6-35b-fast" | "qwen3-235b-a22b" | "qwen-max" | "qwen-plus" | "qwen-turbo"
| "qwen-qwq" => Some(ModelTokenLimit {
max_output_tokens: 16_384,
context_window_tokens: 131_072,
}),
"qwen-plus" => Some(ModelTokenLimit {
"glm-5.1-fast" => Some(ModelTokenLimit {
max_output_tokens: 16_384,
context_window_tokens: 200_000,
}),
// Generic fallback for any model: assume 128K context, 8K output.
// This prevents the "unknown model → no limit check → context overflow" bug.
_ => Some(ModelTokenLimit {
max_output_tokens: 8_192,
context_window_tokens: 131_072,
}),
_ => None,
}
}

Expand Down Expand Up @@ -1138,6 +1158,21 @@ mod tests {
assert_eq!(super::resolve_model_alias("KIMI"), "kimi-k2.5"); // case insensitive
}

#[test]
fn custom_prefix_routes_to_custom_openai_env_vars() {
let meta = super::metadata_for_model("custom/openclaw_3750")
.expect("custom/ prefix must resolve to custom OpenAI metadata");
assert_eq!(meta.provider, ProviderKind::OpenAi);
assert_eq!(meta.auth_env, "CLAWCUSTOMOPENAI_API_KEY");
assert_eq!(meta.base_url_env, "CLAWCUSTOMOPENAI_BASE_URL");

assert_eq!(
detect_provider_kind("custom/openclaw_3750"),
ProviderKind::OpenAi,
"custom/ prefix must select OpenAi provider kind"
);
}

#[test]
fn provider_diagnostics_explain_openai_compatible_capabilities() {
let diagnostics = super::provider_diagnostics_for_model("openai/deepseek-v4-pro");
Expand Down
46 changes: 45 additions & 1 deletion rust/crates/api/src/providers/openai_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ use super::{preflight_message_request, resolve_model_alias, Provider, ProviderFu
pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
pub const DEFAULT_DASHSCOPE_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
/// Default base URL for Claw's custom OpenAI-compatible provider.
/// Intentionally left empty: a custom endpoint must set
/// `CLAWCUSTOMOPENAI_BASE_URL`; otherwise requests will fail at URL build
/// time rather than leaking credentials to the real OpenAI endpoint.
pub const DEFAULT_CUSTOM_OPENAI_BASE_URL: &str = "";
const REQUEST_ID_HEADER: &str = "request-id";
const ALT_REQUEST_ID_HEADER: &str = "x-request-id";
const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
Expand All @@ -43,6 +48,7 @@ pub struct OpenAiCompatConfig {
const XAI_ENV_VARS: &[&str] = &["XAI_API_KEY"];
const OPENAI_ENV_VARS: &[&str] = &["OPENAI_API_KEY"];
const DASHSCOPE_ENV_VARS: &[&str] = &["DASHSCOPE_API_KEY"];
const CUSTOM_OPENAI_ENV_VARS: &[&str] = &["CLAWCUSTOMOPENAI_API_KEY"];

// Provider-specific request body size limits in bytes
const XAI_MAX_REQUEST_BODY_BYTES: usize = 52_428_800; // 50MB
Expand Down Expand Up @@ -95,12 +101,27 @@ impl OpenAiCompatConfig {
}
}

/// Claw-specific custom OpenAI-compatible endpoint.
/// Reads `CLAWCUSTOMOPENAI_API_KEY` / `CLAWCUSTOMOPENAI_BASE_URL` so it
/// can coexist with real OpenAI/NeuralWatt `OPENAI_*` environment vars.
#[must_use]
pub const fn custom_openai() -> Self {
Self {
provider_name: "Custom OpenAI",
api_key_env: "CLAWCUSTOMOPENAI_API_KEY",
base_url_env: "CLAWCUSTOMOPENAI_BASE_URL",
default_base_url: DEFAULT_CUSTOM_OPENAI_BASE_URL,
max_request_body_bytes: OPENAI_MAX_REQUEST_BODY_BYTES,
}
}

#[must_use]
pub fn credential_env_vars(self) -> &'static [&'static str] {
match self.provider_name {
"xAI" => XAI_ENV_VARS,
"OpenAI" => OPENAI_ENV_VARS,
"DashScope" => DASHSCOPE_ENV_VARS,
"Custom OpenAI" => CUSTOM_OPENAI_ENV_VARS,
_ => &[],
}
}
Expand Down Expand Up @@ -1066,7 +1087,7 @@ fn wire_model_for_base_url<'a>(
if matches!(lowered_prefix.as_str(), "xai" | "grok" | "qwen" | "kimi") {
return Cow::Borrowed(&model[pos + 1..]);
}
if lowered_prefix == "local" {
if matches!(lowered_prefix.as_str(), "local" | "custom") {
return Cow::Borrowed(&model[pos + 1..]);
}

Expand Down Expand Up @@ -2278,6 +2299,29 @@ mod tests {
assert_eq!(parse_tool_arguments("not-json"), json!({"raw": "not-json"}));
}

#[test]
fn custom_routing_prefix_strips_on_wire() {
let payload = build_chat_completion_request(
&MessageRequest {
model: "custom/openclaw_3750".to_string(),
max_tokens: 64,
messages: vec![InputMessage::user_text("hello")],
..Default::default()
},
OpenAiCompatConfig::custom_openai(),
);

assert_eq!(payload["model"], json!("openclaw_3750"));
}

#[test]
fn custom_openai_config_uses_separate_env_vars() {
let config = OpenAiCompatConfig::custom_openai();
assert_eq!(config.provider_name, "Custom OpenAI");
assert_eq!(config.api_key_env, "CLAWCUSTOMOPENAI_API_KEY");
assert_eq!(config.base_url_env, "CLAWCUSTOMOPENAI_BASE_URL");
}

#[test]
fn missing_xai_api_key_is_provider_specific() {
let _lock = env_lock();
Expand Down
31 changes: 28 additions & 3 deletions rust/crates/commands/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "tui",
aliases: &[],
summary: "Switch to the split-pane TUI dashboard",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "status",
aliases: &[],
Expand Down Expand Up @@ -808,7 +815,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
name: "team",
aliases: &[],
summary: "Manage agent teams",
argument_hint: Some("[list|create|delete]"),
argument_hint: Some("[on|off|status]"),
resume_supported: true,
},
SlashCommandSpec {
Expand Down Expand Up @@ -1188,10 +1195,14 @@ pub enum SlashCommand {
History {
count: Option<String>,
},
Unknown(String),
Lsp {
action: Option<String>,
target: Option<String>,
},
Team {
action: Option<String>,
},
Unknown(String),
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -1509,6 +1520,9 @@ pub fn validate_slash_command_input(
"history" => SlashCommand::History {
count: optional_single_arg(command, &args, "[count]")?,
},
"team" => SlashCommand::Team {
action: optional_single_arg(command, &args, "[list|create|delete]")?,
},
other => SlashCommand::Unknown(other.to_string()),
}))
}
Expand Down Expand Up @@ -5393,6 +5407,7 @@ pub fn handle_slash_command(
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. }
| SlashCommand::History { .. }
| SlashCommand::Lsp { .. }
| SlashCommand::Team { .. }
| SlashCommand::Setup
| SlashCommand::Unknown(_) => None,
Expand Down Expand Up @@ -5525,6 +5540,16 @@ mod tests {
#[test]
fn parses_supported_slash_commands() {
assert_eq!(SlashCommand::parse("/help"), Ok(Some(SlashCommand::Help)));
assert_eq!(
SlashCommand::parse("/team"),
Ok(Some(SlashCommand::Team { action: None }))
);
assert_eq!(
SlashCommand::parse("/team on"),
Ok(Some(SlashCommand::Team {
action: Some("on".to_string())
}))
);
assert_eq!(
SlashCommand::parse(" /status "),
Ok(Some(SlashCommand::Status))
Expand Down Expand Up @@ -6012,7 +6037,7 @@ mod tests {
assert!(!help.contains("/login"));
assert!(!help.contains("/logout"));
assert!(help.contains("/setup"));
assert_eq!(slash_command_specs().len(), 140);
assert_eq!(slash_command_specs().len(), 141);
assert!(resume_supported_slash_commands().len() >= 39);
}

Expand Down
Loading
Loading