Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha
## [Unreleased]

### Fixed
- Provider model ids keep their vendor prefix in config but are stripped per backend before the request: `anthropic/claude-...` no longer reaches the Anthropic API (HTTP 4xx) or OpenAI after a 5xx, so the fallback chain runs through to local.
- WebChat writes are moved off the agent thread: frames are queued and the lws service thread is woken (`lws_cancel_service`), marking writable only from `EVENT_WAIT_CANCELLED`, which stops the poll-set race against a closing socket.
- Sandboxed shell commands no longer inherit the agent environment: the command process keeps only `PATH`, locale (`LANG`, `LC_ALL`, `LC_CTYPE`), `TERM`, `TZ`, and `HOME` (set to the workspace), so a prompt-injected `printenv` cannot read provider API keys or bot tokens. `LD_*`, `CUDA_*`, and proxy variables are dropped before `exec`; see the environment model in `docs/SECURITY.md` (#109).
- The shell command timeout is enforced against a monotonic wall clock on both the sandboxed and the unsandboxed paths, so a command that keeps stdout trickling can no longer outlive `shell_timeout_sec` and hold the agent lock until the output buffer fills (#116).
Expand Down
2 changes: 2 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# Copy to ~/.shellclaw/config.toml and edit

[agent]
# Gateway-style vendor prefix. Anthropic receives the id after "anthropic/".
# A foreign cloud id is skipped without HTTP 4xx so local fallback still runs.
model = "anthropic/claude-sonnet-4-20250514"
max_tokens = 4096
temperature = 0.7
Expand Down
61 changes: 57 additions & 4 deletions src/providers/anthropic.c
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#include "cJSON.h"
#include <curl/curl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

Expand All @@ -26,11 +27,37 @@ static const config_t *s_anth_cfg;

#ifdef SHELLCLAW_TEST
static int s_fail_next_reallocs;
static int s_anth_test_skip_http;
static char s_anth_test_model[160];

void anthropic_test_fail_next_reallocs(int n)
{
s_fail_next_reallocs = n < 0 ? 0 : n;
}

void anthropic_test_set_skip_http(int skip)
{
s_anth_test_skip_http = skip ? 1 : 0;
}

const char *anthropic_test_last_request_model(void)
{
return s_anth_test_model;
}

static void anth_test_store_model(const char *model)
{
size_t n;

s_anth_test_model[0] = '\0';
if (!model)
return;
n = strlen(model);
if (n >= sizeof(s_anth_test_model))
n = sizeof(s_anth_test_model) - 1U;
memcpy(s_anth_test_model, model, n);
s_anth_test_model[n] = '\0';
}
#endif

static void *anth_realloc(void *ptr, size_t size)
Expand Down Expand Up @@ -374,14 +401,40 @@ static int build_and_send(const provider_message_t *messages, size_t message_cou
const provider_tool_def_t *tools, size_t tool_count,
provider_response_t *response)
{
const char *configured;
const char *model;
int max_tokens;
char errmsg[256];
cJSON *root;
if (!s_anth_cfg) { provider_set_error(response, "Anthropic provider not initialized"); return -1; }
cJSON *root = cJSON_CreateObject();
configured = config_agent_model(s_anth_cfg);
if (!configured || configured[0] == '\0')
configured = "claude-3-5-sonnet-20241022";
model = provider_model_id_for_backend(configured, "anthropic");
if (!model) {
snprintf(errmsg, sizeof(errmsg),
"Anthropic skipped: agent model \"%s\" is not an Anthropic id",
configured);
provider_set_error(response, errmsg);
return -1;
}
root = cJSON_CreateObject();
if (!root) { provider_set_error(response, "Out of memory"); return -1; }
const char *model = config_agent_model(s_anth_cfg);
int max_tokens = config_agent_max_tokens(s_anth_cfg);
if (!model) model = "claude-3-5-sonnet-20241022";
max_tokens = config_agent_max_tokens(s_anth_cfg);
if (max_tokens <= 0) max_tokens = 4096;
cJSON_AddItemToObject(root, "model", cJSON_CreateString(model));
#ifdef SHELLCLAW_TEST
if (s_anth_test_skip_http) {
anth_test_store_model(model);
cJSON_Delete(root);
response->content = provider_dup_str("");
if (!response->content) {
provider_set_error(response, "Out of memory");
return -1;
}
return 0;
}
#endif
cJSON_AddItemToObject(root, "max_tokens", cJSON_CreateNumber(max_tokens));
for (size_t i = 0; i < message_count; i++) {
if (messages[i].role && strcmp(messages[i].role, "system") == 0) {
Expand Down
17 changes: 14 additions & 3 deletions src/providers/openai.c
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include "providers/openai_compat.h"
#include "providers/provider.h"
#include "cJSON.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

Expand All @@ -24,6 +25,7 @@ static int build_and_send(const provider_message_t *messages, size_t message_cou
provider_response_t *response)
{
const char *endpoint;
const char *configured;
const char *model;
int max_tokens;
double temperature_cfg;
Expand All @@ -36,11 +38,20 @@ static int build_and_send(const provider_message_t *messages, size_t message_cou
endpoint = config_provider_openai_endpoint(s_oai_cfg);
if (!endpoint || !endpoint[0])
endpoint = "https://api.openai.com/v1/chat/completions";
model = config_agent_model(s_oai_cfg);
configured = config_agent_model(s_oai_cfg);
if (!configured || configured[0] == '\0')
configured = "gpt-4o-mini";
model = provider_model_id_for_backend(configured, "openai");
if (!model) {
char errmsg[256];
snprintf(errmsg, sizeof(errmsg),
"OpenAI skipped: agent model \"%s\" is not an OpenAI id",
configured);
provider_set_error(response, errmsg);
return -1;
}
max_tokens = config_agent_max_tokens(s_oai_cfg);
temperature_cfg = config_agent_temperature(s_oai_cfg);
if (!model)
model = "gpt-4o-mini";
body = openai_compat_build_chat_body(model, max_tokens, temperature_cfg, messages,
message_count, tools, tool_count, response);
if (!body)
Expand Down
13 changes: 13 additions & 0 deletions src/providers/provider.h
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ int provider_parse_chat_completions_json(const char *response_body, provider_res
/** Non-zero if router may try next chain entry: no `HTTP 4xx` in message, otherwise transport/5xx may retry. */
int provider_error_allows_fallback_retry(const char *provider_error_content);

/**
* Resolve @p model to the id @p backend should send.
*
* A `vendor/rest` id whose vendor matches @p backend returns the rest (a pointer
* into @p model). A known other cloud vendor, a bare Claude id on OpenAI, or a
* bare OpenAI id on Anthropic returns NULL. Callers must fail that case without
* an HTTP status: a 4xx stops the fallback chain.
*
* Example: provider_model_id_for_backend("anthropic/claude-sonnet-4-20250514", "anthropic")
* returns "claude-sonnet-4-20250514".
*/
const char *provider_model_id_for_backend(const char *model, const char *backend);

void provider_router_active_backend_snapshot(char *dst, size_t dst_sz);
void provider_router_last_error_snapshot(char *dst, size_t dst_sz);
void provider_router_periodic_recovery_tick(time_t now_wall);
Expand Down
59 changes: 59 additions & 0 deletions src/providers/provider_common.c
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,65 @@ char *provider_dup_str(const char *s)
return p;
}

static int id_has_prefix(const char *id, const char *prefix)
{
size_t n = strlen(prefix);
return strncmp(id, prefix, n) == 0;
}

static int vendor_tag_is_cloud(const char *model, size_t vendor_len)
{
if (vendor_len == 9U && strncmp(model, "anthropic", 9) == 0)
return 1;
if (vendor_len == 6U && strncmp(model, "openai", 6) == 0)
return 1;
return 0;
}

static int bare_id_is_openai_family(const char *model)
{
if (id_has_prefix(model, "gpt-") || id_has_prefix(model, "chatgpt-"))
return 1;
if (id_has_prefix(model, "o1") || id_has_prefix(model, "o3"))
return 1;
return id_has_prefix(model, "o4");
}

static const char *bare_model_for_backend(const char *model, const char *backend)
{
if (strcmp(backend, "openai") == 0 && id_has_prefix(model, "claude"))
return NULL;
if (strcmp(backend, "anthropic") == 0 && bare_id_is_openai_family(model))
return NULL;
return model;
}

static int vendor_matches_backend(const char *model, size_t vendor_len, const char *backend)
{
if (strncmp(model, backend, vendor_len) != 0)
return 0;
return backend[vendor_len] == '\0';
}

/* NULL means skip. Sending the foreign id would be HTTP 4xx and stop fallback. */
const char *provider_model_id_for_backend(const char *model, const char *backend)
{
const char *slash;
size_t vendor_len;

if (!model || model[0] == '\0' || !backend || backend[0] == '\0')
return model;
slash = strchr(model, '/');
if (!slash || slash == model || slash[1] == '\0')
return bare_model_for_backend(model, backend);
vendor_len = (size_t)(slash - model);
if (vendor_matches_backend(model, vendor_len, backend))
return slash + 1;
if (vendor_tag_is_cloud(model, vendor_len))
return NULL;
return model;
}

int provider_error_allows_fallback_retry(const char *msg)
{
const char *h;
Expand Down
47 changes: 47 additions & 0 deletions tests/test_anthropic.c
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
extern int anthropic_parse_response_for_test(const char *json, provider_response_t *response);
extern char *anthropic_messages_json_for_test(const provider_message_t *messages, size_t message_count);
extern void anthropic_test_fail_next_reallocs(int n);
extern void anthropic_test_set_skip_http(int skip);
extern const char *anthropic_test_last_request_model(void);
#endif

static const char *TMP_CONFIG = "/tmp/shellclaw_test_anthropic_config.toml";
Expand Down Expand Up @@ -455,6 +457,50 @@ static int test_tool_results_do_not_merge_across_assistant_rounds(void)
return 0;
}

static int test_example_model_prefix_is_stripped(void)
{
const char *saved = getenv("ANTHROPIC_API_KEY");
char *saved_copy = saved ? strdup(saved) : NULL;
config_t *cfg = NULL;
char errbuf[256];
provider_response_t response = {0};
const provider_t *p;
provider_message_t msg = {
.role = "user", .content = "Hi", .tool_calls = NULL,
.tool_calls_count = 0, .tool_use_id = NULL
};
FILE *f;
int chat_ret;

f = fopen(TMP_CONFIG, "w");
ASSERT(f != NULL);
fprintf(f, "[agent]\nmodel = \"anthropic/claude-sonnet-4-20250514\"\nmax_tokens = 256\n");
fprintf(f, "[memory]\ndb_path = \"/tmp/shellclaw_anth_test.db\"\n");
fprintf(f, "[providers.anthropic]\napi_key_env = \"ANTHROPIC_API_KEY\"\n");
fclose(f);
ASSERT(setenv("ANTHROPIC_API_KEY", "sk-test-not-a-real-key", 1) == 0);
anthropic_test_set_skip_http(1);
ASSERT(config_load(TMP_CONFIG, &cfg, errbuf, sizeof(errbuf)) == 0);
p = provider_anthropic_get();
ASSERT(p != NULL);
ASSERT(p->init(cfg) == 0);
chat_ret = p->chat(&msg, 1, NULL, 0, &response);
ASSERT(chat_ret == 0);
ASSERT(strcmp(anthropic_test_last_request_model(), "claude-sonnet-4-20250514") == 0);
p->cleanup();
config_free(cfg);
remove(TMP_CONFIG);
anthropic_test_set_skip_http(0);
if (saved_copy) {
setenv("ANTHROPIC_API_KEY", saved_copy, 1);
free(saved_copy);
} else {
unsetenv("ANTHROPIC_API_KEY");
}
provider_response_clear(&response);
return 0;
}

static int test_parse_tool_realloc_failure_is_error(void)
{
char body[1024];
Expand Down Expand Up @@ -496,6 +542,7 @@ int main(void)
RUN(test_tool_results_do_not_merge_across_assistant_rounds());
RUN(test_parse_text_realloc_failure_is_error());
RUN(test_parse_tool_realloc_failure_is_error());
RUN(test_example_model_prefix_is_stripped());
#endif
printf("test_anthropic: all tests passed\n");
return 0;
Expand Down
47 changes: 47 additions & 0 deletions tests/test_openai.c
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,52 @@ static int test_init_and_chat_if_key_set(void)
return 0;
}

static int test_prefixed_claude_model_skips_without_http(void)
{
const char *saved = getenv("OPENAI_API_KEY");
char *saved_copy = saved ? strdup(saved) : NULL;
config_t *cfg = NULL;
char errbuf[256];
provider_response_t response = {0};
const provider_t *p;
provider_message_t msg = {
.role = "user", .content = "Hi", .tool_calls = NULL,
.tool_calls_count = 0, .tool_use_id = NULL
};
FILE *f;
int chat_ret;

f = fopen(TMP_CONFIG, "w");
ASSERT(f != NULL);
fprintf(f, "[agent]\nmodel = \"anthropic/claude-sonnet-4-20250514\"\nmax_tokens = 256\n");
fprintf(f, "[memory]\ndb_path = \"/tmp/shellclaw_openai_test.db\"\n");
fprintf(f, "[providers.openai]\napi_key_env = \"OPENAI_API_KEY\"\n");
fclose(f);
ASSERT(setenv("OPENAI_API_KEY", "sk-test-not-a-real-key", 1) == 0);
ASSERT(config_load(TMP_CONFIG, &cfg, errbuf, sizeof(errbuf)) == 0);
p = provider_openai_get();
ASSERT(p != NULL);
ASSERT(p->init(cfg) == 0);
chat_ret = p->chat(&msg, 1, NULL, 0, &response);
p->cleanup();
config_free(cfg);
remove(TMP_CONFIG);
if (saved_copy) {
setenv("OPENAI_API_KEY", saved_copy, 1);
free(saved_copy);
} else {
unsetenv("OPENAI_API_KEY");
}
ASSERT(chat_ret == -1);
ASSERT(response.error != 0);
ASSERT(response.content != NULL);
ASSERT(strstr(response.content, "anthropic/claude-sonnet-4-20250514") != NULL);
ASSERT(strstr(response.content, "HTTP ") == NULL);
ASSERT(provider_error_allows_fallback_retry(response.content) == 1);
provider_response_clear(&response);
return 0;
}

#ifdef SHELLCLAW_TEST
static int test_parse_malformed_json_sets_error(void)
{
Expand Down Expand Up @@ -219,6 +265,7 @@ int main(void)
RUN(test_init_fails_without_api_key_in_env());
RUN(test_chat_fails_without_init());
RUN(test_init_and_chat_if_key_set());
RUN(test_prefixed_claude_model_skips_without_http());
#ifdef SHELLCLAW_TEST
RUN(test_parse_malformed_json_sets_error());
RUN(test_parse_api_error_object_sets_error());
Expand Down
Loading
Loading