Skip to content

Commit 769a4f5

Browse files
Merge remote-tracking branch 'upstream/main' into dev
2 parents 9ea07e3 + e874bc6 commit 769a4f5

32 files changed

Lines changed: 4422 additions & 1108 deletions

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,8 @@ archive/
55
# Claude Code local artifacts
66
.claude/settings.local.json
77
.claude/sessions/
8+
# Claw Code local artifacts
9+
.claw/settings.local.json
10+
.claw/sessions/
11+
.clawhip/
12+
status-help.txt

ROADMAP.md

Lines changed: 83 additions & 73 deletions
Large diffs are not rendered by default.

USAGE.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ cargo build --workspace
2121
- Rust toolchain with `cargo`
2222
- One of:
2323
- `ANTHROPIC_API_KEY` for direct API access
24-
- `claw login` for OAuth-based auth
24+
- `ANTHROPIC_AUTH_TOKEN` for bearer-token auth
2525
- Optional: `ANTHROPIC_BASE_URL` when targeting a proxy or local service
2626

2727
## Install / build the workspace
@@ -105,8 +105,7 @@ export ANTHROPIC_API_KEY="sk-ant-..."
105105

106106
```bash
107107
cd rust
108-
./target/debug/claw login
109-
./target/debug/claw logout
108+
export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
110109
```
111110

112111
### Which env var goes where
@@ -116,7 +115,7 @@ cd rust
116115
| Credential shape | Env var | HTTP header | Typical source |
117116
|---|---|---|---|
118117
| `sk-ant-*` API key | `ANTHROPIC_API_KEY` | `x-api-key: sk-ant-...` | [console.anthropic.com](https://console.anthropic.com) |
119-
| OAuth access token (opaque) | `ANTHROPIC_AUTH_TOKEN` | `Authorization: Bearer ...` | `claw login` or an Anthropic-compatible proxy that mints Bearer tokens |
118+
| OAuth access token (opaque) | `ANTHROPIC_AUTH_TOKEN` | `Authorization: Bearer ...` | an Anthropic-compatible proxy or OAuth flow that mints bearer tokens |
120119
| 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) |
121120

122121
**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.
@@ -125,7 +124,7 @@ cd rust
125124

126125
## Local Models
127126

128-
`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`.
127+
`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.
129128

130129
### Anthropic-compatible endpoint
131130

@@ -192,7 +191,7 @@ Reasoning variants (`qwen-qwq-*`, `qwq-*`, `*-thinking`) automatically strip `te
192191

193192
| Provider | Protocol | Auth env var(s) | Base URL env var | Default base URL |
194193
|---|---|---|---|---|
195-
| **Anthropic** (direct) | Anthropic Messages API | `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` or OAuth (`claw login`) | `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` |
194+
| **Anthropic** (direct) | Anthropic Messages API | `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` | `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` |
196195
| **xAI** | OpenAI-compatible | `XAI_API_KEY` | `XAI_BASE_URL` | `https://api.x.ai/v1` |
197196
| **OpenAI-compatible** | OpenAI Chat Completions | `OPENAI_API_KEY` | `OPENAI_BASE_URL` | `https://api.openai.com/v1` |
198197
| **DashScope** (Alibaba) | OpenAI-compatible | `DASHSCOPE_API_KEY` | `DASHSCOPE_BASE_URL` | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
1-
{"created_at_ms":1775386832313,"session_id":"session-1775386832313-0","type":"session_meta","updated_at_ms":1775386832313,"version":1}
2-
{"message":{"blocks":[{"text":"status --help","type":"text"}],"role":"user"},"type":"message"}
1+
{"created_at_ms":1775777421902,"session_id":"session-1775777421902-1","type":"session_meta","updated_at_ms":1775777421902,"version":1}

