diff --git a/CHANGELOG.md b/CHANGELOG.md index a30ea28..d181438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/config.example.toml b/config.example.toml index 6ab83de..0bde621 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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 diff --git a/src/providers/anthropic.c b/src/providers/anthropic.c index 998aa87..561f477 100644 --- a/src/providers/anthropic.c +++ b/src/providers/anthropic.c @@ -11,6 +11,7 @@ #include "cJSON.h" #include #include +#include #include #include @@ -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) @@ -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) { diff --git a/src/providers/openai.c b/src/providers/openai.c index ad2d06c..542e61a 100644 --- a/src/providers/openai.c +++ b/src/providers/openai.c @@ -10,6 +10,7 @@ #include "providers/openai_compat.h" #include "providers/provider.h" #include "cJSON.h" +#include #include #include @@ -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; @@ -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) diff --git a/src/providers/provider.h b/src/providers/provider.h index 385b200..d151ca8 100644 --- a/src/providers/provider.h +++ b/src/providers/provider.h @@ -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); diff --git a/src/providers/provider_common.c b/src/providers/provider_common.c index fb82a62..b2249ee 100644 --- a/src/providers/provider_common.c +++ b/src/providers/provider_common.c @@ -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; diff --git a/tests/test_anthropic.c b/tests/test_anthropic.c index dba4f50..3af1281 100644 --- a/tests/test_anthropic.c +++ b/tests/test_anthropic.c @@ -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"; @@ -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]; @@ -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; diff --git a/tests/test_openai.c b/tests/test_openai.c index 3214712..9876987 100644 --- a/tests/test_openai.c +++ b/tests/test_openai.c @@ -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) { @@ -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()); diff --git a/tests/test_provider.c b/tests/test_provider.c index af4c652..e8f4c70 100644 --- a/tests/test_provider.c +++ b/tests/test_provider.c @@ -5,6 +5,7 @@ #include "providers/provider.h" #include +#include #define ASSERT(c) do { if (!(c)) { fprintf(stderr, "FAIL: %s:%d %s\n", __FILE__, __LINE__, #c); return 1; } } while (0) @@ -30,10 +31,47 @@ static int test_provider_set_error_null_response(void) return 0; } +static int test_model_id_for_backend(void) +{ + const char *example = "anthropic/claude-sonnet-4-20250514"; + const char *bare_claude = "claude-sonnet-4-5"; + const char *openai_prefixed = "openai/gpt-4o"; + const char *bare_gpt = "gpt-4o"; + const char *local_name = "Phi-3-mini"; + const char *empty = ""; + const char *stripped; + + stripped = provider_model_id_for_backend(example, "anthropic"); + ASSERT(stripped == example + strlen("anthropic/")); + ASSERT(strcmp(stripped, "claude-sonnet-4-20250514") == 0); + ASSERT(provider_model_id_for_backend(example, "openai") == NULL); + ASSERT(provider_model_id_for_backend(bare_claude, "openai") == NULL); + ASSERT(provider_model_id_for_backend(bare_claude, "anthropic") == bare_claude); + stripped = provider_model_id_for_backend(openai_prefixed, "openai"); + ASSERT(stripped == openai_prefixed + strlen("openai/")); + ASSERT(strcmp(stripped, "gpt-4o") == 0); + ASSERT(provider_model_id_for_backend(openai_prefixed, "anthropic") == NULL); + ASSERT(provider_model_id_for_backend(bare_gpt, "anthropic") == NULL); + ASSERT(provider_model_id_for_backend(bare_gpt, "openai") == bare_gpt); + ASSERT(provider_model_id_for_backend("chatgpt-4o-latest", "anthropic") == NULL); + ASSERT(provider_model_id_for_backend("o1-preview", "anthropic") == NULL); + ASSERT(provider_model_id_for_backend("o3-mini", "anthropic") == NULL); + ASSERT(provider_model_id_for_backend("o4-mini", "anthropic") == NULL); + ASSERT(provider_model_id_for_backend(local_name, "anthropic") == local_name); + ASSERT(provider_model_id_for_backend(local_name, "openai") == local_name); + ASSERT(provider_model_id_for_backend(empty, "anthropic") == empty); + ASSERT(provider_model_id_for_backend(NULL, "openai") == NULL); + ASSERT(provider_error_allows_fallback_retry( + "OpenAI skipped: agent model is not an OpenAI id") == 1); + ASSERT(provider_error_allows_fallback_retry("OpenAI API HTTP 400") == 0); + return 0; +} + int main(void) { if (test_stub_provider_vtable() != 0) return 1; if (test_provider_set_error_null_response() != 0) return 1; + if (test_model_id_for_backend() != 0) return 1; printf("test_provider: all tests passed\n"); return 0; }