From bd3b60bee3de1bf24454553d8d6667de638807bc Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 17 Oct 2026 12:04:28 -0300 Subject: [PATCH 1/3] fix(providers): map gateway model ids before cloud fallback config.example.toml sets agent.model to anthropic/claude-sonnet-4-20250514. Both cloud backends forwarded that string. Anthropic rejects the vendor prefix with HTTP 4xx, which stops the fallback chain before local. OpenAI receives the same Claude id after an Anthropic 5xx and returns HTTP 400, which also stops the chain. Strip a matching vendor prefix and skip a foreign cloud id with a non-HTTP error so the router can continue. Supersedes #118. --- config.example.toml | 2 ++ src/providers/anthropic.c | 61 ++++++++++++++++++++++++++++++--- src/providers/openai.c | 17 +++++++-- src/providers/provider.h | 13 +++++++ src/providers/provider_common.c | 59 +++++++++++++++++++++++++++++++ tests/test_anthropic.c | 47 +++++++++++++++++++++++++ tests/test_openai.c | 47 +++++++++++++++++++++++++ tests/test_provider.c | 38 ++++++++++++++++++++ 8 files changed, 277 insertions(+), 7 deletions(-) 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..044194c 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,10 +25,12 @@ 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; char *body; + char errmsg[256]; int ret; if (!s_oai_cfg) { provider_set_error(response, "OpenAI provider not initialized"); @@ -36,11 +39,19 @@ 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) { + 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; } From a97d3aafecacd40b6a2a557923f7fd6a9216321b Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 17 Oct 2026 12:04:33 -0300 Subject: [PATCH 2/3] docs(changelog): record the model id mapping fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff8856e..0505153 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. - Live config reload reaches the Anthropic provider (`provider_anthropic_set_live_config`, guarded like OpenAI), so dashboard/SIGHUP reloads update model and `max_tokens` instead of staying on the config captured at init. - Parallel tool results are grouped into one `user` message (one `tool_result` block per call, per the Messages API contract), fixing Anthropic 400s on parallel tool calls. - The local provider re-probes on the next request after an unreachable startup, at most once every 15 seconds, so a down `llama-server` no longer costs synchronous curl probes on every agent turn. From ba2dca95deb514f720b51513c3982c043198450e Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Sat, 17 Oct 2026 12:06:27 -0300 Subject: [PATCH 3/3] style(providers): scope the OpenAI skip error message --- src/providers/openai.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/openai.c b/src/providers/openai.c index 044194c..542e61a 100644 --- a/src/providers/openai.c +++ b/src/providers/openai.c @@ -30,7 +30,6 @@ static int build_and_send(const provider_message_t *messages, size_t message_cou int max_tokens; double temperature_cfg; char *body; - char errmsg[256]; int ret; if (!s_oai_cfg) { provider_set_error(response, "OpenAI provider not initialized"); @@ -44,6 +43,7 @@ static int build_and_send(const provider_message_t *messages, size_t message_cou 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);