rust/README.md

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ export ANTHROPIC_API_KEY="sk-ant-..."
3434
export ANTHROPIC_BASE_URL="https://your-proxy.com"
3535
```
3636

37-
Or authenticate via OAuth and let the CLI persist credentials locally:
37+
Or provide an OAuth bearer token directly:
3838

3939
```bash
40-
cargo run -p rusty-claude-cli -- login
40+
export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
4141
```
4242

4343
## Mock parity harness
@@ -80,7 +80,7 @@ Primary artifacts:
8080
| Feature | Status |
8181
|---------|--------|
8282
| Anthropic / OpenAI-compatible provider flows + streaming ||
83-
| OAuth login/logout ||
83+
| Direct bearer-token auth via `ANTHROPIC_AUTH_TOKEN` ||
8484
| Interactive REPL (rustyline) ||
8585
| Tool system (bash, read, write, edit, grep, glob) ||
8686
| Web tools (search, fetch) ||
@@ -141,8 +141,6 @@ Top-level commands:
141141
mcp
142142
skills
143143
system-prompt
144-
login
145-
logout
146144
init
147145
```
148146

@@ -159,8 +157,8 @@ Tab completion expands slash commands, model aliases, permission modes, and rece
159157
The REPL now exposes a much broader surface than the original minimal shell:
160158

161159
- session / visibility: `/help`, `/status`, `/sandbox`, `/cost`, `/resume`, `/session`, `/version`, `/usage`, `/stats`
162-
- workspace / git: `/compact`, `/clear`, `/config`, `/memory`, `/init`, `/diff`, `/commit`, `/pr`, `/issue`, `/export`, `/hooks`, `/files`, `/branch`, `/release-notes`, `/add-dir`
163-
- discovery / debugging: `/mcp`, `/agents`, `/skills`, `/doctor`, `/tasks`, `/context`, `/desktop`, `/ide`
160+
- workspace / git: `/compact`, `/clear`, `/config`, `/memory`, `/init`, `/diff`, `/commit`, `/pr`, `/issue`, `/export`, `/hooks`, `/files`, `/release-notes`
161+
- discovery / debugging: `/mcp`, `/agents`, `/skills`, `/doctor`, `/tasks`, `/context`, `/desktop`
164162
- automation / analysis: `/review`, `/advisor`, `/insights`, `/security-review`, `/subagent`, `/team`, `/telemetry`, `/providers`, `/cron`, and more
165163
- plugin management: `/plugin` (with aliases `/plugins`, `/marketplace`)
166164

@@ -194,7 +192,7 @@ rust/
194192

195193
### Crate Responsibilities
196194

197-
- **api** — provider clients, SSE streaming, request/response types, auth (API key + OAuth bearer), request-size/context-window preflight
195+
- **api** — provider clients, SSE streaming, request/response types, auth (`ANTHROPIC_API_KEY` + bearer-token support), request-size/context-window preflight
198196
- **commands** — slash command definitions, parsing, help text generation, JSON/text command rendering
199197
- **compat-harness** — extracts tool/prompt manifests from upstream TS source
200198
- **mock-anthropic-service** — deterministic `/v1/messages` mock for CLI parity tests and local harness runs

rust/crates/api/src/client.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,7 @@ mod tests {
239239
openai_client.base_url()
240240
);
241241
}
242-
other => panic!(
243-
"Expected ProviderClient::OpenAi for qwen-plus, got: {:?}",
244-
other
245-
),
242+
other => panic!("Expected ProviderClient::OpenAi for qwen-plus, got: {other:?}"),
246243
}
247244
}
248245
}

rust/crates/api/src/error.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ pub enum ApiError {
2424
env_vars: &'static [&'static str],
2525
/// Optional, runtime-computed hint appended to the error Display
2626
/// output. Populated when the provider resolver can infer what the
27-
/// user probably intended (e.g. an OpenAI key is set but Anthropic
27+
/// user probably intended (e.g. an `OpenAI` key is set but Anthropic
2828
/// was selected because no Anthropic credentials exist).
2929
hint: Option<String>,
3030
},

rust/crates/api/src/http_client.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,12 @@ pub fn build_http_client_with(config: &ProxyConfig) -> Result<reqwest::Client, A
8888
.as_deref()
8989
.and_then(reqwest::NoProxy::from_string);
9090

91-
let (http_proxy_url, https_proxy_url) = match config.proxy_url.as_deref() {
91+
let (http_proxy_url, https_url) = match config.proxy_url.as_deref() {
9292
Some(unified) => (Some(unified), Some(unified)),
9393
None => (config.http_proxy.as_deref(), config.https_proxy.as_deref()),
9494
};
9595

96-
if let Some(url) = https_proxy_url {
96+
if let Some(url) = https_url {
9797
let mut proxy = reqwest::Proxy::https(url)?;
9898
if let Some(filter) = no_proxy.clone() {
9999
proxy = proxy.no_proxy(Some(filter));

rust/crates/api/src/providers/anthropic.rs

Lines changed: 14 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -502,9 +502,8 @@ impl AnthropicClient {
502502
// Best-effort refinement using the Anthropic count_tokens endpoint.
503503
// On any failure (network, parse, auth), fall back to the local
504504
// byte-estimate result which already passed above.
505-
let counted_input_tokens = match self.count_tokens(request).await {
506-
Ok(count) => count,
507-
Err(_) => return Ok(()),
505+
let Ok(counted_input_tokens) = self.count_tokens(request).await else {
506+
return Ok(());
508507
};
509508
let estimated_total_tokens = counted_input_tokens.saturating_add(request.max_tokens);
510509
if estimated_total_tokens > limit.context_window_tokens {
@@ -631,21 +630,7 @@ impl AuthSource {
631630
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
632631
return Ok(Self::BearerToken(bearer_token));
633632
}
634-
match load_saved_oauth_token() {
635-
Ok(Some(token_set)) if oauth_token_is_expired(&token_set) => {
636-
if token_set.refresh_token.is_some() {
637-
Err(ApiError::Auth(
638-
"saved OAuth token is expired; load runtime OAuth config to refresh it"
639-
.to_string(),
640-
))
641-
} else {
642-
Err(ApiError::ExpiredOAuthToken)
643-
}
644-
}
645-
Ok(Some(token_set)) => Ok(Self::BearerToken(token_set.access_token)),
646-
Ok(None) => Err(anthropic_missing_credentials()),
647-
Err(error) => Err(error),
648-
}
633+
Err(anthropic_missing_credentials())
649634
}
650635
}
651636

@@ -665,14 +650,14 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result<Option<OAuthTok
665650

666651
pub fn has_auth_from_env_or_saved() -> Result<bool, ApiError> {
667652
Ok(read_env_non_empty("ANTHROPIC_API_KEY")?.is_some()
668-
|| read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some()
669-
|| load_saved_oauth_token()?.is_some())
653+
|| read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some())
670654
}
671655

672656
pub fn resolve_startup_auth_source<F>(load_oauth_config: F) -> Result<AuthSource, ApiError>
673657
where
674658
F: FnOnce() -> Result<Option<OAuthConfig>, ApiError>,
675659
{
660+
let _ = load_oauth_config;
676661
if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
677662
return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
678663
Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer {
@@ -685,25 +670,7 @@ where
685670
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
686671
return Ok(AuthSource::BearerToken(bearer_token));
687672
}
688-
689-
let Some(token_set) = load_saved_oauth_token()? else {
690-
return Err(anthropic_missing_credentials());
691-
};
692-
if !oauth_token_is_expired(&token_set) {
693-
return Ok(AuthSource::BearerToken(token_set.access_token));
694-
}
695-
if token_set.refresh_token.is_none() {
696-
return Err(ApiError::ExpiredOAuthToken);
697-
}
698-
699-
let Some(config) = load_oauth_config()? else {
700-
return Err(ApiError::Auth(
701-
"saved OAuth token is expired; runtime OAuth config is missing".to_string(),
702-
));
703-
};
704-
Ok(AuthSource::from(resolve_saved_oauth_token_set(
705-
&config, token_set,
706-
)?))
673+
Err(anthropic_missing_credentials())
707674
}
708675

709676
fn resolve_saved_oauth_token_set(
@@ -1016,7 +983,7 @@ fn strip_unsupported_beta_body_fields(body: &mut Value) {
1016983
object.remove("presence_penalty");
1017984
// Anthropic uses "stop_sequences" not "stop". Convert if present.
1018985
if let Some(stop_val) = object.remove("stop") {
1019-
if stop_val.as_array().map_or(false, |a| !a.is_empty()) {
986+
if stop_val.as_array().is_some_and(|a| !a.is_empty()) {
1020987
object.insert("stop_sequences".to_string(), stop_val);
1021988
}
1022989
}
@@ -1180,7 +1147,7 @@ mod tests {
11801147
}
11811148

11821149
#[test]
1183-
fn auth_source_from_saved_oauth_when_env_absent() {
1150+
fn auth_source_from_env_or_saved_ignores_saved_oauth_when_env_absent() {
11841151
let _guard = env_lock();
11851152
let config_home = temp_config_home();
11861153
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
@@ -1194,8 +1161,8 @@ mod tests {
11941161
})
11951162
.expect("save oauth credentials");
11961163

1197-
let auth = AuthSource::from_env_or_saved().expect("saved auth");
1198-
assert_eq!(auth.bearer_token(), Some("saved-access-token"));
1164+
let error = AuthSource::from_env_or_saved().expect_err("saved oauth should be ignored");
1165+
assert!(error.to_string().contains("ANTHROPIC_API_KEY"));
11991166

12001167
clear_oauth_credentials().expect("clear credentials");
12011168
std::env::remove_var("CLAW_CONFIG_HOME");
@@ -1251,7 +1218,7 @@ mod tests {
12511218
}
12521219

12531220
#[test]
1254-
fn resolve_startup_auth_source_uses_saved_oauth_without_loading_config() {
1221+
fn resolve_startup_auth_source_ignores_saved_oauth_without_loading_config() {
12551222
let _guard = env_lock();
12561223
let config_home = temp_config_home();
12571224
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
@@ -1265,41 +1232,9 @@ mod tests {
12651232
})
12661233
.expect("save oauth credentials");
12671234

1268-
let auth = resolve_startup_auth_source(|| panic!("config should not be loaded"))
1269-
.expect("startup auth");
1270-
assert_eq!(auth.bearer_token(), Some("saved-access-token"));
1271-
1272-
clear_oauth_credentials().expect("clear credentials");
1273-
std::env::remove_var("CLAW_CONFIG_HOME");
1274-
cleanup_temp_config_home(&config_home);
1275-
}
1276-
1277-
#[test]
1278-
fn resolve_startup_auth_source_errors_when_refreshable_token_lacks_config() {
1279-
let _guard = env_lock();
1280-
let config_home = temp_config_home();
1281-
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
1282-
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
1283-
std::env::remove_var("ANTHROPIC_API_KEY");
1284-
save_oauth_credentials(&runtime::OAuthTokenSet {
1285-
access_token: "expired-access-token".to_string(),
1286-
refresh_token: Some("refresh-token".to_string()),
1287-
expires_at: Some(1),
1288-
scopes: vec!["scope:a".to_string()],
1289-
})
1290-
.expect("save expired oauth credentials");
1291-
1292-
let error =
1293-
resolve_startup_auth_source(|| Ok(None)).expect_err("missing config should error");
1294-
assert!(
1295-
matches!(error, crate::error::ApiError::Auth(message) if message.contains("runtime OAuth config is missing"))
1296-
);
1297-
1298-
let stored = runtime::load_oauth_credentials()
1299-
.expect("load stored credentials")
1300-
.expect("stored token set");
1301-
assert_eq!(stored.access_token, "expired-access-token");
1302-
assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
1235+
let error = resolve_startup_auth_source(|| panic!("config should not be loaded"))
1236+
.expect_err("saved oauth should be ignored");
1237+
assert!(error.to_string().contains("ANTHROPIC_API_KEY"));
13031238

13041239
clear_oauth_credentials().expect("clear credentials");
13051240
std::env::remove_var("CLAW_CONFIG_HOME");

rust/crates/api/src/providers/mod.rs

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,15 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
222222
if let Some(metadata) = metadata_for_model(model) {
223223
return metadata.provider;
224224
}
225+
// When OPENAI_BASE_URL is set, the user explicitly configured an
226+
// OpenAI-compatible endpoint. Prefer it over the Anthropic fallback
227+
// even when the model name has no recognized prefix — this is the
228+
// common case for local providers (Ollama, LM Studio, vLLM, etc.)
229+
// where model names like "qwen2.5-coder:7b" don't match any prefix.
230+
if std::env::var_os("OPENAI_BASE_URL").is_some() && openai_compat::has_api_key("OPENAI_API_KEY")
231+
{
232+
return ProviderKind::OpenAi;
233+
}
225234
if anthropic::has_auth_from_env_or_saved().unwrap_or(false) {
226235
return ProviderKind::Anthropic;
227236
}
@@ -515,9 +524,10 @@ mod tests {
515524
// ANTHROPIC_API_KEY was set because metadata_for_model returned None
516525
// and detect_provider_kind fell through to auth-sniffer order.
517526
// The model prefix must win over env-var presence.
518-
let kind = super::metadata_for_model("openai/gpt-4.1-mini")
519-
.map(|m| m.provider)
520-
.unwrap_or_else(|| detect_provider_kind("openai/gpt-4.1-mini"));
527+
let kind = super::metadata_for_model("openai/gpt-4.1-mini").map_or_else(
528+
|| detect_provider_kind("openai/gpt-4.1-mini"),
529+
|m| m.provider,
530+
);
521531
assert_eq!(
522532
kind,
523533
ProviderKind::OpenAi,
@@ -526,8 +536,7 @@ mod tests {
526536

527537
// Also cover bare gpt- prefix
528538
let kind2 = super::metadata_for_model("gpt-4o")
529-
.map(|m| m.provider)
530-
.unwrap_or_else(|| detect_provider_kind("gpt-4o"));
539+
.map_or_else(|| detect_provider_kind("gpt-4o"), |m| m.provider);
531540
assert_eq!(kind2, ProviderKind::OpenAi);
532541
}
533542

@@ -1002,4 +1011,31 @@ NO_EQUALS_LINE
10021011
"empty env var should not trigger the hint sniffer, got {hint:?}"
10031012
);
10041013
}
1014+
1015+
#[test]
1016+
fn openai_base_url_overrides_anthropic_fallback_for_unknown_model() {
1017+
// given — user has OPENAI_BASE_URL + OPENAI_API_KEY but no Anthropic
1018+
// creds, and a model name with no recognized prefix.
1019+
let _lock = env_lock();
1020+
let _base_url = EnvVarGuard::set("OPENAI_BASE_URL", Some("http://127.0.0.1:11434/v1"));
1021+
let _api_key = EnvVarGuard::set("OPENAI_API_KEY", Some("dummy"));
1022+
let _anthropic_key = EnvVarGuard::set("ANTHROPIC_API_KEY", None);
1023+
let _anthropic_token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", None);
1024+
1025+
// when
1026+
let provider = detect_provider_kind("qwen2.5-coder:7b");
1027+
1028+
// then — should route to OpenAI, not Anthropic
1029+
assert_eq!(
1030+
provider,
1031+
ProviderKind::OpenAi,
1032+
"OPENAI_BASE_URL should win over Anthropic fallback for unknown models"
1033+
);
1034+
}
1035+
1036+
// NOTE: a "OPENAI_BASE_URL without OPENAI_API_KEY" test is omitted
1037+
// because workspace-parallel test binaries can race on process env
1038+
// (env_lock only protects within a single binary). The detection logic
1039+
// is covered: OPENAI_BASE_URL alone routes to OpenAi as a last-resort
1040+
// fallback in detect_provider_kind().
10051041
}

0 commit comments

Comments
 (0)