From 189d6124dad64375c7bde4754cce98219c98196f Mon Sep 17 00:00:00 2001 From: Alwin Arrasyid Date: Fri, 27 Mar 2026 16:21:41 +0700 Subject: [PATCH 1/3] feat: Azure OpenAI integration --- README.md | 2 +- docs-site/reference/README_COMPLETE.md | 7 +- main/agent.c | 53 +- main/config.h | 8 +- main/json_util.c | 322 ++++++++++++- main/json_util.h | 4 + main/llm.c | 27 ++ main/llm.h | 6 +- scripts/emulate.sh | 20 +- scripts/provision-dev.sh | 12 +- scripts/provision.sh | 168 ++++++- scripts/qemu_live_llm_bridge.py | 64 ++- scripts/test-api.sh | 12 +- test/api/provider_harness.py | 397 ++++++++++++--- test/api/test_azure_openai.py | 57 +++ test/host/mock_llm.c | 6 + test/host/test_agent.c | 61 +++ test/host/test_api_provider_harness.py | 209 +++++++- test/host/test_install_provision_scripts.py | 504 ++++++++++++++------ test/host/test_json_util_integration.c | 145 +++++- test/host/test_llm_runtime.c | 41 ++ test/host/test_qemu_live_llm_bridge.py | 49 ++ test/host/test_telegram_poll_policy.c | 3 + 23 files changed, 1923 insertions(+), 254 deletions(-) create mode 100644 test/api/test_azure_openai.py diff --git a/README.md b/README.md index b687c96..a2d6dc0 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Non-interactive install: - USB local admin console for recovery, safe mode, and pre-network bring-up - Persistent memory across reboots - Persona options: `neutral`, `friendly`, `technical`, `witty` -- Provider support for Anthropic, OpenAI, OpenRouter, and Ollama (custom endpoint) +- Provider support for Anthropic, OpenAI, Azure OpenAI, OpenRouter, and Ollama (custom endpoint) ## Hardware diff --git a/docs-site/reference/README_COMPLETE.md b/docs-site/reference/README_COMPLETE.md index 85ac306..29ff6b0 100644 --- a/docs-site/reference/README_COMPLETE.md +++ b/docs-site/reference/README_COMPLETE.md @@ -35,7 +35,7 @@ Agent: Done. GPIO2 is now off. - **Built-in and custom tools** - Ships with a pre-built set of tools, easy to extend - **GPIO control** — Read sensors, toggle relays, control LEDs - **Persistent memory** — Remembers things across reboots -- **Any LLM backend** — Anthropic, OpenAI, OpenRouter, or Ollama (custom endpoint) +- **Any LLM backend** — Anthropic, OpenAI, Azure OpenAI, OpenRouter, or Ollama (custom endpoint) - **$5 hardware** — Just an ESP32 dev board and WiFi - **~888 KiB guaranteed max binary** — Fits in dual OTA partitions with ~40% free @@ -626,6 +626,11 @@ export ANTHROPIC_API_KEY=... # OpenAI export OPENAI_API_KEY=... ./scripts/emulate.sh --live-api --live-api-provider openai + +# Azure OpenAI +export AZURE_OPENAI_API_KEY=... +export AZURE_OPENAI_API_URL="https://.openai.azure.com/openai/responses?api-version=2025-04-01-preview" +./scripts/emulate.sh --live-api --live-api-provider azure-openai ``` `--live-api` keeps QEMU offline but proxies LLM requests over UART to a host bridge process. diff --git a/main/agent.c b/main/agent.c index 8489777..d18c146 100644 --- a/main/agent.c +++ b/main/agent.c @@ -108,7 +108,7 @@ static void history_rollback_to(int marker, const char *reason) // Add a message to history static void history_add(const char *role, const char *content, - bool is_tool_use, bool is_tool_result, + bool is_tool_use, bool is_tool_result, bool is_response_item, const char *tool_id, const char *tool_name) { // Drop one oldest message when full. @@ -125,6 +125,7 @@ static void history_add(const char *role, const char *content, msg->content[sizeof(msg->content) - 1] = '\0'; msg->is_tool_use = is_tool_use; msg->is_tool_result = is_tool_result; + msg->is_response_item = is_response_item; if (tool_id) { strncpy(msg->tool_id, tool_id, sizeof(msg->tool_id) - 1); @@ -141,6 +142,11 @@ static void history_add(const char *role, const char *content, } } +static void history_add_response_item(const char *item_json) +{ + history_add("assistant", item_json, false, false, true, NULL, NULL); +} + static void queue_channel_response(const char *text) { if (!s_channel_output_queue) { @@ -502,7 +508,7 @@ static void process_message(const char *user_message, message_source_t source, i telegram_polling_paused = true; // Add user message to history - history_add("user", user_message, false, false, NULL, NULL); + history_add("user", user_message, false, false, false, NULL, NULL); int rounds = 0; bool done = false; @@ -646,9 +652,38 @@ static void process_message(const char *user_message, message_source_t source, i // Store the tool_input as JSON string for history char *input_str = cJSON_PrintUnformatted(tool_input); - // Add tool_use to history - history_add("assistant", input_str ? input_str : "{}", - true, false, tool_id, tool_name); + if (llm_uses_responses_api()) { + const cJSON *parsed = json_get_parsed_response(); + const cJSON *output = parsed ? cJSON_GetObjectItem((cJSON *)parsed, "output") : NULL; + const cJSON *item = NULL; + if (output && cJSON_IsArray(output)) { + cJSON_ArrayForEach(item, output) { + if (!cJSON_IsObject((cJSON *)item)) { + continue; + } + + // Preserve every raw Responses output item for the next turn. + // OpenAI's Responses tool-calling flow expects the model's prior + // output items (especially reasoning and tool calls) to be fed + // back alongside function_call_output items. + cJSON *copy = cJSON_Duplicate((cJSON *)item, 1); + char *item_json = NULL; + if (copy) { + cJSON_DeleteItemFromObject(copy, "_parsed_arguments"); + item_json = cJSON_PrintUnformatted(copy); + cJSON_Delete(copy); + } + if (item_json) { + history_add_response_item(item_json); + free(item_json); + } + } + } + } else { + // Add tool_use to history + history_add("assistant", input_str ? input_str : "{}", + true, false, false, tool_id, tool_name); + } free(input_str); // Check if it's a user-defined tool @@ -687,17 +722,17 @@ static void process_message(const char *user_message, message_source_t source, i } // Add tool_result to history - history_add("user", s_tool_result_buf, false, true, tool_id, NULL); + history_add("user", s_tool_result_buf, false, true, false, tool_id, NULL); json_free_parsed_response(); // Continue loop to let Claude see the result } else { // Text response - we're done if (text_out[0] != '\0') { - history_add("assistant", text_out, false, false, NULL, NULL); + history_add("assistant", text_out, false, false, false, NULL, NULL); send_response(text_out, reply_chat_id); } else { - history_add("assistant", "(No response from Claude)", false, false, NULL, NULL); + history_add("assistant", "(No response from Claude)", false, false, false, NULL, NULL); send_response("(No response from Claude)", reply_chat_id); } json_free_parsed_response(); @@ -707,7 +742,7 @@ static void process_message(const char *user_message, message_source_t source, i if (!done) { ESP_LOGW(TAG, "Max tool rounds reached"); - history_add("assistant", "(Reached max tool iterations)", false, false, NULL, NULL); + history_add("assistant", "(Reached max tool iterations)", false, false, false, NULL, NULL); send_response("(Reached max tool iterations)", reply_chat_id); telegram_resume_polling(); telegram_polling_paused = false; diff --git a/main/config.h b/main/config.h index 17eddb5..1ee5182 100644 --- a/main/config.h +++ b/main/config.h @@ -5,7 +5,7 @@ // Buffer Sizes // ----------------------------------------------------------------------------- #define LLM_REQUEST_BUF_SIZE 12288 // 12KB for outgoing JSON -#define LLM_RESPONSE_BUF_SIZE 16384 // 16KB for incoming JSON +#define LLM_RESPONSE_BUF_SIZE 32768 // 32KB for incoming JSON #define CHANNEL_RX_BUF_SIZE 512 // Input line buffer #define CHANNEL_TX_BUF_SIZE 1024 // Output response buffer for serial/web relay #define TOOL_RESULT_BUF_SIZE 512 // Tool execution result @@ -45,8 +45,9 @@ typedef enum { LLM_BACKEND_ANTHROPIC = 0, LLM_BACKEND_OPENAI = 1, - LLM_BACKEND_OPENROUTER = 2, - LLM_BACKEND_OLLAMA = 3, + LLM_BACKEND_AZURE_OPENAI = 2, + LLM_BACKEND_OPENROUTER = 3, + LLM_BACKEND_OLLAMA = 4, } llm_backend_t; #define LLM_API_URL_ANTHROPIC "https://api.anthropic.com/v1/messages" @@ -57,6 +58,7 @@ typedef enum { #define LLM_DEFAULT_MODEL_ANTHROPIC "claude-sonnet-4-6" #define LLM_DEFAULT_MODEL_OPENAI "gpt-5.4" +#define LLM_DEFAULT_MODEL_AZURE_OPENAI "gpt-5.4" #define LLM_DEFAULT_MODEL_OPENROUTER "openrouter/auto" #define LLM_DEFAULT_MODEL_OLLAMA "qwen3:8b" diff --git a/main/json_util.c b/main/json_util.c index 252848e..8a9e3be 100644 --- a/main/json_util.c +++ b/main/json_util.c @@ -14,16 +14,102 @@ static const char *TAG = "json"; // Keep parsed response tree alive for tool_input access static cJSON *s_parsed_response = NULL; +static bool model_uses_max_completion_tokens(const char *model) +{ + const char *name = model; + const char *slash = NULL; + + if (!name || name[0] == '\0') { + return false; + } + + slash = strrchr(name, '/'); + if (slash && slash[1] != '\0') { + name = slash + 1; + } + + return strncmp(name, "gpt-5", 5) == 0; +} + static bool add_token_limit_field(cJSON *root) { const char *field = "max_tokens"; - if (llm_get_backend() == LLM_BACKEND_OPENAI) { + if (llm_is_openai_format() && model_uses_max_completion_tokens(llm_get_model())) { // GPT-5 chat-completions models reject max_tokens and require max_completion_tokens. field = "max_completion_tokens"; } return cJSON_AddNumberToObject(root, field, LLM_MAX_TOKENS) != NULL; } +static cJSON *create_text_content_item(const char *type, const char *text) +{ + cJSON *item = cJSON_CreateObject(); + if (!item || + !cJSON_AddStringToObject(item, "type", type) || + !cJSON_AddStringToObject(item, "text", text)) { + cJSON_Delete(item); + return NULL; + } + return item; +} + +static cJSON *create_responses_message_item(const char *role, const char *text) +{ + cJSON *item = cJSON_CreateObject(); + cJSON *content = NULL; + cJSON *text_item = NULL; + const char *content_type = strcmp(role, "assistant") == 0 ? "output_text" : "input_text"; + + if (!item || + !cJSON_AddStringToObject(item, "type", "message") || + !cJSON_AddStringToObject(item, "role", role)) { + cJSON_Delete(item); + return NULL; + } + + content = cJSON_AddArrayToObject(item, "content"); + text_item = create_text_content_item(content_type, text); + if (!content || !text_item) { + cJSON_Delete(text_item); + cJSON_Delete(item); + return NULL; + } + + cJSON_AddItemToArray(content, text_item); + return item; +} + +static cJSON *create_responses_function_call_item(const conversation_msg_t *msg) +{ + cJSON *item = cJSON_CreateObject(); + + if (!item || + !cJSON_AddStringToObject(item, "type", "function_call") || + !cJSON_AddStringToObject(item, "call_id", msg->tool_id) || + !cJSON_AddStringToObject(item, "name", msg->tool_name) || + !cJSON_AddStringToObject(item, "arguments", msg->content)) { + cJSON_Delete(item); + return NULL; + } + + return item; +} + +static cJSON *create_responses_function_call_output_item(const conversation_msg_t *msg) +{ + cJSON *item = cJSON_CreateObject(); + + if (!item || + !cJSON_AddStringToObject(item, "type", "function_call_output") || + !cJSON_AddStringToObject(item, "call_id", msg->tool_id) || + !cJSON_AddStringToObject(item, "output", msg->content)) { + cJSON_Delete(item); + return NULL; + } + + return item; +} + static bool history_has_prior_tool_use( const conversation_msg_t *history, int index, @@ -36,6 +122,22 @@ static bool history_has_prior_tool_use( if (history[i].is_tool_use && strcmp(history[i].tool_id, tool_id) == 0) { return true; } + if (history[i].is_response_item) { + cJSON *item = cJSON_Parse(history[i].content); + if (!item) { + continue; + } + cJSON *type = cJSON_GetObjectItem(item, "type"); + cJSON *call_id = cJSON_GetObjectItem(item, "call_id"); + bool is_match = type && cJSON_IsString(type) && + call_id && cJSON_IsString(call_id) && + strcmp(type->valuestring, "function_call") == 0 && + strcmp(call_id->valuestring, tool_id) == 0; + cJSON_Delete(item); + if (is_match) { + return true; + } + } } return false; } @@ -506,6 +608,202 @@ static bool parse_openai_response( return true; } +static char *build_responses_api_request( + const char *system_prompt, + const conversation_msg_t *history, + int history_len, + const char *user_message, + const tool_def_t *tools, + int tool_count) +{ + cJSON *root = cJSON_CreateObject(); + cJSON *input = NULL; + if (!root) { + return NULL; + } + + if (!cJSON_AddStringToObject(root, "model", llm_get_model()) || + !cJSON_AddStringToObject(root, "instructions", system_prompt) || + !cJSON_AddBoolToObject(root, "parallel_tool_calls", false) || + !cJSON_AddNumberToObject(root, "max_output_tokens", LLM_MAX_TOKENS)) { + goto fail; + } + + cJSON *reasoning = cJSON_AddObjectToObject(root, "reasoning"); + if (!reasoning || !cJSON_AddStringToObject(reasoning, "effort", "low")) { + goto fail; + } + + input = cJSON_AddArrayToObject(root, "input"); + if (!input) { + goto fail; + } + + for (int i = 0; i < history_len; i++) { + cJSON *item = NULL; + if (history[i].is_response_item) { + item = cJSON_Parse(history[i].content); + } else if (history[i].is_tool_use) { + item = create_responses_function_call_item(&history[i]); + } else if (history[i].is_tool_result) { + if (!history_has_prior_tool_use(history, i, history[i].tool_id)) { + ESP_LOGW(TAG, "Skipping orphan tool_result in history[%d] (id=%s)", + i, history[i].tool_id); + continue; + } + item = create_responses_function_call_output_item(&history[i]); + } else { + item = create_responses_message_item(history[i].role, history[i].content); + } + + if (!item) { + goto fail; + } + cJSON_AddItemToArray(input, item); + } + + if (user_message && user_message[0] != '\0') { + cJSON *user_item = create_responses_message_item("user", user_message); + if (!user_item) { + goto fail; + } + cJSON_AddItemToArray(input, user_item); + } + + int user_tool_count = user_tools_count(); + if (tool_count > 0 || user_tool_count > 0) { + cJSON *tools_arr = cJSON_AddArrayToObject(root, "tools"); + if (!tools_arr) { + goto fail; + } + + for (int i = 0; i < tool_count; i++) { + cJSON *tool = cJSON_CreateObject(); + cJSON *params = cJSON_Parse(tools[i].input_schema_json); + if (!params) { + params = cJSON_CreateObject(); + } + + if (!tool || !params || + !cJSON_AddStringToObject(tool, "type", "function") || + !cJSON_AddStringToObject(tool, "name", tools[i].name) || + !cJSON_AddStringToObject(tool, "description", tools[i].description)) { + cJSON_Delete(params); + cJSON_Delete(tool); + goto fail; + } + + cJSON_AddItemToObject(tool, "parameters", params); + cJSON_AddItemToArray(tools_arr, tool); + } + + user_tool_t user_tools_arr[MAX_DYNAMIC_TOOLS]; + int loaded = user_tools_get_all(user_tools_arr, MAX_DYNAMIC_TOOLS); + for (int i = 0; i < loaded; i++) { + cJSON *tool = cJSON_CreateObject(); + cJSON *params = cJSON_CreateObject(); + cJSON *properties = cJSON_CreateObject(); + if (!tool || !params || !properties || + !cJSON_AddStringToObject(tool, "type", "function") || + !cJSON_AddStringToObject(tool, "name", user_tools_arr[i].name) || + !cJSON_AddStringToObject(tool, "description", user_tools_arr[i].description) || + !cJSON_AddStringToObject(params, "type", "object")) { + cJSON_Delete(properties); + cJSON_Delete(params); + cJSON_Delete(tool); + goto fail; + } + + cJSON_AddItemToObject(params, "properties", properties); + cJSON_AddItemToObject(tool, "parameters", params); + cJSON_AddItemToArray(tools_arr, tool); + } + } + + char *json_str = cJSON_PrintUnformatted(root); + if (!json_str) { + goto fail; + } + + cJSON_Delete(root); + return json_str; + +fail: + cJSON_Delete(root); + return NULL; +} + +static bool parse_responses_api_response( + cJSON *root, + char *text_out, + size_t text_out_len, + char *tool_name_out, + size_t tool_name_len, + char *tool_id_out, + size_t tool_id_len, + cJSON **tool_input_out) +{ + cJSON *output = cJSON_GetObjectItem(root, "output"); + if (!output || !cJSON_IsArray(output) || cJSON_GetArraySize(output) == 0) { + ESP_LOGE(TAG, "No output array in responses API result"); + return false; + } + + cJSON *item = NULL; + cJSON_ArrayForEach(item, output) { + cJSON *type = cJSON_GetObjectItem(item, "type"); + if (!type || !cJSON_IsString(type)) { + continue; + } + + if (strcmp(type->valuestring, "message") == 0) { + cJSON *content = cJSON_GetObjectItem(item, "content"); + if (!content || !cJSON_IsArray(content)) { + continue; + } + + cJSON *content_item = NULL; + cJSON_ArrayForEach(content_item, content) { + cJSON *content_type = cJSON_GetObjectItem(content_item, "type"); + cJSON *text = cJSON_GetObjectItem(content_item, "text"); + if (content_type && cJSON_IsString(content_type) && + text && cJSON_IsString(text) && + strcmp(content_type->valuestring, "output_text") == 0) { + strncpy(text_out, text->valuestring, text_out_len - 1); + text_out[text_out_len - 1] = '\0'; + break; + } + } + } else if (strcmp(type->valuestring, "function_call") == 0) { + cJSON *call_id = cJSON_GetObjectItem(item, "call_id"); + cJSON *name = cJSON_GetObjectItem(item, "name"); + cJSON *args = cJSON_GetObjectItem(item, "arguments"); + + if (call_id && cJSON_IsString(call_id)) { + strncpy(tool_id_out, call_id->valuestring, tool_id_len - 1); + tool_id_out[tool_id_len - 1] = '\0'; + } + if (name && cJSON_IsString(name)) { + strncpy(tool_name_out, name->valuestring, tool_name_len - 1); + tool_name_out[tool_name_len - 1] = '\0'; + } + if (args && cJSON_IsString(args)) { + cJSON *parsed_args = cJSON_Parse(args->valuestring); + if (!parsed_args) { + parsed_args = cJSON_CreateObject(); + } + if (parsed_args) { + cJSON_AddItemToObject(item, "_parsed_arguments", parsed_args); + *tool_input_out = parsed_args; + } + } + return true; + } + } + + return true; +} + // ----------------------------------------------------------------------------- // Public API // ----------------------------------------------------------------------------- @@ -520,7 +818,10 @@ char *json_build_request( { char *json_str; - if (llm_is_openai_format()) { + if (llm_uses_responses_api()) { + json_str = build_responses_api_request(system_prompt, history, history_len, + user_message, tools, tool_count); + } else if (llm_is_openai_format()) { json_str = build_openai_request(system_prompt, history, history_len, user_message, tools, tool_count); } else { @@ -561,7 +862,7 @@ bool json_parse_response( // Check for error (both APIs use similar format) cJSON *error = cJSON_GetObjectItem(s_parsed_response, "error"); - if (error) { + if (error && !cJSON_IsNull(error)) { cJSON *msg = cJSON_GetObjectItem(error, "message"); if (msg && cJSON_IsString(msg)) { snprintf(text_out, text_out_len, "API Error: %s", msg->valuestring); @@ -572,10 +873,14 @@ bool json_parse_response( } // Parse based on format - if (llm_is_openai_format()) { + if (llm_uses_responses_api()) { + return parse_responses_api_response(s_parsed_response, text_out, text_out_len, + tool_name_out, tool_name_len, + tool_id_out, tool_id_len, tool_input_out); + } else if (llm_is_openai_format()) { return parse_openai_response(s_parsed_response, text_out, text_out_len, - tool_name_out, tool_name_len, - tool_id_out, tool_id_len, tool_input_out); + tool_name_out, tool_name_len, + tool_id_out, tool_id_len, tool_input_out); } else { return parse_anthropic_response(s_parsed_response, text_out, text_out_len, tool_name_out, tool_name_len, @@ -590,3 +895,8 @@ void json_free_parsed_response(void) s_parsed_response = NULL; } } + +const cJSON *json_get_parsed_response(void) +{ + return s_parsed_response; +} diff --git a/main/json_util.h b/main/json_util.h index 9d47ea6..0102de3 100644 --- a/main/json_util.h +++ b/main/json_util.h @@ -14,6 +14,7 @@ typedef struct { char content[MAX_MESSAGE_LEN]; // The text or tool result bool is_tool_use; // True if this is a tool_use response bool is_tool_result; // True if this is a tool_result + bool is_response_item; // True if content is a raw Responses API item JSON blob char tool_id[64]; // Tool use ID (for tool_use/tool_result) char tool_name[32]; // Tool name (for tool_use) } conversation_msg_t; @@ -47,4 +48,7 @@ bool json_parse_response( // Free the parsed response (call after done with tool_input) void json_free_parsed_response(void); +// Borrow the currently parsed response root. Do not free or retain it. +const cJSON *json_get_parsed_response(void); + #endif // JSON_UTIL_H diff --git a/main/llm.c b/main/llm.c index 6c81b9c..135c5ef 100644 --- a/main/llm.c +++ b/main/llm.c @@ -44,6 +44,8 @@ static const char *llm_backend_name(llm_backend_t backend) return "Anthropic"; case LLM_BACKEND_OPENAI: return "OpenAI"; + case LLM_BACKEND_AZURE_OPENAI: + return "Azure OpenAI"; case LLM_BACKEND_OPENROUTER: return "OpenRouter"; case LLM_BACKEND_OLLAMA: @@ -453,6 +455,9 @@ esp_err_t llm_init(void) s_backend = LLM_BACKEND_ANTHROPIC; } else if (strcmp(backend_str, "openai") == 0) { s_backend = LLM_BACKEND_OPENAI; + } else if (strcmp(backend_str, "azure-openai") == 0 || + strcmp(backend_str, "azure_openai") == 0) { + s_backend = LLM_BACKEND_AZURE_OPENAI; } else if (strcmp(backend_str, "openrouter") == 0) { s_backend = LLM_BACKEND_OPENROUTER; } else if (strcmp(backend_str, "ollama") == 0) { @@ -498,6 +503,8 @@ esp_err_t llm_init(void) ESP_LOGI(TAG, "Backend: %s, Model: %s", llm_backend_name(s_backend), s_model); if (s_api_url_override[0] != '\0') { ESP_LOGI(TAG, "Using custom LLM API endpoint override"); + } else if (s_backend == LLM_BACKEND_AZURE_OPENAI) { + ESP_LOGW(TAG, "Azure OpenAI backend requires llm_api_url to be configured"); } else if (s_backend == LLM_BACKEND_OLLAMA) { ESP_LOGW(TAG, "Ollama backend using default loopback URL; set llm_api_url for network access"); } @@ -535,6 +542,8 @@ const char *llm_get_api_url(void) switch (s_backend) { case LLM_BACKEND_OPENAI: return LLM_API_URL_OPENAI; + case LLM_BACKEND_AZURE_OPENAI: + return ""; case LLM_BACKEND_OPENROUTER: return LLM_API_URL_OPENROUTER; case LLM_BACKEND_OLLAMA: @@ -549,6 +558,8 @@ const char *llm_get_default_model(void) switch (s_backend) { case LLM_BACKEND_OPENAI: return LLM_DEFAULT_MODEL_OPENAI; + case LLM_BACKEND_AZURE_OPENAI: + return LLM_DEFAULT_MODEL_AZURE_OPENAI; case LLM_BACKEND_OPENROUTER: return LLM_DEFAULT_MODEL_OPENROUTER; case LLM_BACKEND_OLLAMA: @@ -573,10 +584,16 @@ bool llm_stub_has_api_key_for_test(void) bool llm_is_openai_format(void) { return s_backend == LLM_BACKEND_OPENAI || + s_backend == LLM_BACKEND_AZURE_OPENAI || s_backend == LLM_BACKEND_OPENROUTER || s_backend == LLM_BACKEND_OLLAMA; } +bool llm_uses_responses_api(void) +{ + return s_backend == LLM_BACKEND_AZURE_OPENAI; +} + #ifdef CONFIG_ZCLAW_STUB_LLM // Stub response for QEMU testing static const char *get_stub_response(const char *request_json) @@ -661,6 +678,14 @@ esp_err_t llm_request(const char *request_json, char *response_buf, size_t respo return ESP_ERR_INVALID_STATE; } + if (llm_get_api_url()[0] == '\0') { + ESP_LOGE(TAG, "No API URL configured for backend %s", llm_backend_name(s_backend)); + capture_net_diag_snapshot(&snapshot_after); + log_http_diag("llm_request", NULL, ESP_ERR_INVALID_STATE, -1, 0, false, + started_us, NULL, &snapshot_before, &snapshot_after); + return ESP_ERR_INVALID_STATE; + } + http_gate_wait_ms = (telegram_poll_timeout_for_backend(s_backend) * 1000) + 1000; gate_acquired = http_gate_acquire("llm_request", pdMS_TO_TICKS(http_gate_wait_ms)); if (!gate_acquired) { @@ -705,6 +730,8 @@ esp_err_t llm_request(const char *request_json, char *response_buf, size_t respo if (s_backend == LLM_BACKEND_ANTHROPIC) { esp_http_client_set_header(client, "x-api-key", s_api_key); esp_http_client_set_header(client, "anthropic-version", "2023-06-01"); + } else if (s_backend == LLM_BACKEND_AZURE_OPENAI) { + esp_http_client_set_header(client, "api-key", s_api_key); } else if (s_backend == LLM_BACKEND_OPENAI || s_backend == LLM_BACKEND_OPENROUTER || (s_backend == LLM_BACKEND_OLLAMA && s_api_key[0] != '\0')) { // OpenAI/OpenRouter use Bearer token. For Ollama, Bearer is optional and only sent diff --git a/main/llm.h b/main/llm.h index d69c2ab..81e16ce 100644 --- a/main/llm.h +++ b/main/llm.h @@ -4,6 +4,7 @@ #include "config.h" #include "esp_err.h" #include +#include // Initialize the LLM HTTP client esp_err_t llm_init(void); @@ -30,9 +31,12 @@ const char *llm_get_default_model(void); // Get current model (user-configured or default) const char *llm_get_model(void); -// Check if backend uses OpenAI-compatible format (OpenAI, OpenRouter, Ollama) +// Check if backend uses OpenAI-compatible format (OpenAI, Azure OpenAI, OpenRouter, Ollama) bool llm_is_openai_format(void); +// Check if backend uses the Responses API request/response shape. +bool llm_uses_responses_api(void); + #if CONFIG_ZCLAW_STUB_LLM // Host-test helper: indicates whether an API key is currently loaded in runtime state. bool llm_stub_has_api_key_for_test(void); diff --git a/scripts/emulate.sh b/scripts/emulate.sh index a6f3959..f175faf 100755 --- a/scripts/emulate.sh +++ b/scripts/emulate.sh @@ -21,7 +21,7 @@ while [[ $# -gt 0 ]]; do ;; --live-api-provider) if [ $# -lt 2 ]; then - echo "Error: --live-api-provider requires a value: auto|anthropic|openai" + echo "Error: --live-api-provider requires a value: auto|anthropic|openai|azure-openai" exit 1 fi LIVE_API_PROVIDER="$2" @@ -32,14 +32,14 @@ while [[ $# -gt 0 ]]; do shift ;; *) - echo "Usage: $0 [--live-api] [--live-api-provider auto|anthropic|openai] [--live-api-logs]" + echo "Usage: $0 [--live-api] [--live-api-provider auto|anthropic|openai|azure-openai] [--live-api-logs]" exit 1 ;; esac done -if [[ "$LIVE_API_PROVIDER" != "auto" && "$LIVE_API_PROVIDER" != "anthropic" && "$LIVE_API_PROVIDER" != "openai" ]]; then - echo "Error: invalid --live-api-provider '$LIVE_API_PROVIDER' (expected auto|anthropic|openai)" +if [[ "$LIVE_API_PROVIDER" != "auto" && "$LIVE_API_PROVIDER" != "anthropic" && "$LIVE_API_PROVIDER" != "openai" && "$LIVE_API_PROVIDER" != "azure-openai" ]]; then + echo "Error: invalid --live-api-provider '$LIVE_API_PROVIDER' (expected auto|anthropic|openai|azure-openai)" exit 1 fi @@ -85,6 +85,16 @@ if [ "$LIVE_API_MODE" -eq 1 ]; then exit 1 fi ;; + azure-openai) + if [ -z "${AZURE_OPENAI_API_KEY:-}" ]; then + echo "Error: AZURE_OPENAI_API_KEY is required for --live-api-provider azure-openai" + exit 1 + fi + if [ -z "${AZURE_OPENAI_API_URL:-}" ]; then + echo "Error: AZURE_OPENAI_API_URL is required for --live-api-provider azure-openai" + exit 1 + fi + ;; auto) if [ -z "${ANTHROPIC_API_KEY:-}" ] && [ -z "${OPENAI_API_KEY:-}" ]; then echo "Error: set ANTHROPIC_API_KEY or OPENAI_API_KEY for --live-api mode" @@ -123,6 +133,8 @@ if [ "$LIVE_API_MODE" -eq 1 ]; then echo "Using ANTHROPIC_API_KEY from host environment." elif [ "$LIVE_API_PROVIDER" = "openai" ]; then echo "Using OPENAI_API_KEY from host environment." + elif [ "$LIVE_API_PROVIDER" = "azure-openai" ]; then + echo "Using AZURE_OPENAI_API_KEY and AZURE_OPENAI_API_URL from host environment." else echo "Auto mode: bridge infers provider from request format (Anthropic/OpenAI)." fi diff --git a/scripts/provision-dev.sh b/scripts/provision-dev.sh index 54a1c42..90d1164 100755 --- a/scripts/provision-dev.sh +++ b/scripts/provision-dev.sh @@ -38,7 +38,7 @@ Overrides: --port --ssid --pass - --backend anthropic | openai | openrouter | ollama + --backend anthropic | openai | azure-openai | openrouter | ollama --model --api-key --api-url Custom API endpoint URL @@ -73,6 +73,7 @@ ZCLAW_API_URL= ZCLAW_API_KEY= # Or rely on provider env vars instead: # OPENAI_API_KEY= +# AZURE_OPENAI_API_KEY= # ANTHROPIC_API_KEY= # OPENROUTER_API_KEY= # OLLAMA_API_KEY= @@ -177,6 +178,9 @@ resolve_api_key() { openai) printf '%s\n' "${OPENAI_API_KEY:-}" ;; + azure-openai) + printf '%s\n' "${AZURE_OPENAI_API_KEY:-}" + ;; anthropic) printf '%s\n' "${ANTHROPIC_API_KEY:-}" ;; @@ -357,6 +361,12 @@ if [ "$BACKEND" = "ollama" ] && [ -z "$API_URL" ]; then exit 1 fi +if [ "$BACKEND" = "azure-openai" ] && [ -z "$API_URL" ]; then + echo "Error: API URL not set for Azure OpenAI backend." + echo "Set ZCLAW_API_URL in $ENV_FILE or pass --api-url." + exit 1 +fi + if [ "$SHOW_CONFIG" = true ]; then BOT_ID="" if BOT_ID="$(extract_bot_id "$TG_TOKEN" 2>/dev/null)"; then diff --git a/scripts/provision.sh b/scripts/provision.sh index 63e7464..fed58e5 100755 --- a/scripts/provision.sh +++ b/scripts/provision.sh @@ -29,9 +29,9 @@ Options: --port Serial port (auto-detect if omitted) --ssid WiFi SSID (auto-detected when possible) --pass WiFi password (optional) - --backend anthropic | openai | openrouter | ollama + --backend anthropic | openai | azure-openai | openrouter | ollama --model Model ID (defaults by backend) - --api-key LLM API key (required for anthropic/openai/openrouter) + --api-key LLM API key (required for anthropic/openai/azure-openai/openrouter) --api-url Optional custom API endpoint URL --tg-token Telegram bot token (optional) --tg-chat-id Telegram chat ID allowlist (optional) @@ -350,6 +350,7 @@ default_model_for_backend() { case "$1" in anthropic) echo "claude-sonnet-4-6" ;; openai) echo "gpt-5.4" ;; + azure-openai) echo "gpt-5.4" ;; openrouter) echo "openrouter/auto" ;; ollama) echo "qwen3:8b" ;; *) echo "claude-sonnet-4-6" ;; @@ -372,6 +373,10 @@ load_model_menu_for_backend() { MODEL_MENU_LABELS=("gpt-5.4 (default)" "gpt-5-mini" "gpt-4.1-mini" "Other model ID") MODEL_MENU_VALUES=("gpt-5.4" "gpt-5-mini" "gpt-4.1-mini" "__custom__") ;; + azure-openai) + MODEL_MENU_LABELS=("gpt-5.4 (default)" "gpt-5-mini" "gpt-4.1-mini" "Other model ID") + MODEL_MENU_VALUES=("gpt-5.4" "gpt-5-mini" "gpt-4.1-mini" "__custom__") + ;; openrouter) MODEL_MENU_LABELS=("openrouter/auto (default)" "openai/gpt-5.2" "openai/gpt-5-mini" "anthropic/claude-sonnet-4.6" "anthropic/claude-haiku-4.5" "Other model ID") MODEL_MENU_VALUES=("openrouter/auto" "openai/gpt-5.2" "openai/gpt-5-mini" "anthropic/claude-sonnet-4.6" "anthropic/claude-haiku-4.5" "__custom__") @@ -429,11 +434,66 @@ prompt_for_model() { validate_backend() { case "$1" in - anthropic|openai|openrouter|ollama) return 0 ;; + anthropic|openai|azure-openai|openrouter|ollama) return 0 ;; *) return 1 ;; esac } +normalize_azure_openai_api_url() { + local raw="$1" + local trimmed + local base + local query + + trimmed="$(trim_spaces "$raw")" + if [ -z "$trimmed" ]; then + return 1 + fi + + if [[ ! "$trimmed" =~ ^https:// ]]; then + return 1 + fi + + base="${trimmed%%\?*}" + query="" + if [ "$base" != "$trimmed" ]; then + query="${trimmed#*\?}" + fi + base="${base%/}" + + if [[ ! "$base" =~ /openai(/v1)?/responses$ ]]; then + return 1 + fi + + if [ -z "$query" ] || [[ "$query" != *api-version=* ]]; then + return 1 + fi + + printf '%s\n' "${base}?${query}" +} + +openai_like_max_tokens_field() { + local model="$1" + local model_name="$model" + + if [[ "$model_name" == */* ]]; then + model_name="${model_name##*/}" + fi + + if [[ "$model_name" == gpt-5* ]]; then + printf '%s\n' "max_completion_tokens" + else + printf '%s\n' "max_tokens" + fi +} + +azure_openai_request_body() { + local model="$1" + cat </dev/null 2>&1; then + echo "Warning: curl not found; skipping Azure OpenAI API check." + return 2 + fi + + api_url="$(normalize_azure_openai_api_url "$api_url" || true)" + if [ -z "$api_url" ]; then + echo "Azure OpenAI API check failed: invalid Azure Responses API URL." + return 1 + fi + + req_body="$(azure_openai_request_body "$model")" + + response_file="$(mktemp -t zclaw-azure-openai-check.XXXXXX 2>/dev/null || mktemp)" + if ! http_code="$(curl -sS -o "$response_file" -w "%{http_code}" \ + -H "content-type: application/json" \ + -H "api-key: $api_key" \ + "$api_url" \ + -d "$req_body")"; then + rm -f "$response_file" + echo "Azure OpenAI API check failed: network/transport error." + return 1 + fi + + if [ "$http_code" = "200" ]; then + rm -f "$response_file" + echo "Azure OpenAI API check passed (Responses endpoint reachable)." + return 0 + fi + + echo "Azure OpenAI API check failed (HTTP $http_code)." + if command -v python3 >/dev/null 2>&1; then + python3 - "$response_file" <<'PY' +import json +import sys +from pathlib import Path + +p = Path(sys.argv[1]) +try: + data = json.loads(p.read_text(encoding="utf-8")) +except Exception: + print("Response preview: " + p.read_text(encoding="utf-8", errors="ignore")[:200]) + raise SystemExit(0) + +msg = "" +if isinstance(data, dict): + if isinstance(data.get("error"), dict): + msg = data["error"].get("message") or data["error"].get("code") or "" + elif isinstance(data.get("error"), str): + msg = data["error"] +if msg: + print("API said: " + msg) +PY + else + echo "Response preview: $(head -c 200 "$response_file")" + fi + + rm -f "$response_file" + return 1 +} + verify_ollama_endpoint() { local api_key="$1" local _model="$2" @@ -1010,13 +1140,13 @@ if [ -z "$BACKEND" ]; then if [ "$ASSUME_YES" = true ]; then BACKEND="openai" else - read -r -p "LLM provider [openai/anthropic/openrouter/ollama] (default: openai): " BACKEND + read -r -p "LLM provider [openai/azure-openai/anthropic/openrouter/ollama] (default: openai): " BACKEND BACKEND="${BACKEND:-openai}" fi fi if ! validate_backend "$BACKEND"; then - echo "Error: invalid backend '$BACKEND' (expected anthropic|openai|openrouter|ollama)" + echo "Error: invalid backend '$BACKEND' (expected anthropic|openai|azure-openai|openrouter|ollama)" exit 1 fi @@ -1044,6 +1174,21 @@ if [ "$BACKEND" = "ollama" ]; then fi fi +if [ "$BACKEND" = "azure-openai" ]; then + if [ -z "$API_URL" ]; then + if [ "$ASSUME_YES" = true ]; then + echo "Error: --api-url is required with --backend azure-openai in --yes mode" + exit 1 + fi + read -r -p "Azure OpenAI Responses API URL (for example https://.../openai/responses?api-version=...): " API_URL + fi + API_URL="$(normalize_azure_openai_api_url "$API_URL" || true)" + if [ -z "$API_URL" ]; then + echo "Error: invalid --api-url for Azure OpenAI. Expected https://.../openai/responses?api-version=... or https://.../openai/v1/responses?api-version=..." + exit 1 + fi +fi + if [ "$BACKEND" != "ollama" ] && [ -z "$API_KEY" ]; then if [ "$ASSUME_YES" = true ]; then echo "Error: --api-key is required with --yes" @@ -1069,6 +1214,10 @@ if [ "$VERIFY_API_KEY" = true ]; then VERIFY_LABEL="OpenAI" VERIFY_FN="verify_openai_api_key" ;; + azure-openai) + VERIFY_LABEL="Azure OpenAI" + VERIFY_FN="verify_azure_openai_api_key" + ;; openrouter) VERIFY_LABEL="OpenRouter" VERIFY_FN="verify_openrouter_api_key" @@ -1100,6 +1249,8 @@ if [ "$VERIFY_API_KEY" = true ]; then echo "" if [ "$BACKEND" = "ollama" ]; then read -r -p "Re-enter Ollama endpoint URL and retry? [Y/n] " retry_key + elif [ "$BACKEND" = "azure-openai" ]; then + read -r -p "Re-enter Azure OpenAI API key and URL and retry? [Y/n] " retry_key else read -r -p "Re-enter API key and retry? [Y/n] " retry_key fi @@ -1111,6 +1262,13 @@ if [ "$VERIFY_API_KEY" = true ]; then if [ -z "$API_URL" ]; then echo "Valid API URL is required." fi + elif [ "$BACKEND" = "azure-openai" ]; then + read -r -p "LLM API key (input is visible): " API_KEY + read -r -p "Azure OpenAI Responses API URL (for example https://.../openai/responses?api-version=...): " API_URL + API_URL="$(normalize_azure_openai_api_url "$API_URL" || true)" + if [ -z "$API_KEY" ] || [ -z "$API_URL" ]; then + echo "Valid API key and Azure OpenAI URL are required." + fi else read -r -p "LLM API key (input is visible): " API_KEY if [ -z "$API_KEY" ]; then diff --git a/scripts/qemu_live_llm_bridge.py b/scripts/qemu_live_llm_bridge.py index 3fa1ddb..14692f8 100644 --- a/scripts/qemu_live_llm_bridge.py +++ b/scripts/qemu_live_llm_bridge.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Run QEMU and proxy emulator LLM requests to Anthropic or OpenAI from host.""" +"""Run QEMU and proxy emulator LLM requests to Anthropic, OpenAI, or Azure OpenAI from host.""" from __future__ import annotations @@ -25,7 +25,9 @@ def build_error_payload(message: str) -> str: - return json.dumps({"error": {"message": f"Host bridge error: {message}"}}, separators=(",", ":")) + return json.dumps( + {"error": {"message": f"Host bridge error: {message}"}}, separators=(",", ":") + ) def write_host_line(stream, message: str) -> None: @@ -99,6 +101,38 @@ def call_openai(request_json: str, timeout_s: int) -> str: return build_error_payload(str(exc)) +def call_azure_openai(request_json: str, timeout_s: int) -> str: + api_key = os.environ.get("AZURE_OPENAI_API_KEY", "") + if not api_key: + return build_error_payload("AZURE_OPENAI_API_KEY is not set") + + api_url = os.environ.get("AZURE_OPENAI_API_URL", "") + if not api_url: + return build_error_payload("AZURE_OPENAI_API_URL is not set") + + req = urllib.request.Request( + api_url, + data=request_json.encode("utf-8"), + headers={ + "api-key": api_key, + "content-type": "application/json", + }, + method="POST", + ) + + try: + with urllib.request.urlopen(req, timeout=timeout_s) as resp: + body = resp.read().decode("utf-8", errors="replace") + return compact_json_or_error(body) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + if body: + return compact_json_or_error(body) + return build_error_payload(f"HTTP {exc.code}") + except Exception as exc: # pragma: no cover - network/runtime dependent + return build_error_payload(str(exc)) + + def detect_provider_from_request(request_json: str) -> str: try: payload = json.loads(request_json) @@ -108,6 +142,10 @@ def detect_provider_from_request(request_json: str) -> str: if not isinstance(payload, dict): return "openai" + # zclaw currently uses Responses-shaped payloads only for Azure OpenAI. + if "input" in payload or "instructions" in payload or "max_output_tokens" in payload: + return "azure-openai" + tools = payload.get("tools") if isinstance(tools, list) and tools: first_tool = tools[0] @@ -138,6 +176,8 @@ def resolve_provider(provider: str, request_json: str) -> str: def call_provider(provider: str, request_json: str, timeout_s: int) -> str: if provider == "openai": return call_openai(request_json, timeout_s) + if provider == "azure-openai": + return call_azure_openai(request_json, timeout_s) return call_anthropic(request_json, timeout_s) @@ -289,7 +329,9 @@ def run(self) -> int: prefix_buf.clear() mode = "suppress_resp" continue - if REQ_PREFIX_B.startswith(candidate) or RESP_PREFIX_B.startswith(candidate): + if REQ_PREFIX_B.startswith(candidate) or RESP_PREFIX_B.startswith( + candidate + ): continue os.write(sys.stdout.fileno(), candidate) @@ -311,12 +353,14 @@ def run(self) -> int: def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="QEMU live LLM bridge for zclaw emulator") + parser = argparse.ArgumentParser( + description="QEMU live LLM bridge for zclaw emulator" + ) parser.add_argument( "--provider", - choices=("auto", "anthropic", "openai"), + choices=("auto", "anthropic", "openai", "azure-openai"), default="auto", - help="Host API provider: auto-detect from request format (default), anthropic, or openai", + help="Host API provider: auto-detect from request format (default), anthropic, openai, or azure-openai", ) parser.add_argument( "--api-timeout", @@ -359,9 +403,13 @@ def main() -> int: provider_note = "auto-detect (anthropic/openai)" else: provider_note = args.provider - write_host_line(sys.stdout, f"[qemu-live-llm] Bridge active (provider: {provider_note}).") + write_host_line( + sys.stdout, f"[qemu-live-llm] Bridge active (provider: {provider_note})." + ) write_host_line(sys.stdout, "[qemu-live-llm] Press Ctrl+A then X to exit QEMU.") - bridge = QemuLiveBridge(args.qemu_cmd, args.provider, args.api_timeout, args.bridge_logs) + bridge = QemuLiveBridge( + args.qemu_cmd, args.provider, args.api_timeout, args.bridge_logs + ) with RawStdin(): return bridge.run() diff --git a/scripts/test-api.sh b/scripts/test-api.sh index 0a34f6d..877b3d7 100755 --- a/scripts/test-api.sh +++ b/scripts/test-api.sh @@ -10,11 +10,12 @@ PROVIDER="${1:-all}" MESSAGE="${2:-Create a tool to blink GPIO 2 twice and report what you did.}" usage() { - echo "Usage: $0 [anthropic|openai|openrouter|all] [message]" + echo "Usage: $0 [anthropic|openai|azure-openai|openrouter|all] [message]" echo "" echo "Env keys:" echo " ANTHROPIC_API_KEY" echo " OPENAI_API_KEY" + echo " AZURE_OPENAI_API_KEY (+ AZURE_OPENAI_API_URL)" echo " OPENROUTER_API_KEY" echo "" echo "Examples:" @@ -32,6 +33,11 @@ run_provider() { return 0 fi + if [ "$name" = "azure_openai" ] && [ -z "${AZURE_OPENAI_API_URL:-}" ]; then + echo "Skipping azure-openai (AZURE_OPENAI_API_URL not set)" + return 0 + fi + echo "Running $name API test..." python3 "$script_path" --quiet "$MESSAGE" } @@ -43,12 +49,16 @@ case "$PROVIDER" in openai) run_provider "openai" "OPENAI_API_KEY" ;; + azure-openai) + run_provider "azure_openai" "AZURE_OPENAI_API_KEY" + ;; openrouter) run_provider "openrouter" "OPENROUTER_API_KEY" ;; all) run_provider "anthropic" "ANTHROPIC_API_KEY" run_provider "openai" "OPENAI_API_KEY" + run_provider "azure_openai" "AZURE_OPENAI_API_KEY" run_provider "openrouter" "OPENROUTER_API_KEY" ;; -h|--help) diff --git a/test/api/provider_harness.py b/test/api/provider_harness.py index 274ccc2..47060c5 100644 --- a/test/api/provider_harness.py +++ b/test/api/provider_harness.py @@ -31,7 +31,10 @@ "input_schema": { "type": "object", "properties": { - "pin": {"type": "integer", "description": "GPIO pin allowed by GPIO Tool Safety policy"}, + "pin": { + "type": "integer", + "description": "GPIO pin allowed by GPIO Tool Safety policy", + }, "state": {"type": "integer", "description": "0=LOW, 1=HIGH"}, }, "required": ["pin", "state"], @@ -43,7 +46,10 @@ "input_schema": { "type": "object", "properties": { - "pin": {"type": "integer", "description": "GPIO pin allowed by GPIO Tool Safety policy"}, + "pin": { + "type": "integer", + "description": "GPIO pin allowed by GPIO Tool Safety policy", + }, }, "required": ["pin"], }, @@ -54,7 +60,10 @@ "input_schema": { "type": "object", "properties": { - "milliseconds": {"type": "integer", "description": "Time to wait in ms (max 60000)"}, + "milliseconds": { + "type": "integer", + "description": "Time to wait in ms (max 60000)", + }, }, "required": ["milliseconds"], }, @@ -65,9 +74,18 @@ "input_schema": { "type": "object", "properties": { - "sda_pin": {"type": "integer", "description": "GPIO pin for SDA (subject to GPIO Tool Safety policy)"}, - "scl_pin": {"type": "integer", "description": "GPIO pin for SCL (subject to GPIO Tool Safety policy)"}, - "frequency_hz": {"type": "integer", "description": "I2C bus speed in Hz (optional, default 100000)"}, + "sda_pin": { + "type": "integer", + "description": "GPIO pin for SDA (subject to GPIO Tool Safety policy)", + }, + "scl_pin": { + "type": "integer", + "description": "GPIO pin for SCL (subject to GPIO Tool Safety policy)", + }, + "frequency_hz": { + "type": "integer", + "description": "I2C bus speed in Hz (optional, default 100000)", + }, }, "required": ["sda_pin", "scl_pin"], }, @@ -78,11 +96,26 @@ "input_schema": { "type": "object", "properties": { - "sda_pin": {"type": "integer", "description": "GPIO pin for SDA (subject to GPIO Tool Safety policy)"}, - "scl_pin": {"type": "integer", "description": "GPIO pin for SCL (subject to GPIO Tool Safety policy)"}, - "address": {"type": "integer", "description": "7-bit I2C device address"}, - "data_hex": {"type": "string", "description": "Space-separated hex bytes to write"}, - "frequency_hz": {"type": "integer", "description": "I2C bus speed in Hz (optional, default 100000)"}, + "sda_pin": { + "type": "integer", + "description": "GPIO pin for SDA (subject to GPIO Tool Safety policy)", + }, + "scl_pin": { + "type": "integer", + "description": "GPIO pin for SCL (subject to GPIO Tool Safety policy)", + }, + "address": { + "type": "integer", + "description": "7-bit I2C device address", + }, + "data_hex": { + "type": "string", + "description": "Space-separated hex bytes to write", + }, + "frequency_hz": { + "type": "integer", + "description": "I2C bus speed in Hz (optional, default 100000)", + }, }, "required": ["sda_pin", "scl_pin", "address", "data_hex"], }, @@ -93,11 +126,26 @@ "input_schema": { "type": "object", "properties": { - "sda_pin": {"type": "integer", "description": "GPIO pin for SDA (subject to GPIO Tool Safety policy)"}, - "scl_pin": {"type": "integer", "description": "GPIO pin for SCL (subject to GPIO Tool Safety policy)"}, - "address": {"type": "integer", "description": "7-bit I2C device address"}, - "read_length": {"type": "integer", "description": "Number of bytes to read"}, - "frequency_hz": {"type": "integer", "description": "I2C bus speed in Hz (optional, default 100000)"}, + "sda_pin": { + "type": "integer", + "description": "GPIO pin for SDA (subject to GPIO Tool Safety policy)", + }, + "scl_pin": { + "type": "integer", + "description": "GPIO pin for SCL (subject to GPIO Tool Safety policy)", + }, + "address": { + "type": "integer", + "description": "7-bit I2C device address", + }, + "read_length": { + "type": "integer", + "description": "Number of bytes to read", + }, + "frequency_hz": { + "type": "integer", + "description": "I2C bus speed in Hz (optional, default 100000)", + }, }, "required": ["sda_pin", "scl_pin", "address", "read_length"], }, @@ -108,12 +156,30 @@ "input_schema": { "type": "object", "properties": { - "sda_pin": {"type": "integer", "description": "GPIO pin for SDA (subject to GPIO Tool Safety policy)"}, - "scl_pin": {"type": "integer", "description": "GPIO pin for SCL (subject to GPIO Tool Safety policy)"}, - "address": {"type": "integer", "description": "7-bit I2C device address"}, - "write_hex": {"type": "string", "description": "Space-separated hex bytes to write first"}, - "read_length": {"type": "integer", "description": "Number of bytes to read after the write"}, - "frequency_hz": {"type": "integer", "description": "I2C bus speed in Hz (optional, default 100000)"}, + "sda_pin": { + "type": "integer", + "description": "GPIO pin for SDA (subject to GPIO Tool Safety policy)", + }, + "scl_pin": { + "type": "integer", + "description": "GPIO pin for SCL (subject to GPIO Tool Safety policy)", + }, + "address": { + "type": "integer", + "description": "7-bit I2C device address", + }, + "write_hex": { + "type": "string", + "description": "Space-separated hex bytes to write first", + }, + "read_length": { + "type": "integer", + "description": "Number of bytes to read after the write", + }, + "frequency_hz": { + "type": "integer", + "description": "I2C bus speed in Hz (optional, default 100000)", + }, }, "required": ["sda_pin", "scl_pin", "address", "write_hex", "read_length"], }, @@ -124,8 +190,15 @@ "input_schema": { "type": "object", "properties": { - "pin": {"type": "integer", "description": "GPIO pin connected to the DHT data line"}, - "model": {"type": "string", "enum": ["dht11", "dht22"], "description": "DHT sensor model"}, + "pin": { + "type": "integer", + "description": "GPIO pin connected to the DHT data line", + }, + "model": { + "type": "string", + "enum": ["dht11", "dht22"], + "description": "DHT sensor model", + }, "retries": {"type": "integer", "description": "Optional retry count"}, }, "required": ["pin", "model"], @@ -177,11 +250,20 @@ "type": "object", "properties": { "type": {"type": "string", "enum": ["periodic", "daily", "once"]}, - "interval_minutes": {"type": "integer", "description": "For periodic: minutes between runs"}, - "delay_minutes": {"type": "integer", "description": "For once: minutes from now before one-time run"}, + "interval_minutes": { + "type": "integer", + "description": "For periodic: minutes between runs", + }, + "delay_minutes": { + "type": "integer", + "description": "For once: minutes from now before one-time run", + }, "hour": {"type": "integer", "description": "For daily: hour 0-23"}, "minute": {"type": "integer", "description": "For daily: minute 0-59"}, - "action": {"type": "string", "description": "What to do when triggered"}, + "action": { + "type": "string", + "description": "What to do when triggered", + }, }, "required": ["type", "action"], }, @@ -241,9 +323,18 @@ "input_schema": { "type": "object", "properties": { - "name": {"type": "string", "description": "Tool name (alphanumeric, no spaces)"}, - "description": {"type": "string", "description": "Short description for tool list"}, - "action": {"type": "string", "description": "What to do when tool is called"}, + "name": { + "type": "string", + "description": "Tool name (alphanumeric, no spaces)", + }, + "description": { + "type": "string", + "description": "Short description for tool list", + }, + "action": { + "type": "string", + "description": "What to do when tool is called", + }, }, "required": ["name", "description", "action"], }, @@ -268,26 +359,44 @@ # Simulated tool results MOCK_RESULTS = { - "gpio_write": lambda inp: f"Pin {inp.get('pin')} -> {'HIGH' if inp.get('state') else 'LOW'}", + "gpio_write": lambda inp: ( + f"Pin {inp.get('pin')} -> {'HIGH' if inp.get('state') else 'LOW'}" + ), "gpio_read": lambda inp: f"Pin {inp.get('pin')} = HIGH", "delay": lambda inp: f"Waited {inp.get('milliseconds')} ms", - "i2c_scan": lambda inp: f"No I2C devices found on SDA={inp.get('sda_pin')} SCL={inp.get('scl_pin')} @ {inp.get('frequency_hz', 100000)} Hz", + "i2c_scan": lambda inp: ( + f"No I2C devices found on SDA={inp.get('sda_pin')} SCL={inp.get('scl_pin')} @ {inp.get('frequency_hz', 100000)} Hz" + ), "i2c_write": lambda inp: f"Wrote bytes to I2C address {inp.get('address')}", - "i2c_read": lambda inp: f"Read {inp.get('read_length')} byte(s) from I2C address {inp.get('address')}: 0x00", - "i2c_write_read": lambda inp: f"Read {inp.get('read_length')} byte(s) from I2C address {inp.get('address')} after writing bytes: 0x00", - "dht_read": lambda inp: f"{inp.get('model', 'dht11').upper()} on GPIO {inp.get('pin')}: humidity=55.0%, temperature=24.0 C", + "i2c_read": lambda inp: ( + f"Read {inp.get('read_length')} byte(s) from I2C address {inp.get('address')}: 0x00" + ), + "i2c_write_read": lambda inp: ( + f"Read {inp.get('read_length')} byte(s) from I2C address {inp.get('address')} after writing bytes: 0x00" + ), + "dht_read": lambda inp: ( + f"{inp.get('model', 'dht11').upper()} on GPIO {inp.get('pin')}: humidity=55.0%, temperature=24.0 C" + ), "memory_set": lambda inp: f"Saved: {inp.get('key')} = {inp.get('value')}", "memory_get": lambda inp: f"{inp.get('key')} = example_value", "memory_list": lambda inp: "Stored keys: user_name, last_water", "memory_delete": lambda inp: f"Deleted: {inp.get('key')}", - "cron_set": lambda inp: f"Created schedule #1: {inp.get('type')} -> {inp.get('action')}", + "cron_set": lambda inp: ( + f"Created schedule #1: {inp.get('type')} -> {inp.get('action')}" + ), "cron_list": lambda inp: "No scheduled tasks", "cron_delete": lambda inp: f"Deleted schedule #{inp.get('id')}", "get_time": lambda inp: "2026-02-21 14:30:00 UTC", "get_version": lambda inp: "zclaw v2.0.4", - "get_health": lambda inp: "Health: OK | Heap: 180000 free | Requests: 5/hr, 20/day | Time: synced", - "get_diagnostics": lambda inp: "Diagnostics: uptime=2h 14m | heap=180000/120000/90000 | req=5/hr,20/day", - "create_tool": lambda inp: f"Created tool '{inp.get('name')}': {inp.get('description')}", + "get_health": lambda inp: ( + "Health: OK | Heap: 180000 free | Requests: 5/hr, 20/day | Time: synced" + ), + "get_diagnostics": lambda inp: ( + "Diagnostics: uptime=2h 14m | heap=180000/120000/90000 | req=5/hr,20/day" + ), + "create_tool": lambda inp: ( + f"Created tool '{inp.get('name')}': {inp.get('description')}" + ), "list_user_tools": lambda inp: "No user tools defined", "delete_user_tool": lambda inp: f"Deleted tool '{inp.get('name')}'", } @@ -296,7 +405,7 @@ @dataclass(frozen=True) class ProviderConfig: name: str - api_url: str + api_url: str | None default_model: str model_env: str api_key_env: str @@ -320,6 +429,14 @@ class ProviderConfig: api_key_env="OPENAI_API_KEY", wire_format="openai", ), + "azure-openai": ProviderConfig( + name="azure-openai", + api_url=None, + default_model="gpt-5.4", + model_env="AZURE_OPENAI_MODEL", + api_key_env="AZURE_OPENAI_API_KEY", + wire_format="responses", + ), "openrouter": ProviderConfig( name="openrouter", api_url="https://openrouter.ai/api/v1/chat/completions", @@ -331,7 +448,9 @@ class ProviderConfig: } -def _tool_defs_for_provider(provider: ProviderConfig, user_tools: list[dict[str, str]]) -> list[dict[str, Any]]: +def _tool_defs_for_provider( + provider: ProviderConfig, user_tools: list[dict[str, str]] +) -> list[dict[str, Any]]: base = copy.deepcopy(TOOLS) for ut in user_tools: base.append( @@ -345,6 +464,17 @@ def _tool_defs_for_provider(provider: ProviderConfig, user_tools: list[dict[str, if provider.wire_format == "anthropic": return base + if provider.wire_format == "responses": + return [ + { + "type": "function", + "name": tool["name"], + "description": tool["description"], + "parameters": tool["input_schema"], + } + for tool in base + ] + return [ { "type": "function", @@ -376,6 +506,14 @@ def call_api( if httpx is None: raise RuntimeError("httpx is required for live API tests (pip install httpx)") + api_url = provider.api_url + if provider.name == "azure-openai": + api_url = os.environ.get("AZURE_OPENAI_API_URL", "") + if not api_url: + raise RuntimeError( + "AZURE_OPENAI_API_URL is required for Azure OpenAI API tests" + ) + tools = _tool_defs_for_provider(provider, user_tools) if provider.wire_format == "anthropic": @@ -391,13 +529,13 @@ def call_api( "tools": tools, "messages": messages, } - else: - headers = { - "Authorization": f"Bearer {api_key}", - "content-type": "application/json", - } + elif provider.wire_format == "openai": + headers = {"content-type": "application/json"} + headers["Authorization"] = f"Bearer {api_key}" if provider.name == "openrouter": - headers["HTTP-Referer"] = os.environ.get("OPENROUTER_HTTP_REFERER", "https://github.com/tnm/zclaw") + headers["HTTP-Referer"] = os.environ.get( + "OPENROUTER_HTTP_REFERER", "https://github.com/tnm/zclaw" + ) headers["X-Title"] = os.environ.get("OPENROUTER_X_TITLE", "zclaw api tests") token_field, token_value = _openai_like_max_tokens_field(model) @@ -414,13 +552,61 @@ def call_api( "messages": messages, "tools": tools, } + else: + headers = { + "content-type": "application/json", + "api-key": api_key, + } + input_items: list[dict[str, Any]] = [] + instructions = SYSTEM_PROMPT + + for msg in messages: + if msg.get("role") == "system" and msg.get("type") is None: + instructions = str(msg.get("content", SYSTEM_PROMPT)) + continue + if msg.get("type") is not None: + input_items.append(msg) + else: + input_items.append( + { + "type": "message", + "role": msg.get("role", "user"), + "content": [ + { + "type": ( + "output_text" + if msg.get("role") == "assistant" + else "input_text" + ), + "text": str(msg.get("content", "")), + } + ], + } + ) + + payload = { + "model": model, + "instructions": instructions, + "max_output_tokens": 1024, + "parallel_tool_calls": False, + "reasoning": {"effort": "low"}, + "input": input_items, + "tools": tools, + } + + if not api_url: + raise RuntimeError( + f"Provider '{provider.name}' does not have an API URL configured" + ) - response = httpx.post(provider.api_url, headers=headers, json=payload, timeout=30) + response = httpx.post(api_url, headers=headers, json=payload, timeout=30) response.raise_for_status() return response.json() -def execute_tool(name: str, input_data: dict[str, Any], user_tools: list[dict[str, str]]) -> str: +def execute_tool( + name: str, input_data: dict[str, Any], user_tools: list[dict[str, str]] +) -> str: """Simulate tool execution.""" for ut in user_tools: if ut["name"] == name: @@ -431,7 +617,9 @@ def execute_tool(name: str, input_data: dict[str, Any], user_tools: list[dict[st return f"Unknown tool: {name}" -def handle_create_tool(input_data: dict[str, Any], user_tools: list[dict[str, str]]) -> str: +def handle_create_tool( + input_data: dict[str, Any], user_tools: list[dict[str, str]] +) -> str: """Track user-created tools in-memory for the current session.""" user_tools.append( { @@ -443,7 +631,9 @@ def handle_create_tool(input_data: dict[str, Any], user_tools: list[dict[str, st return str(MOCK_RESULTS["create_tool"](input_data)) -def _extract_anthropic_round(response: dict[str, Any]) -> tuple[str, list[dict[str, Any]], bool]: +def _extract_anthropic_round( + response: dict[str, Any], +) -> tuple[str, list[dict[str, Any]], bool]: stop_reason = response.get("stop_reason") content = response.get("content", []) text_response = "" @@ -479,7 +669,9 @@ def _parse_tool_args(arguments_raw: Any) -> dict[str, Any]: return {} -def _extract_openai_round(response: dict[str, Any]) -> tuple[str, list[dict[str, Any]], bool, dict[str, Any]]: +def _extract_openai_round( + response: dict[str, Any], +) -> tuple[str, list[dict[str, Any]], bool, dict[str, Any]]: choices = response.get("choices", []) if not choices: return "", [], True, {"role": "assistant", "content": ""} @@ -503,7 +695,10 @@ def _extract_openai_round(response: dict[str, Any]) -> tuple[str, list[dict[str, } ) - assistant_msg: dict[str, Any] = {"role": "assistant", "content": message.get("content")} + assistant_msg: dict[str, Any] = { + "role": "assistant", + "content": message.get("content"), + } if raw_tool_calls: assistant_msg["tool_calls"] = raw_tool_calls @@ -511,6 +706,39 @@ def _extract_openai_round(response: dict[str, Any]) -> tuple[str, list[dict[str, return text_response, tool_uses, done, assistant_msg +def _extract_responses_round( + response: dict[str, Any], +) -> tuple[str, list[dict[str, Any]], bool, list[dict[str, Any]]]: + output = response.get("output", []) + text_response = str(response.get("output_text") or "") + tool_uses: list[dict[str, Any]] = [] + assistant_items: list[dict[str, Any]] = [] + + for item in output: + if not isinstance(item, dict): + continue + + assistant_items.append(item) + item_type = item.get("type") + if item_type == "message": + if not text_response: + for content in item.get("content", []): + if content.get("type") == "output_text": + text_response = str(content.get("text", "")) + break + elif item_type == "function_call": + tool_uses.append( + { + "id": str(item.get("call_id", "")), + "name": str(item.get("name", "")), + "input": _parse_tool_args(item.get("arguments", "{}")), + } + ) + + done = not tool_uses + return text_response, tool_uses, done, assistant_items + + def run_conversation( provider: ProviderConfig, user_message: str, @@ -523,7 +751,7 @@ def run_conversation( messages: list[dict[str, Any]] = [{"role": "user", "content": user_message}] if verbose: - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f"PROVIDER: {provider.name}") print(f"MODEL: {model}") print(f"USER: {user_message}") @@ -532,14 +760,30 @@ def run_conversation( max_rounds = 5 for round_num in range(max_rounds): response = call_api(provider, messages, api_key, model, user_tools) + assistant_items: list[dict[str, Any]] = [] if provider.wire_format == "anthropic": text_response, tool_uses, done = _extract_anthropic_round(response) - assistant_msg = {"role": "assistant", "content": response.get("content", [])} + assistant_msg = { + "role": "assistant", + "content": response.get("content", []), + } stop_reason = response.get("stop_reason") - else: - text_response, tool_uses, done, assistant_msg = _extract_openai_round(response) + elif provider.wire_format == "openai": + text_response, tool_uses, done, assistant_msg = _extract_openai_round( + response + ) stop_reason = response.get("choices", [{}])[0].get("finish_reason") + else: + text_response, tool_uses, done, assistant_items = _extract_responses_round( + response + ) + assistant_msg = { + "type": "message", + "role": "assistant", + "content": text_response, + } + stop_reason = response.get("status") if verbose: print(f"\n--- Round {round_num + 1} (stop_reason: {stop_reason}) ---") @@ -548,14 +792,13 @@ def run_conversation( if done: if verbose: - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f"FINAL: {text_response}") print("=" * 60) return text_response - messages.append(assistant_msg) - if provider.wire_format == "anthropic": + messages.append(assistant_msg) tool_results = [] for tool_use in tool_uses: tool_name = tool_use["name"] @@ -573,9 +816,33 @@ def run_conversation( if verbose: print(f"TOOL RESULT: {result}") - tool_results.append({"type": "tool_result", "tool_use_id": tool_id, "content": result}) + tool_results.append( + {"type": "tool_result", "tool_use_id": tool_id, "content": result} + ) messages.append({"role": "user", "content": tool_results}) + elif provider.wire_format == "openai": + messages.append(assistant_msg) + for tool_use in tool_uses: + tool_name = tool_use["name"] + tool_id = tool_use["id"] + tool_input = tool_use["input"] + + if verbose: + print(f"TOOL CALL: {tool_name}({json.dumps(tool_input)})") + + if tool_name == "create_tool": + result = handle_create_tool(tool_input, user_tools) + else: + result = execute_tool(tool_name, tool_input, user_tools) + + if verbose: + print(f"TOOL RESULT: {result}") + + messages.append( + {"role": "tool", "tool_call_id": tool_id, "content": result} + ) else: + messages.extend(assistant_items) for tool_use in tool_uses: tool_name = tool_use["name"] tool_id = tool_use["id"] @@ -592,7 +859,13 @@ def run_conversation( if verbose: print(f"TOOL RESULT: {result}") - messages.append({"role": "tool", "tool_call_id": tool_id, "content": result}) + messages.append( + { + "type": "function_call_output", + "call_id": tool_id, + "output": result, + } + ) return "(Max rounds reached)" diff --git a/test/api/test_azure_openai.py b/test/api/test_azure_openai.py new file mode 100644 index 0000000..c527ef0 --- /dev/null +++ b/test/api/test_azure_openai.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Run zclaw tool-calling API harness against Azure OpenAI.""" + +import argparse +import os +import sys + +from provider_harness import PROVIDERS, interactive_mode, run_conversation + + +def main() -> None: + provider = PROVIDERS["azure-openai"] + parser = argparse.ArgumentParser( + description="Test zclaw tool calling with Azure OpenAI API" + ) + parser.add_argument("message", nargs="?", help="Message to send") + parser.add_argument( + "--interactive", "-i", action="store_true", help="Interactive mode" + ) + parser.add_argument( + "--quiet", "-q", action="store_true", help="Only show final response" + ) + parser.add_argument( + "--model", + "-m", + help=f"Model or deployment to use (default: {provider.model_env} env or {provider.default_model})", + ) + args = parser.parse_args() + + api_key = os.environ.get(provider.api_key_env) + if not api_key: + print(f"Error: {provider.api_key_env} environment variable not set") + sys.exit(1) + + if not os.environ.get("AZURE_OPENAI_API_URL"): + print("Error: AZURE_OPENAI_API_URL environment variable not set") + sys.exit(1) + + model = args.model or os.environ.get(provider.model_env, provider.default_model) + + if args.interactive: + interactive_mode(provider, api_key, model) + elif args.message: + run_conversation( + provider, + args.message, + api_key, + model, + user_tools=[], + verbose=not args.quiet, + ) + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/test/host/mock_llm.c b/test/host/mock_llm.c index 83df6ad..5c8b60a 100644 --- a/test/host/mock_llm.c +++ b/test/host/mock_llm.c @@ -129,6 +129,12 @@ const char *llm_get_model(void) bool llm_is_openai_format(void) { return s_backend == LLM_BACKEND_OPENAI || + s_backend == LLM_BACKEND_AZURE_OPENAI || s_backend == LLM_BACKEND_OPENROUTER || s_backend == LLM_BACKEND_OLLAMA; } + +bool llm_uses_responses_api(void) +{ + return s_backend == LLM_BACKEND_AZURE_OPENAI; +} diff --git a/test/host/test_agent.c b/test/host/test_agent.c index 0554a48..cfccbf0 100644 --- a/test/host/test_agent.c +++ b/test/host/test_agent.c @@ -256,6 +256,60 @@ TEST(channel_output_allows_long_response) return 0; } +TEST(responses_tool_followup_preserves_all_output_items) +{ + QueueHandle_t channel_q; + char text[CHANNEL_TX_BUF_SIZE]; + const char *tool_call_response = + "{" + "\"output\":[" + "{\"id\":\"rs_1\",\"type\":\"reasoning\",\"summary\":[]}," + "{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\"," + "\"content\":[{\"type\":\"output_text\",\"text\":\"Let me check.\"}]}," + "{\"id\":\"fc_1\",\"type\":\"function_call\",\"call_id\":\"call_resp_1\"," + "\"name\":\"gpio_write\",\"arguments\":\"{\\\"pin\\\":2,\\\"state\\\":1}\"}" + "]," + "\"status\":\"in_progress\"" + "}"; + const char *final_response = + "{" + "\"output\":[" + "{\"type\":\"message\",\"role\":\"assistant\"," + "\"content\":[{\"type\":\"output_text\",\"text\":\"done\"}]}" + "]," + "\"output_text\":\"done\"," + "\"status\":\"completed\"" + "}"; + const char *last_request = NULL; + + reset_state(); + mock_llm_set_backend(LLM_BACKEND_AZURE_OPENAI, "gpt-5.4"); + + channel_q = xQueueCreate(4, sizeof(channel_output_msg_t)); + ASSERT(channel_q != NULL); + agent_test_set_queues(channel_q, NULL); + + ASSERT(mock_llm_push_result(ESP_OK, tool_call_response)); + ASSERT(mock_llm_push_result(ESP_OK, final_response)); + + agent_test_process_message("turn on gpio 2"); + + ASSERT(mock_llm_request_count() == 2); + ASSERT(mock_tools_execute_calls() == 1); + ASSERT(recv_channel_text(channel_q, text, sizeof(text)) == 1); + ASSERT_STR_EQ(text, "done"); + + last_request = mock_llm_last_request_json(); + ASSERT(last_request != NULL); + ASSERT(strstr(last_request, "\"type\":\"reasoning\"") != NULL); + ASSERT(strstr(last_request, "\"type\":\"message\",\"role\":\"assistant\"") != NULL); + ASSERT(strstr(last_request, "\"type\":\"function_call\",\"call_id\":\"call_resp_1\"") != NULL); + ASSERT(strstr(last_request, "\"type\":\"function_call_output\",\"call_id\":\"call_resp_1\",\"output\":\"mock tool executed\"") != NULL); + + vQueueDelete(channel_q); + return 0; +} + TEST(start_command_bypasses_llm_and_debounces) { QueueHandle_t channel_q; @@ -962,6 +1016,13 @@ int test_agent_all(void) failures++; } + printf(" responses_tool_followup_preserves_all_output_items... "); + if (test_responses_tool_followup_preserves_all_output_items() == 0) { + printf("OK\n"); + } else { + failures++; + } + printf(" start_command_bypasses_llm_and_debounces... "); if (test_start_command_bypasses_llm_and_debounces() == 0) { printf("OK\n"); diff --git a/test/host/test_api_provider_harness.py b/test/host/test_api_provider_harness.py index 7f81351..a20a536 100644 --- a/test/host/test_api_provider_harness.py +++ b/test/host/test_api_provider_harness.py @@ -69,7 +69,7 @@ def test_extract_openai_round_tool_call(self) -> None: "type": "function", "function": { "name": "gpio_write", - "arguments": "{\"pin\":2,\"state\":1}", + "arguments": '{"pin":2,"state":1}', }, } ], @@ -77,7 +77,9 @@ def test_extract_openai_round_tool_call(self) -> None: } ] } - text, tool_uses, done, assistant_msg = provider_harness._extract_openai_round(response) + text, tool_uses, done, assistant_msg = provider_harness._extract_openai_round( + response + ) self.assertEqual(text, "") self.assertFalse(done) self.assertEqual(len(tool_uses), 1) @@ -117,7 +119,9 @@ def test_call_api_openai_inserts_system_message_when_missing(self) -> None: messages = [{"role": "user", "content": "Hello"}] payload: dict[str, Any] = {} - def fake_post(url: str, headers: dict[str, str], json: dict[str, Any], timeout: int) -> Mock: + def fake_post( + url: str, headers: dict[str, str], json: dict[str, Any], timeout: int + ) -> Mock: payload["url"] = url payload["headers"] = headers payload["json"] = json @@ -128,14 +132,21 @@ def fake_post(url: str, headers: dict[str, str], json: dict[str, Any], timeout: return response with patch.object(provider_harness, "httpx", SimpleNamespace(post=fake_post)): - result = provider_harness.call_api(provider, messages, "test-key", "gpt-4.1-mini", user_tools=[]) + result = provider_harness.call_api( + provider, messages, "test-key", "gpt-4.1-mini", user_tools=[] + ) self.assertEqual(result, {"ok": True}) self.assertEqual(payload["url"], provider.api_url) self.assertEqual(payload["timeout"], 30) request_json = payload["json"] - self.assertEqual(request_json["messages"][0], {"role": "system", "content": provider_harness.SYSTEM_PROMPT}) - self.assertEqual(request_json["messages"][1], {"role": "user", "content": "Hello"}) + self.assertEqual( + request_json["messages"][0], + {"role": "system", "content": provider_harness.SYSTEM_PROMPT}, + ) + self.assertEqual( + request_json["messages"][1], {"role": "user", "content": "Hello"} + ) self.assertEqual(messages, [{"role": "user", "content": "Hello"}]) def test_call_api_openai_keeps_existing_system_message(self) -> None: @@ -146,7 +157,9 @@ def test_call_api_openai_keeps_existing_system_message(self) -> None: ] payload: dict[str, Any] = {} - def fake_post(url: str, headers: dict[str, str], json: dict[str, Any], timeout: int) -> Mock: + def fake_post( + url: str, headers: dict[str, str], json: dict[str, Any], timeout: int + ) -> Mock: payload["url"] = url payload["headers"] = headers payload["json"] = json @@ -157,11 +170,191 @@ def fake_post(url: str, headers: dict[str, str], json: dict[str, Any], timeout: return response with patch.object(provider_harness, "httpx", SimpleNamespace(post=fake_post)): - provider_harness.call_api(provider, messages, "test-key", "gpt-4.1-mini", user_tools=[]) + provider_harness.call_api( + provider, messages, "test-key", "gpt-4.1-mini", user_tools=[] + ) request_json = payload["json"] self.assertEqual(request_json["messages"], messages) + def test_call_api_azure_openai_uses_api_key_header_and_env_url(self) -> None: + provider = provider_harness.PROVIDERS["azure-openai"] + messages = [{"role": "user", "content": "Hello"}] + payload: dict[str, Any] = {} + + def fake_post( + url: str, headers: dict[str, str], json: dict[str, Any], timeout: int + ) -> Mock: + payload["url"] = url + payload["headers"] = headers + payload["json"] = json + payload["timeout"] = timeout + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = {"ok": True} + return response + + with patch.dict( + provider_harness.os.environ, + { + "AZURE_OPENAI_API_URL": "https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview" + }, + clear=False, + ): + with patch.object( + provider_harness, "httpx", SimpleNamespace(post=fake_post) + ): + result = provider_harness.call_api( + provider, messages, "test-key", "demo", user_tools=[] + ) + + self.assertEqual(result, {"ok": True}) + self.assertEqual( + payload["url"], + "https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview", + ) + self.assertEqual(payload["headers"]["api-key"], "test-key") + self.assertNotIn("Authorization", payload["headers"]) + self.assertEqual( + payload["json"]["input"][0], + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Hello"}], + }, + ) + self.assertEqual( + payload["json"]["instructions"], provider_harness.SYSTEM_PROMPT + ) + self.assertEqual(payload["json"]["parallel_tool_calls"], False) + self.assertEqual(payload["json"]["reasoning"], {"effort": "low"}) + self.assertEqual(payload["json"]["tools"][0]["type"], "function") + self.assertIn("name", payload["json"]["tools"][0]) + + def test_extract_responses_round_function_call(self) -> None: + response = { + "status": "completed", + "output": [ + { + "type": "function_call", + "call_id": "call_123", + "name": "gpio_write", + "arguments": '{"pin":2,"state":1}', + } + ], + } + + text, tool_uses, done, assistant_items = ( + provider_harness._extract_responses_round(response) + ) + self.assertEqual(text, "") + self.assertFalse(done) + self.assertEqual(len(tool_uses), 1) + self.assertEqual(tool_uses[0]["name"], "gpio_write") + self.assertEqual(tool_uses[0]["input"]["pin"], 2) + self.assertEqual(assistant_items[0]["type"], "function_call") + + def test_extract_responses_round_preserves_reasoning_items(self) -> None: + response = { + "status": "completed", + "output": [ + {"id": "rs_123", "type": "reasoning", "summary": []}, + { + "type": "function_call", + "call_id": "call_123", + "name": "gpio_write", + "arguments": '{"pin":2,"state":1}', + }, + ], + } + + _, tool_uses, done, assistant_items = provider_harness._extract_responses_round( + response + ) + self.assertFalse(done) + self.assertEqual(len(tool_uses), 1) + self.assertEqual(len(assistant_items), 2) + self.assertEqual(assistant_items[0]["type"], "reasoning") + self.assertEqual(assistant_items[1]["type"], "function_call") + + def test_call_api_azure_openai_encodes_assistant_history_as_output_text( + self, + ) -> None: + provider = provider_harness.PROVIDERS["azure-openai"] + messages = [ + {"role": "assistant", "content": "Earlier answer"}, + {"role": "user", "content": "Next question"}, + ] + payload: dict[str, Any] = {} + + def fake_post( + url: str, headers: dict[str, str], json: dict[str, Any], timeout: int + ) -> Mock: + payload["json"] = json + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = {"ok": True} + return response + + with patch.dict( + provider_harness.os.environ, + { + "AZURE_OPENAI_API_URL": "https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview" + }, + clear=False, + ): + with patch.object( + provider_harness, "httpx", SimpleNamespace(post=fake_post) + ): + provider_harness.call_api( + provider, messages, "test-key", "demo", user_tools=[] + ) + + self.assertEqual( + payload["json"]["input"][0]["content"][0]["type"], "output_text" + ) + self.assertEqual( + payload["json"]["input"][1]["content"][0]["type"], "input_text" + ) + + def test_call_api_azure_openai_preserves_raw_response_items(self) -> None: + provider = provider_harness.PROVIDERS["azure-openai"] + messages = [ + {"id": "rs_123", "type": "reasoning", "summary": []}, + { + "type": "function_call_output", + "call_id": "call_123", + "output": "ok", + }, + ] + payload: dict[str, Any] = {} + + def fake_post( + url: str, headers: dict[str, str], json: dict[str, Any], timeout: int + ) -> Mock: + payload["json"] = json + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = {"ok": True} + return response + + with patch.dict( + provider_harness.os.environ, + { + "AZURE_OPENAI_API_URL": "https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview" + }, + clear=False, + ): + with patch.object( + provider_harness, "httpx", SimpleNamespace(post=fake_post) + ): + provider_harness.call_api( + provider, messages, "test-key", "demo", user_tools=[] + ) + + self.assertEqual(payload["json"]["input"][0]["type"], "reasoning") + self.assertEqual(payload["json"]["input"][1]["type"], "function_call_output") + if __name__ == "__main__": unittest.main() diff --git a/test/host/test_install_provision_scripts.py b/test/host/test_install_provision_scripts.py index 28bbd5f..ad73ad3 100644 --- a/test/host/test_install_provision_scripts.py +++ b/test/host/test_install_provision_scripts.py @@ -35,7 +35,7 @@ def _prepare_fake_idf_env(self, tmp: Path) -> tuple[dict[str, str], Path]: export_dir = home / "esp" / "esp-idf" export_dir.mkdir(parents=True, exist_ok=True) (export_dir / "export.sh").write_text( - "export IDF_PATH=\"$HOME/esp/esp-idf\"\n", + 'export IDF_PATH="$HOME/esp/esp-idf"\n', encoding="utf-8", ) @@ -48,7 +48,9 @@ def _prepare_fake_idf_env(self, tmp: Path) -> tuple[dict[str, str], Path]: env["TERM"] = "dumb" return env, bin_dir - def _run_install_with_prefs(self, prefs_text: str, extra_args: list[str]) -> subprocess.CompletedProcess[str]: + def _run_install_with_prefs( + self, prefs_text: str, extra_args: list[str] + ) -> subprocess.CompletedProcess[str]: with tempfile.TemporaryDirectory() as td: tmp = Path(td) home = tmp / "home" @@ -132,24 +134,21 @@ def test_install_linux_uses_pacman_for_optional_dependencies(self) -> None: _write_executable( bin_dir / "uname", - "#!/bin/sh\n" - "printf '%s\\n' 'Linux'\n", + "#!/bin/sh\nprintf '%s\\n' 'Linux'\n", ) _write_executable( bin_dir / "sudo", - "#!/bin/sh\n" - "\"$@\"\n", + '#!/bin/sh\n"$@"\n', ) _write_executable( bin_dir / "apt-get", - "#!/bin/sh\n" - "exit 127\n", + "#!/bin/sh\nexit 127\n", ) _write_executable( bin_dir / "pacman", "#!/bin/sh\n" - "printf 'pacman %s\\n' \"$*\" >> \"$PKG_LOG\"\n" - "if [ \"$1\" = \"--version\" ]; then\n" + 'printf \'pacman %s\\n\' "$*" >> "$PKG_LOG"\n' + 'if [ "$1" = "--version" ]; then\n' " exit 0\n" "fi\n" "exit 0\n", @@ -192,7 +191,9 @@ def test_install_linux_uses_pacman_for_optional_dependencies(self) -> None: else: self.assertIn("cJSON library found", output) - def test_install_linux_unknown_package_manager_skips_optional_auto_install(self) -> None: + def test_install_linux_unknown_package_manager_skips_optional_auto_install( + self, + ) -> None: with tempfile.TemporaryDirectory() as td: tmp = Path(td) home = tmp / "home" @@ -202,14 +203,12 @@ def test_install_linux_unknown_package_manager_skips_optional_auto_install(self) _write_executable( bin_dir / "uname", - "#!/bin/sh\n" - "printf '%s\\n' 'Linux'\n", + "#!/bin/sh\nprintf '%s\\n' 'Linux'\n", ) for pm in ("apt-get", "pacman", "dnf", "zypper"): _write_executable( bin_dir / pm, - "#!/bin/sh\n" - "exit 127\n", + "#!/bin/sh\nexit 127\n", ) env = os.environ.copy() @@ -274,8 +273,7 @@ def test_build_box3_passes_expected_idf_overrides(self) -> None: _write_executable( bin_dir / "idf.py", - "#!/bin/sh\n" - "printf '%s\\n' \"$@\" > \"$IDF_ARGS_FILE\"\n", + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$IDF_ARGS_FILE"\n', ) proc = subprocess.run( @@ -291,7 +289,10 @@ def test_build_box3_passes_expected_idf_overrides(self) -> None: self.assertEqual(proc.returncode, 0, msg=output) args_text = args_file.read_text(encoding="utf-8") self.assertIn("IDF_TARGET=esp32s3", args_text) - self.assertIn("SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.esp32s3-box-3.defaults", args_text) + self.assertIn( + "SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.esp32s3-box-3.defaults", + args_text, + ) self.assertIn("build", args_text) def test_build_t_relay_passes_expected_idf_overrides(self) -> None: @@ -303,8 +304,7 @@ def test_build_t_relay_passes_expected_idf_overrides(self) -> None: _write_executable( bin_dir / "idf.py", - "#!/bin/sh\n" - "printf '%s\\n' \"$@\" > \"$IDF_ARGS_FILE\"\n", + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$IDF_ARGS_FILE"\n', ) proc = subprocess.run( @@ -320,7 +320,10 @@ def test_build_t_relay_passes_expected_idf_overrides(self) -> None: self.assertEqual(proc.returncode, 0, msg=output) args_text = args_file.read_text(encoding="utf-8") self.assertIn("IDF_TARGET=esp32", args_text) - self.assertIn("SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.esp32-t-relay.defaults", args_text) + self.assertIn( + "SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.esp32-t-relay.defaults", + args_text, + ) self.assertIn("build", args_text) def test_flash_box3_passes_expected_idf_overrides(self) -> None: @@ -334,13 +337,11 @@ def test_flash_box3_passes_expected_idf_overrides(self) -> None: _write_executable( bin_dir / "idf.py", - "#!/bin/sh\n" - "printf '%s\\n' \"$@\" > \"$IDF_ARGS_FILE\"\n", + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$IDF_ARGS_FILE"\n', ) _write_executable( bin_dir / "lsof", - "#!/bin/sh\n" - "exit 1\n", + "#!/bin/sh\nexit 1\n", ) _write_executable( bin_dir / "esptool.py", @@ -352,8 +353,7 @@ def test_flash_box3_passes_expected_idf_overrides(self) -> None: ) _write_executable( bin_dir / "espefuse.py", - "#!/bin/sh\n" - "printf '%s\\n' 'FLASH_CRYPT_CNT = 0'\n", + "#!/bin/sh\nprintf '%s\\n' 'FLASH_CRYPT_CNT = 0'\n", ) proc = subprocess.run( @@ -369,7 +369,10 @@ def test_flash_box3_passes_expected_idf_overrides(self) -> None: self.assertEqual(proc.returncode, 0, msg=output) args_text = args_file.read_text(encoding="utf-8") self.assertIn("IDF_TARGET=esp32s3", args_text) - self.assertIn("SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.esp32s3-box-3.defaults", args_text) + self.assertIn( + "SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.esp32s3-box-3.defaults", + args_text, + ) self.assertIn("-p", args_text) self.assertIn(str(fake_port), args_text) self.assertIn("flash", args_text) @@ -385,13 +388,11 @@ def test_flash_t_relay_passes_expected_idf_overrides(self) -> None: _write_executable( bin_dir / "idf.py", - "#!/bin/sh\n" - "printf '%s\\n' \"$@\" > \"$IDF_ARGS_FILE\"\n", + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$IDF_ARGS_FILE"\n', ) _write_executable( bin_dir / "lsof", - "#!/bin/sh\n" - "exit 1\n", + "#!/bin/sh\nexit 1\n", ) _write_executable( bin_dir / "esptool.py", @@ -403,8 +404,7 @@ def test_flash_t_relay_passes_expected_idf_overrides(self) -> None: ) _write_executable( bin_dir / "espefuse.py", - "#!/bin/sh\n" - "printf '%s\\n' 'FLASH_CRYPT_CNT = 0'\n", + "#!/bin/sh\nprintf '%s\\n' 'FLASH_CRYPT_CNT = 0'\n", ) proc = subprocess.run( @@ -420,7 +420,10 @@ def test_flash_t_relay_passes_expected_idf_overrides(self) -> None: self.assertEqual(proc.returncode, 0, msg=output) args_text = args_file.read_text(encoding="utf-8") self.assertIn("IDF_TARGET=esp32", args_text) - self.assertIn("SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.esp32-t-relay.defaults", args_text) + self.assertIn( + "SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.esp32-t-relay.defaults", + args_text, + ) self.assertIn("-p", args_text) self.assertIn(str(fake_port), args_text) self.assertIn("flash", args_text) @@ -435,7 +438,14 @@ def test_flash_box3_detects_chip_with_idf_esptool_fallback(self) -> None: env["IDF_ARGS_FILE"] = str(args_file) esptool_script = ( - tmp / "home" / "esp" / "esp-idf" / "components" / "esptool_py" / "esptool" / "esptool.py" + tmp + / "home" + / "esp" + / "esp-idf" + / "components" + / "esptool_py" + / "esptool" + / "esptool.py" ) esptool_script.parent.mkdir(parents=True, exist_ok=True) esptool_script.write_text( @@ -449,18 +459,15 @@ def test_flash_box3_detects_chip_with_idf_esptool_fallback(self) -> None: _write_executable( bin_dir / "idf.py", - "#!/bin/sh\n" - "printf '%s\\n' \"$@\" > \"$IDF_ARGS_FILE\"\n", + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$IDF_ARGS_FILE"\n', ) _write_executable( bin_dir / "lsof", - "#!/bin/sh\n" - "exit 1\n", + "#!/bin/sh\nexit 1\n", ) _write_executable( bin_dir / "espefuse.py", - "#!/bin/sh\n" - "printf '%s\\n' 'FLASH_CRYPT_CNT = 0'\n", + "#!/bin/sh\nprintf '%s\\n' 'FLASH_CRYPT_CNT = 0'\n", ) proc = subprocess.run( @@ -487,13 +494,11 @@ def test_flash_box3_rejects_non_s3_chip(self) -> None: _write_executable( bin_dir / "lsof", - "#!/bin/sh\n" - "exit 1\n", + "#!/bin/sh\nexit 1\n", ) _write_executable( bin_dir / "esptool.py", - "#!/bin/sh\n" - "printf '%s\\n' 'Chip is ESP32-C3 (QFN32)'\n", + "#!/bin/sh\nprintf '%s\\n' 'Chip is ESP32-C3 (QFN32)'\n", ) proc = subprocess.run( @@ -519,13 +524,11 @@ def test_flash_t_relay_rejects_non_esp32_chip(self) -> None: _write_executable( bin_dir / "lsof", - "#!/bin/sh\n" - "exit 1\n", + "#!/bin/sh\nexit 1\n", ) _write_executable( bin_dir / "esptool.py", - "#!/bin/sh\n" - "printf '%s\\n' 'Chip is ESP32-S3 (QFN56)'\n", + "#!/bin/sh\nprintf '%s\\n' 'Chip is ESP32-S3 (QFN56)'\n", ) proc = subprocess.run( @@ -542,7 +545,9 @@ def test_flash_t_relay_rejects_non_esp32_chip(self) -> None: self.assertIn("requires target 'esp32'", output) self.assertIn("ESP32-S3", output) - def _run_provision_detect(self, env_ssid: str, nmcli_output: str) -> subprocess.CompletedProcess[str]: + def _run_provision_detect( + self, env_ssid: str, nmcli_output: str + ) -> subprocess.CompletedProcess[str]: with tempfile.TemporaryDirectory() as td: tmp = Path(td) bin_dir = tmp / "bin" @@ -550,13 +555,11 @@ def _run_provision_detect(self, env_ssid: str, nmcli_output: str) -> subprocess. _write_executable( bin_dir / "uname", - "#!/bin/sh\n" - "echo Linux\n", + "#!/bin/sh\necho Linux\n", ) _write_executable( bin_dir / "nmcli", - "#!/bin/sh\n" - f"printf '%s\\n' '{nmcli_output}'\n", + f"#!/bin/sh\nprintf '%s\\n' '{nmcli_output}'\n", ) env = os.environ.copy() @@ -585,7 +588,11 @@ def test_provision_detect_uses_non_placeholder_env_ssid(self) -> None: self.assertEqual(proc.returncode, 0, msg=output) self.assertEqual(proc.stdout.strip(), ":smiley:") - def _run_provision_api_check_fail(self, backend: str) -> subprocess.CompletedProcess[str]: + def _run_provision_api_check_fail( + self, + backend: str, + api_url: str | None = None, + ) -> subprocess.CompletedProcess[str]: with tempfile.TemporaryDirectory() as td: tmp = Path(td) bin_dir = tmp / "bin" @@ -595,7 +602,7 @@ def _run_provision_api_check_fail(self, backend: str) -> subprocess.CompletedPro export_dir = home / "esp" / "esp-idf" export_dir.mkdir(parents=True, exist_ok=True) (export_dir / "export.sh").write_text( - "export IDF_PATH=\"$HOME/esp/esp-idf\"\n", + 'export IDF_PATH="$HOME/esp/esp-idf"\n', encoding="utf-8", ) @@ -604,11 +611,11 @@ def _run_provision_api_check_fail(self, backend: str) -> subprocess.CompletedPro "#!/bin/sh\n" "out=''\n" "while [ $# -gt 0 ]; do\n" - " if [ \"$1\" = \"-o\" ]; then out=\"$2\"; shift 2; continue; fi\n" + ' if [ "$1" = "-o" ]; then out="$2"; shift 2; continue; fi\n' " shift\n" "done\n" - "if [ -n \"$out\" ]; then\n" - " printf '%s' '{\"error\":{\"message\":\"invalid api key\"}}' > \"$out\"\n" + 'if [ -n "$out" ]; then\n' + ' printf \'%s\' \'{"error":{"message":"invalid api key"}}\' > "$out"\n' "fi\n" "printf '%s' '401'\n", ) @@ -618,21 +625,25 @@ def _run_provision_api_check_fail(self, backend: str) -> subprocess.CompletedPro env["PATH"] = f"{bin_dir}:/usr/bin:/bin" env["TERM"] = "dumb" + cmd = [ + str(PROVISION_SH), + "--yes", + "--port", + "/dev/null", + "--ssid", + "TestNet", + "--pass", + "password123", + "--backend", + backend, + "--api-key", + "sk-test", + ] + if api_url is not None: + cmd.extend(["--api-url", api_url]) + return subprocess.run( - [ - str(PROVISION_SH), - "--yes", - "--port", - "/dev/null", - "--ssid", - "TestNet", - "--pass", - "password123", - "--backend", - backend, - "--api-key", - "sk-test", - ], + cmd, cwd=PROJECT_ROOT, env=env, text=True, @@ -656,7 +667,7 @@ def _run_provision_api_check_capture_url( export_dir = home / "esp" / "esp-idf" export_dir.mkdir(parents=True, exist_ok=True) (export_dir / "export.sh").write_text( - "export IDF_PATH=\"$HOME/esp/esp-idf\"\n", + 'export IDF_PATH="$HOME/esp/esp-idf"\n', encoding="utf-8", ) @@ -666,9 +677,9 @@ def _run_provision_api_check_capture_url( "out=''\n" "url=''\n" "while [ $# -gt 0 ]; do\n" - " case \"$1\" in\n" + ' case "$1" in\n' " -o)\n" - " out=\"$2\"\n" + ' out="$2"\n' " shift 2\n" " ;;\n" " -w|-H|-d|--connect-timeout|--max-time)\n" @@ -678,14 +689,14 @@ def _run_provision_api_check_capture_url( " shift\n" " ;;\n" " *)\n" - " url=\"$1\"\n" + ' url="$1"\n' " shift\n" " ;;\n" " esac\n" "done\n" - "printf '%s' \"$url\" > \"$CURL_URL_FILE\"\n" - "if [ -n \"$out\" ]; then\n" - " printf '%s' '{\"error\":{\"message\":\"invalid api key\"}}' > \"$out\"\n" + 'printf \'%s\' "$url" > "$CURL_URL_FILE"\n' + 'if [ -n "$out" ]; then\n' + ' printf \'%s\' \'{"error":{"message":"invalid api key"}}\' > "$out"\n' "fi\n" "printf '%s' '401'\n", ) @@ -720,7 +731,11 @@ def _run_provision_api_check_capture_url( check=False, ) - called_url = curl_url_file.read_text(encoding="utf-8") if curl_url_file.exists() else "" + called_url = ( + curl_url_file.read_text(encoding="utf-8") + if curl_url_file.exists() + else "" + ) return proc, called_url def _run_provision_ollama_missing_api_url(self) -> subprocess.CompletedProcess[str]: @@ -730,7 +745,7 @@ def _run_provision_ollama_missing_api_url(self) -> subprocess.CompletedProcess[s export_dir = home / "esp" / "esp-idf" export_dir.mkdir(parents=True, exist_ok=True) (export_dir / "export.sh").write_text( - "export IDF_PATH=\"$HOME/esp/esp-idf\"\n", + 'export IDF_PATH="$HOME/esp/esp-idf"\n', encoding="utf-8", ) @@ -773,7 +788,7 @@ def _run_provision_length_validation( export_dir = home / "esp" / "esp-idf" export_dir.mkdir(parents=True, exist_ok=True) (export_dir / "export.sh").write_text( - "export IDF_PATH=\"$HOME/esp/esp-idf\"\n", + 'export IDF_PATH="$HOME/esp/esp-idf"\n', encoding="utf-8", ) @@ -822,14 +837,20 @@ def _run_provision_capture_csv( tmp = Path(td) home = tmp / "home" idf_dir = home / "esp" / "esp-idf" - nvs_gen = idf_dir / "components" / "nvs_flash" / "nvs_partition_generator" / "nvs_partition_gen.py" + nvs_gen = ( + idf_dir + / "components" + / "nvs_flash" + / "nvs_partition_generator" + / "nvs_partition_gen.py" + ) parttool = idf_dir / "components" / "partition_table" / "parttool.py" nvs_gen.parent.mkdir(parents=True, exist_ok=True) parttool.parent.mkdir(parents=True, exist_ok=True) nvs_gen.write_text("# nvs generator stub path\n", encoding="utf-8") parttool.write_text("# parttool stub path\n", encoding="utf-8") (idf_dir / "export.sh").write_text( - "export IDF_PATH=\"$HOME/esp/esp-idf\"\n", + 'export IDF_PATH="$HOME/esp/esp-idf"\n', encoding="utf-8", ) @@ -838,9 +859,9 @@ def _run_provision_capture_csv( _write_executable( bin_dir / "python", "#!/bin/sh\n" - "if [ \"$2\" = \"generate\" ]; then\n" - " cp \"$3\" \"$CSV_CAPTURE\"\n" - " : > \"$4\"\n" + 'if [ "$2" = "generate" ]; then\n' + ' cp "$3" "$CSV_CAPTURE"\n' + ' : > "$4"\n' " exit 0\n" "fi\n" "exit 0\n", @@ -881,7 +902,11 @@ def _run_provision_capture_csv( check=False, ) - captured_csv = (tmp / "captured-nvs.csv").read_text(encoding="utf-8") if (tmp / "captured-nvs.csv").exists() else "" + captured_csv = ( + (tmp / "captured-nvs.csv").read_text(encoding="utf-8") + if (tmp / "captured-nvs.csv").exists() + else "" + ) return proc, captured_csv def test_provision_openai_api_check_runs_in_yes_mode(self) -> None: @@ -898,7 +923,19 @@ def test_provision_openrouter_api_check_runs_in_yes_mode(self) -> None: self.assertIn("Verifying OpenRouter API key", output) self.assertIn("Error: API check failed in --yes mode.", output) - def test_provision_openai_api_check_uses_models_endpoint_for_chat_override(self) -> None: + def test_provision_azure_openai_api_check_runs_in_yes_mode(self) -> None: + proc = self._run_provision_api_check_fail( + "azure-openai", + api_url="https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview", + ) + output = f"{proc.stdout}\n{proc.stderr}" + self.assertNotEqual(proc.returncode, 0, msg=output) + self.assertIn("Verifying Azure OpenAI API key", output) + self.assertIn("Error: API check failed in --yes mode.", output) + + def test_provision_openai_api_check_uses_models_endpoint_for_chat_override( + self, + ) -> None: proc, called_url = self._run_provision_api_check_capture_url( backend="openai", api_url="https://api.openai.com/v1/chat/completions", @@ -908,7 +945,9 @@ def test_provision_openai_api_check_uses_models_endpoint_for_chat_override(self) self.assertIn("Verifying OpenAI API key", output) self.assertEqual(called_url, "https://api.openai.com/v1/models") - def test_provision_openrouter_api_check_uses_models_endpoint_for_chat_override(self) -> None: + def test_provision_openrouter_api_check_uses_models_endpoint_for_chat_override( + self, + ) -> None: proc, called_url = self._run_provision_api_check_capture_url( backend="openrouter", api_url="https://openrouter.ai/api/v1/chat/completions", @@ -918,11 +957,70 @@ def test_provision_openrouter_api_check_uses_models_endpoint_for_chat_override(s self.assertIn("Verifying OpenRouter API key", output) self.assertEqual(called_url, "https://openrouter.ai/api/v1/models") + def test_provision_azure_openai_api_check_uses_exact_chat_url(self) -> None: + api_url = "https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview" + proc, called_url = self._run_provision_api_check_capture_url( + backend="azure-openai", + api_url=api_url, + ) + output = f"{proc.stdout}\n{proc.stderr}" + self.assertNotEqual(proc.returncode, 0, msg=output) + self.assertIn("Verifying Azure OpenAI API key", output) + self.assertEqual(called_url, api_url) + def test_provision_ollama_requires_api_url_in_yes_mode(self) -> None: proc = self._run_provision_ollama_missing_api_url() output = f"{proc.stdout}\n{proc.stderr}" self.assertNotEqual(proc.returncode, 0, msg=output) - self.assertIn("Error: --api-url is required with --backend ollama in --yes mode", output) + self.assertIn( + "Error: --api-url is required with --backend ollama in --yes mode", output + ) + + def test_provision_azure_openai_requires_api_url_in_yes_mode(self) -> None: + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + home = tmp / "home" + export_dir = home / "esp" / "esp-idf" + export_dir.mkdir(parents=True, exist_ok=True) + (export_dir / "export.sh").write_text( + 'export IDF_PATH="$HOME/esp/esp-idf"\n', + encoding="utf-8", + ) + + env = os.environ.copy() + env["HOME"] = str(home) + env["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin" + env["TERM"] = "dumb" + + proc = subprocess.run( + [ + str(PROVISION_SH), + "--yes", + "--skip-api-check", + "--port", + "/dev/null", + "--ssid", + "TestNet", + "--pass", + "password123", + "--backend", + "azure-openai", + "--api-key", + "sk-test", + ], + cwd=PROJECT_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + output = f"{proc.stdout}\n{proc.stderr}" + self.assertNotEqual(proc.returncode, 0, msg=output) + self.assertIn( + "Error: --api-url is required with --backend azure-openai in --yes mode", + output, + ) def test_provision_rejects_ssid_longer_than_32_chars(self) -> None: proc = self._run_provision_length_validation( @@ -979,7 +1077,9 @@ def test_provision_rejects_more_than_4_telegram_chat_ids(self) -> None: self.assertNotEqual(proc.returncode, 0, msg=output) self.assertIn("Use 1-4 non-zero integers", output) - def test_provision_interactive_openai_model_menu_accepts_curated_choice(self) -> None: + def test_provision_interactive_openai_model_menu_accepts_curated_choice( + self, + ) -> None: proc, captured_csv = self._run_provision_capture_csv( backend="openai", assume_yes=False, @@ -991,7 +1091,9 @@ def test_provision_interactive_openai_model_menu_accepts_curated_choice(self) -> self.assertIn("Select model for openai:", output) self.assertIn('llm_model,data,string,"gpt-4.1-mini"', captured_csv) - def test_provision_interactive_openai_model_menu_defaults_to_first_choice(self) -> None: + def test_provision_interactive_openai_model_menu_defaults_to_first_choice( + self, + ) -> None: proc, captured_csv = self._run_provision_capture_csv( backend="openai", assume_yes=False, @@ -1015,6 +1117,25 @@ def test_provision_interactive_openai_model_menu_accepts_custom_model(self) -> N self.assertIn("Select model for openai:", output) self.assertIn('llm_model,data,string,"custom-model-123"', captured_csv) + def test_provision_interactive_azure_openai_model_menu_defaults_to_first_choice( + self, + ) -> None: + proc, captured_csv = self._run_provision_capture_csv( + backend="azure-openai", + assume_yes=False, + input_text="\nhttps://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview\ny\n\n\n", + api_key="sk-test", + ) + output = f"{proc.stdout}\n{proc.stderr}" + self.assertEqual(proc.returncode, 0, msg=output) + self.assertIn("Select model for azure-openai:", output) + self.assertIn('llm_backend,data,string,"azure-openai"', captured_csv) + self.assertIn('llm_model,data,string,"gpt-5.4"', captured_csv) + self.assertIn( + 'llm_api_url,data,string,"https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview"', + captured_csv, + ) + def test_provision_interactive_ollama_model_menu_defaults_to_qwen(self) -> None: proc, captured_csv = self._run_provision_capture_csv( backend="ollama", @@ -1027,21 +1148,30 @@ def test_provision_interactive_ollama_model_menu_defaults_to_qwen(self) -> None: self.assertIn("Select model for ollama:", output) self.assertIn('llm_backend,data,string,"ollama"', captured_csv) self.assertIn('llm_model,data,string,"qwen3:8b"', captured_csv) - self.assertIn('llm_api_url,data,string,"http://127.0.0.1:11434/v1/chat/completions"', captured_csv) + self.assertIn( + 'llm_api_url,data,string,"http://127.0.0.1:11434/v1/chat/completions"', + captured_csv, + ) def test_provision_writes_chat_id_allowlist_and_legacy_primary_key(self) -> None: with tempfile.TemporaryDirectory() as td: tmp = Path(td) home = tmp / "home" idf_dir = home / "esp" / "esp-idf" - nvs_gen = idf_dir / "components" / "nvs_flash" / "nvs_partition_generator" / "nvs_partition_gen.py" + nvs_gen = ( + idf_dir + / "components" + / "nvs_flash" + / "nvs_partition_generator" + / "nvs_partition_gen.py" + ) parttool = idf_dir / "components" / "partition_table" / "parttool.py" nvs_gen.parent.mkdir(parents=True, exist_ok=True) parttool.parent.mkdir(parents=True, exist_ok=True) nvs_gen.write_text("# nvs generator stub path\n", encoding="utf-8") parttool.write_text("# parttool stub path\n", encoding="utf-8") (idf_dir / "export.sh").write_text( - "export IDF_PATH=\"$HOME/esp/esp-idf\"\n", + 'export IDF_PATH="$HOME/esp/esp-idf"\n', encoding="utf-8", ) @@ -1050,9 +1180,9 @@ def test_provision_writes_chat_id_allowlist_and_legacy_primary_key(self) -> None _write_executable( bin_dir / "python", "#!/bin/sh\n" - "if [ \"$2\" = \"generate\" ]; then\n" - " cp \"$3\" \"$CSV_CAPTURE\"\n" - " : > \"$4\"\n" + 'if [ "$2" = "generate" ]; then\n' + ' cp "$3" "$CSV_CAPTURE"\n' + ' : > "$4"\n' " exit 0\n" "fi\n" "exit 0\n", @@ -1097,21 +1227,29 @@ def test_provision_writes_chat_id_allowlist_and_legacy_primary_key(self) -> None captured_csv = (tmp / "captured-nvs.csv").read_text(encoding="utf-8") self.assertIn('llm_model,data,string,"gpt-5.4"', captured_csv) self.assertIn('tg_chat_id,data,string,"7585013353"', captured_csv) - self.assertIn('tg_chat_ids,data,string,"7585013353,-100222333444"', captured_csv) + self.assertIn( + 'tg_chat_ids,data,string,"7585013353,-100222333444"', captured_csv + ) def test_provision_ollama_writes_normalized_api_url_without_api_key(self) -> None: with tempfile.TemporaryDirectory() as td: tmp = Path(td) home = tmp / "home" idf_dir = home / "esp" / "esp-idf" - nvs_gen = idf_dir / "components" / "nvs_flash" / "nvs_partition_generator" / "nvs_partition_gen.py" + nvs_gen = ( + idf_dir + / "components" + / "nvs_flash" + / "nvs_partition_generator" + / "nvs_partition_gen.py" + ) parttool = idf_dir / "components" / "partition_table" / "parttool.py" nvs_gen.parent.mkdir(parents=True, exist_ok=True) parttool.parent.mkdir(parents=True, exist_ok=True) nvs_gen.write_text("# nvs generator stub path\n", encoding="utf-8") parttool.write_text("# parttool stub path\n", encoding="utf-8") (idf_dir / "export.sh").write_text( - "export IDF_PATH=\"$HOME/esp/esp-idf\"\n", + 'export IDF_PATH="$HOME/esp/esp-idf"\n', encoding="utf-8", ) @@ -1120,9 +1258,9 @@ def test_provision_ollama_writes_normalized_api_url_without_api_key(self) -> Non _write_executable( bin_dir / "python", "#!/bin/sh\n" - "if [ \"$2\" = \"generate\" ]; then\n" - " cp \"$3\" \"$CSV_CAPTURE\"\n" - " : > \"$4\"\n" + 'if [ "$2" = "generate" ]; then\n' + ' cp "$3" "$CSV_CAPTURE"\n' + ' : > "$4"\n' " exit 0\n" "fi\n" "exit 0\n", @@ -1195,7 +1333,7 @@ def test_erase_all_requires_yes_in_non_interactive_shell(self) -> None: export_dir = home / "esp" / "esp-idf" export_dir.mkdir(parents=True, exist_ok=True) (export_dir / "export.sh").write_text( - "export IDF_PATH=\"$HOME/esp/esp-idf\"\n", + 'export IDF_PATH="$HOME/esp/esp-idf"\n', encoding="utf-8", ) @@ -1203,8 +1341,7 @@ def test_erase_all_requires_yes_in_non_interactive_shell(self) -> None: bin_dir.mkdir(parents=True, exist_ok=True) _write_executable( bin_dir / "lsof", - "#!/bin/sh\n" - "exit 1\n", + "#!/bin/sh\nexit 1\n", ) env = os.environ.copy() @@ -1228,7 +1365,9 @@ def test_erase_all_requires_yes_in_non_interactive_shell(self) -> None: output = f"{proc.stdout}\n{proc.stderr}" self.assertNotEqual(proc.returncode, 0, msg=output) - self.assertIn("interactive confirmation required in non-interactive mode", output) + self.assertIn( + "interactive confirmation required in non-interactive mode", output + ) def test_erase_nvs_yes_executes_parttool_erase_partition(self) -> None: with tempfile.TemporaryDirectory() as td: @@ -1242,7 +1381,7 @@ def test_erase_nvs_yes_executes_parttool_erase_partition(self) -> None: parttool.parent.mkdir(parents=True, exist_ok=True) parttool.write_text("# parttool stub path\n", encoding="utf-8") (idf_dir / "export.sh").write_text( - "export IDF_PATH=\"$HOME/esp/esp-idf\"\n", + 'export IDF_PATH="$HOME/esp/esp-idf"\n', encoding="utf-8", ) @@ -1250,13 +1389,11 @@ def test_erase_nvs_yes_executes_parttool_erase_partition(self) -> None: bin_dir.mkdir(parents=True, exist_ok=True) _write_executable( bin_dir / "python3", - "#!/bin/sh\n" - "printf '%s\\n' \"$@\" > \"$ERASE_ARGS_FILE\"\n", + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$ERASE_ARGS_FILE"\n', ) _write_executable( bin_dir / "lsof", - "#!/bin/sh\n" - "exit 1\n", + "#!/bin/sh\nexit 1\n", ) env = os.environ.copy() @@ -1298,7 +1435,7 @@ def test_erase_all_yes_executes_idf_erase_flash(self) -> None: export_dir = home / "esp" / "esp-idf" export_dir.mkdir(parents=True, exist_ok=True) (export_dir / "export.sh").write_text( - "export IDF_PATH=\"$HOME/esp/esp-idf\"\n", + 'export IDF_PATH="$HOME/esp/esp-idf"\n', encoding="utf-8", ) @@ -1306,13 +1443,11 @@ def test_erase_all_yes_executes_idf_erase_flash(self) -> None: bin_dir.mkdir(parents=True, exist_ok=True) _write_executable( bin_dir / "idf.py", - "#!/bin/sh\n" - "printf '%s\\n' \"$@\" > \"$ERASE_ARGS_FILE\"\n", + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$ERASE_ARGS_FILE"\n', ) _write_executable( bin_dir / "lsof", - "#!/bin/sh\n" - "exit 1\n", + "#!/bin/sh\nexit 1\n", ) env = os.environ.copy() @@ -1368,6 +1503,7 @@ def test_provision_dev_write_template_creates_profile(self) -> None: self.assertIn("ZCLAW_MODEL=gpt-5.4", content) self.assertIn("ZCLAW_API_KEY", content) self.assertIn("ZCLAW_API_URL", content) + self.assertIn("AZURE_OPENAI_API_KEY", content) def test_provision_dev_forwards_profile_values(self) -> None: with tempfile.TemporaryDirectory() as td: @@ -1378,8 +1514,7 @@ def test_provision_dev_forwards_profile_values(self) -> None: _write_executable( stub, - "#!/bin/sh\n" - "printf '%s\\n' \"$@\" > \"$ARGS_FILE\"\n", + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$ARGS_FILE"\n', ) env_file.write_text( "\n".join( @@ -1440,8 +1575,7 @@ def test_provision_dev_uses_provider_specific_env_key(self) -> None: _write_executable( stub, - "#!/bin/sh\n" - "printf '%s\\n' \"$@\" > \"$ARGS_FILE\"\n", + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$ARGS_FILE"\n', ) env_file.write_text( "\n".join( @@ -1480,6 +1614,55 @@ def test_provision_dev_uses_provider_specific_env_key(self) -> None: self.assertIn("--api-key", args_text) self.assertIn("or-sk-test-xyz", args_text) + def test_provision_dev_uses_azure_provider_specific_env_key(self) -> None: + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + env_file = tmp / "dev.env" + args_file = tmp / "args.txt" + stub = tmp / "mock-provision.sh" + + _write_executable( + stub, + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$ARGS_FILE"\n', + ) + env_file.write_text( + "\n".join( + [ + "ZCLAW_WIFI_SSID=Trident", + "ZCLAW_BACKEND=azure-openai", + "ZCLAW_API_URL=https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview", + "", + ] + ), + encoding="utf-8", + ) + + env = os.environ.copy() + env["ARGS_FILE"] = str(args_file) + env["ZCLAW_PROVISION_SCRIPT"] = str(stub) + env["AZURE_OPENAI_API_KEY"] = "azure-sk-test-xyz" + + proc = subprocess.run( + [ + str(PROVISION_DEV_SH), + "--env-file", + str(env_file), + ], + cwd=PROJECT_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + output = f"{proc.stdout}\n{proc.stderr}" + self.assertEqual(proc.returncode, 0, msg=output) + args_text = args_file.read_text(encoding="utf-8") + self.assertIn("--backend", args_text) + self.assertIn("azure-openai", args_text) + self.assertIn("--api-key", args_text) + self.assertIn("azure-sk-test-xyz", args_text) + def test_provision_dev_forwards_multi_telegram_chat_ids(self) -> None: with tempfile.TemporaryDirectory() as td: tmp = Path(td) @@ -1489,8 +1672,7 @@ def test_provision_dev_forwards_multi_telegram_chat_ids(self) -> None: _write_executable( stub, - "#!/bin/sh\n" - "printf '%s\\n' \"$@\" > \"$ARGS_FILE\"\n", + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$ARGS_FILE"\n', ) env_file.write_text( "\n".join( @@ -1603,6 +1785,41 @@ def test_provision_dev_ollama_requires_api_url(self) -> None: self.assertNotEqual(proc.returncode, 0, msg=output) self.assertIn("Error: API URL not set for Ollama backend.", output) + def test_provision_dev_azure_openai_requires_api_url(self) -> None: + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + env_file = tmp / "dev.env" + env_file.write_text( + "\n".join( + [ + "ZCLAW_WIFI_SSID=Trident", + "ZCLAW_BACKEND=azure-openai", + "AZURE_OPENAI_API_KEY=azure-sk-test-xyz", + "", + ] + ), + encoding="utf-8", + ) + + env = os.environ.copy() + + proc = subprocess.run( + [ + str(PROVISION_DEV_SH), + "--env-file", + str(env_file), + ], + cwd=PROJECT_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + output = f"{proc.stdout}\n{proc.stderr}" + self.assertNotEqual(proc.returncode, 0, msg=output) + self.assertIn("Error: API URL not set for Azure OpenAI backend.", output) + def test_provision_dev_ollama_forwards_api_url_without_api_key(self) -> None: with tempfile.TemporaryDirectory() as td: tmp = Path(td) @@ -1612,8 +1829,7 @@ def test_provision_dev_ollama_forwards_api_url_without_api_key(self) -> None: _write_executable( stub, - "#!/bin/sh\n" - "printf '%s\\n' \"$@\" > \"$ARGS_FILE\"\n", + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$ARGS_FILE"\n', ) env_file.write_text( "\n".join( @@ -1654,7 +1870,9 @@ def test_provision_dev_ollama_forwards_api_url_without_api_key(self) -> None: self.assertIn("--api-url", args_text) self.assertIn("http://192.168.1.10:11434/v1/chat/completions", args_text) self.assertNotIn("--api-key", args_text) - self.assertIn("API URL: http://192.168.1.10:11434/v1/chat/completions", output) + self.assertIn( + "API URL: http://192.168.1.10:11434/v1/chat/completions", output + ) def test_telegram_clear_backlog_errors_without_token(self) -> None: with tempfile.TemporaryDirectory() as td: @@ -1682,7 +1900,9 @@ def test_telegram_clear_backlog_errors_without_token(self) -> None: self.assertNotEqual(proc.returncode, 0, msg=output) self.assertIn("Error: Telegram token not set.", output) - def test_telegram_clear_backlog_uses_profile_token_and_advances_offset(self) -> None: + def test_telegram_clear_backlog_uses_profile_token_and_advances_offset( + self, + ) -> None: with tempfile.TemporaryDirectory() as td: tmp = Path(td) env_file = tmp / "dev.env" @@ -1700,28 +1920,28 @@ def test_telegram_clear_backlog_uses_profile_token_and_advances_offset(self) -> "fmt=''\n" "url=''\n" "while [ $# -gt 0 ]; do\n" - " case \"$1\" in\n" - " -o) out=\"$2\"; shift 2 ;;\n" - " -w) fmt=\"$2\"; shift 2 ;;\n" + ' case "$1" in\n' + ' -o) out="$2"; shift 2 ;;\n' + ' -w) fmt="$2"; shift 2 ;;\n' " --connect-timeout|--max-time) shift 2 ;;\n" " -s|-S|-sS) shift ;;\n" - " *) url=\"$1\"; shift ;;\n" + ' *) url="$1"; shift ;;\n' " esac\n" "done\n" - "printf '%s\\n' \"$url\" >> \"$CURL_URLS_FILE\"\n" + 'printf \'%s\\n\' "$url" >> "$CURL_URLS_FILE"\n' "code='200'\n" "if echo \"$url\" | grep -q 'offset=-1'; then\n" - " body='{\"ok\":true,\"result\":[{\"update_id\":4242}]}'\n" + ' body=\'{"ok":true,"result":[{"update_id":4242}]}\'\n' "elif echo \"$url\" | grep -q 'offset=4243'; then\n" - " body='{\"ok\":true,\"result\":[]}'\n" + ' body=\'{"ok":true,"result":[]}\'\n' "else\n" " code='400'\n" - " body='{\"ok\":false,\"error_code\":400,\"description\":\"bad offset\"}'\n" + ' body=\'{"ok":false,"error_code":400,"description":"bad offset"}\'\n' "fi\n" - "if [ -n \"$out\" ]; then\n" - " printf '%s' \"$body\" > \"$out\"\n" + 'if [ -n "$out" ]; then\n' + ' printf \'%s\' "$body" > "$out"\n' "fi\n" - "if [ -n \"$fmt\" ]; then\n" + 'if [ -n "$fmt" ]; then\n' " printf '%s' \"$code\"\n" "else\n" " printf '%s' \"$body\"\n" diff --git a/test/host/test_json_util_integration.c b/test/host/test_json_util_integration.c index 770ac39..283de87 100644 --- a/test/host/test_json_util_integration.c +++ b/test/host/test_json_util_integration.c @@ -80,7 +80,7 @@ TEST(build_anthropic_request) TEST(build_openai_request) { - mock_llm_set_backend(LLM_BACKEND_OPENAI, "gpt-test-model"); + mock_llm_set_backend(LLM_BACKEND_OPENAI, "gpt-5.4"); char *request = json_build_request("sys prompt", NULL, 0, "hello", s_test_tools, 1); @@ -91,7 +91,7 @@ TEST(build_openai_request) cJSON *model = cJSON_GetObjectItem(root, "model"); ASSERT(model != NULL && cJSON_IsString(model)); - ASSERT_STR_EQ(model->valuestring, "gpt-test-model"); + ASSERT_STR_EQ(model->valuestring, "gpt-5.4"); cJSON *max_completion_tokens = cJSON_GetObjectItem(root, "max_completion_tokens"); ASSERT(max_completion_tokens != NULL && cJSON_IsNumber(max_completion_tokens)); @@ -146,6 +146,60 @@ TEST(build_openrouter_request) return 0; } +TEST(build_azure_openai_request) +{ + mock_llm_set_backend(LLM_BACKEND_AZURE_OPENAI, "azure/gpt-5-mini"); + + char *request = json_build_request("sys prompt", NULL, 0, "hello", + s_test_tools, 1); + ASSERT(request != NULL); + + cJSON *root = cJSON_Parse(request); + ASSERT(root != NULL); + + cJSON *max_output_tokens = cJSON_GetObjectItem(root, "max_output_tokens"); + ASSERT(max_output_tokens != NULL && cJSON_IsNumber(max_output_tokens)); + + cJSON *input = cJSON_GetObjectItem(root, "input"); + ASSERT(input != NULL && cJSON_IsArray(input)); + ASSERT(cJSON_GetArraySize(input) == 1); + + cJSON *instructions = cJSON_GetObjectItem(root, "instructions"); + ASSERT(instructions != NULL && cJSON_IsString(instructions)); + ASSERT_STR_EQ(instructions->valuestring, "sys prompt"); + + cJSON *parallel_tool_calls = cJSON_GetObjectItem(root, "parallel_tool_calls"); + ASSERT(parallel_tool_calls != NULL && cJSON_IsFalse(parallel_tool_calls)); + + cJSON *reasoning = cJSON_GetObjectItem(root, "reasoning"); + ASSERT(reasoning != NULL && cJSON_IsObject(reasoning)); + cJSON *effort = cJSON_GetObjectItem(reasoning, "effort"); + ASSERT(effort != NULL && cJSON_IsString(effort)); + ASSERT_STR_EQ(effort->valuestring, "low"); + + cJSON *first = cJSON_GetArrayItem(input, 0); + ASSERT(first != NULL); + cJSON *first_role = cJSON_GetObjectItem(first, "role"); + ASSERT(first_role != NULL && cJSON_IsString(first_role)); + ASSERT_STR_EQ(first_role->valuestring, "user"); + + cJSON *tools = cJSON_GetObjectItem(root, "tools"); + ASSERT(tools != NULL && cJSON_IsArray(tools)); + ASSERT(cJSON_GetArraySize(tools) == 1); + cJSON *tool = cJSON_GetArrayItem(tools, 0); + ASSERT(tool != NULL); + cJSON *type = cJSON_GetObjectItem(tool, "type"); + ASSERT(type != NULL && cJSON_IsString(type)); + ASSERT_STR_EQ(type->valuestring, "function"); + cJSON *name = cJSON_GetObjectItem(tool, "name"); + ASSERT(name != NULL && cJSON_IsString(name)); + ASSERT_STR_EQ(name->valuestring, "gpio_write"); + + cJSON_Delete(root); + free(request); + return 0; +} + TEST(build_openai_request_skips_orphan_tool_result) { mock_llm_set_backend(LLM_BACKEND_OPENAI, "gpt-test-model"); @@ -257,6 +311,72 @@ TEST(parse_openai_tool_call) return 0; } +TEST(parse_azure_responses_tool_call) +{ + mock_llm_set_backend(LLM_BACKEND_AZURE_OPENAI, "gpt-5.4"); + + const char *response = "{" + "\"output\":[{" + "\"type\":\"function_call\"," + "\"call_id\":\"call_resp_1\"," + "\"name\":\"memory_set\"," + "\"arguments\":\"{\\\"key\\\":\\\"name\\\",\\\"value\\\":\\\"alice\\\"}\"" + "}]" + "}"; + + char text[256] = {0}; + char tool_name[32] = {0}; + char tool_id[64] = {0}; + cJSON *tool_input = NULL; + + ASSERT(json_parse_response(response, text, sizeof(text), + tool_name, sizeof(tool_name), + tool_id, sizeof(tool_id), + &tool_input)); + ASSERT_STR_EQ(tool_name, "memory_set"); + ASSERT_STR_EQ(tool_id, "call_resp_1"); + ASSERT(tool_input != NULL); + ASSERT_STR_EQ(cJSON_GetObjectItem(tool_input, "key")->valuestring, "name"); + ASSERT_STR_EQ(cJSON_GetObjectItem(tool_input, "value")->valuestring, "alice"); + + json_free_parsed_response(); + return 0; +} + +TEST(parse_azure_responses_text_ignores_null_error_field) +{ + mock_llm_set_backend(LLM_BACKEND_AZURE_OPENAI, "gpt-5.4"); + + const char *response = "{" + "\"error\":null," + "\"output\":[{" + "\"type\":\"message\"," + "\"role\":\"assistant\"," + "\"content\":[{" + "\"type\":\"output_text\"," + "\"text\":\"hello from azure\"" + "}]" + "}]" + "}"; + + char text[256] = {0}; + char tool_name[32] = {0}; + char tool_id[64] = {0}; + cJSON *tool_input = NULL; + + ASSERT(json_parse_response(response, text, sizeof(text), + tool_name, sizeof(tool_name), + tool_id, sizeof(tool_id), + &tool_input)); + ASSERT_STR_EQ(text, "hello from azure"); + ASSERT(tool_name[0] == '\0'); + ASSERT(tool_id[0] == '\0'); + ASSERT(tool_input == NULL); + + json_free_parsed_response(); + return 0; +} + TEST(parse_api_error) { mock_llm_set_backend(LLM_BACKEND_OPENAI, "gpt-test-model"); @@ -310,6 +430,13 @@ int test_json_util_integration_all(void) failures++; } + printf(" build_azure_openai_request... "); + if (test_build_azure_openai_request() == 0) { + printf("OK\n"); + } else { + failures++; + } + printf(" build_openai_request_skips_orphan_tool_result... "); if (test_build_openai_request_skips_orphan_tool_result() == 0) { printf("OK\n"); @@ -331,6 +458,20 @@ int test_json_util_integration_all(void) failures++; } + printf(" parse_azure_responses_tool_call... "); + if (test_parse_azure_responses_tool_call() == 0) { + printf("OK\n"); + } else { + failures++; + } + + printf(" parse_azure_responses_text_ignores_null_error_field... "); + if (test_parse_azure_responses_text_ignores_null_error_field() == 0) { + printf("OK\n"); + } else { + failures++; + } + printf(" parse_api_error... "); if (test_parse_api_error() == 0) { printf("OK\n"); diff --git a/test/host/test_llm_runtime.c b/test/host/test_llm_runtime.c index c0d573f..10881be 100644 --- a/test/host/test_llm_runtime.c +++ b/test/host/test_llm_runtime.c @@ -72,6 +72,23 @@ TEST(loads_openrouter_backend_and_custom_model) return 0; } +TEST(loads_azure_openai_backend_and_custom_url) +{ + configure_mock_store( + "azure-openai", + "demo-deployment", + "test-key", + "https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview" + ); + ASSERT(llm_init() == ESP_OK); + ASSERT(llm_get_backend() == LLM_BACKEND_AZURE_OPENAI); + ASSERT(strcmp(llm_get_api_url(), "https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview") == 0); + ASSERT(strcmp(llm_get_model(), "demo-deployment") == 0); + ASSERT(llm_is_openai_format()); + ASSERT(llm_uses_responses_api()); + return 0; +} + TEST(unknown_backend_falls_back_to_openai) { configure_mock_store("mystery_backend", NULL, "test-key", NULL); @@ -117,6 +134,16 @@ TEST(loads_ollama_backend_with_default_model) return 0; } +TEST(azure_openai_requires_explicit_api_url) +{ + configure_mock_store("azure-openai", NULL, "test-key", NULL); + ASSERT(llm_init() == ESP_OK); + ASSERT(strcmp(llm_get_api_url(), "") == 0); + ASSERT(strcmp(llm_get_model(), LLM_DEFAULT_MODEL_AZURE_OPENAI) == 0); + ASSERT(llm_uses_responses_api()); + return 0; +} + TEST(custom_api_url_override_applies_to_any_backend) { configure_mock_store("openai", NULL, "test-key", "http://192.168.1.50:11434/v1/chat/completions"); @@ -164,6 +191,13 @@ int test_llm_runtime_all(void) failures++; } + printf(" loads_azure_openai_backend_and_custom_url... "); + if (test_loads_azure_openai_backend_and_custom_url() == 0) { + printf("OK\n"); + } else { + failures++; + } + printf(" unknown_backend_falls_back_to_openai... "); if (test_unknown_backend_falls_back_to_openai() == 0) { printf("OK\n"); @@ -192,6 +226,13 @@ int test_llm_runtime_all(void) failures++; } + printf(" azure_openai_requires_explicit_api_url... "); + if (test_azure_openai_requires_explicit_api_url() == 0) { + printf("OK\n"); + } else { + failures++; + } + printf(" custom_api_url_override_applies_to_any_backend... "); if (test_custom_api_url_override_applies_to_any_backend() == 0) { printf("OK\n"); diff --git a/test/host/test_qemu_live_llm_bridge.py b/test/host/test_qemu_live_llm_bridge.py index 15a6195..074942d 100644 --- a/test/host/test_qemu_live_llm_bridge.py +++ b/test/host/test_qemu_live_llm_bridge.py @@ -4,9 +4,11 @@ from __future__ import annotations import json +import os import sys import unittest from pathlib import Path +from unittest.mock import patch TEST_DIR = Path(__file__).resolve().parent @@ -15,6 +17,7 @@ from qemu_live_llm_bridge import ( build_error_payload, + call_provider, compact_json_or_error, detect_provider_from_request, resolve_provider, @@ -76,6 +79,30 @@ def test_detect_provider_from_openai_shape(self) -> None: ) self.assertEqual(detect_provider_from_request(request), "openai") + def test_detect_provider_from_responses_shape(self) -> None: + request = json.dumps( + { + "model": "gpt-5.4", + "instructions": "You are helpful.", + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "hi"}], + } + ], + "tools": [ + { + "type": "function", + "name": "gpio_write", + "description": "write pin", + "parameters": {"type": "object"}, + } + ], + } + ) + self.assertEqual(detect_provider_from_request(request), "azure-openai") + def test_detect_provider_defaults_to_openai_for_invalid_json(self) -> None: self.assertEqual(detect_provider_from_request("not-json"), "openai") @@ -99,6 +126,28 @@ def test_resolve_provider_auto_uses_detection(self) -> None: self.assertEqual(resolve_provider("auto", request), "openai") self.assertEqual(resolve_provider("anthropic", request), "anthropic") + def test_resolve_provider_auto_detects_responses_shape(self) -> None: + request = json.dumps( + { + "model": "gpt-5.4", + "instructions": "x", + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "hi"}], + } + ], + } + ) + self.assertEqual(resolve_provider("auto", request), "azure-openai") + + def test_call_provider_azure_openai_requires_env_vars(self) -> None: + with patch.dict(os.environ, {}, clear=True): + payload = json.loads(call_provider("azure-openai", "{}", 1)) + self.assertIn("error", payload) + self.assertIn("AZURE_OPENAI_API_KEY", payload["error"]["message"]) + if __name__ == "__main__": unittest.main() diff --git a/test/host/test_telegram_poll_policy.c b/test/host/test_telegram_poll_policy.c index 3e82b56..1c23832 100644 --- a/test/host/test_telegram_poll_policy.c +++ b/test/host/test_telegram_poll_policy.c @@ -18,6 +18,7 @@ TEST(default_backends_keep_standard_timeout) { ASSERT(telegram_poll_timeout_for_backend(LLM_BACKEND_ANTHROPIC) == TELEGRAM_POLL_TIMEOUT); ASSERT(telegram_poll_timeout_for_backend(LLM_BACKEND_OPENAI) == TELEGRAM_POLL_TIMEOUT); + ASSERT(telegram_poll_timeout_for_backend(LLM_BACKEND_AZURE_OPENAI) == TELEGRAM_POLL_TIMEOUT); return 0; } @@ -40,6 +41,8 @@ TEST(classic_esp32_shortens_standard_backends) TELEGRAM_POLL_TIMEOUT_ESP32); ASSERT(telegram_poll_timeout_for_backend_test(LLM_BACKEND_OPENAI, true) == TELEGRAM_POLL_TIMEOUT_ESP32); + ASSERT(telegram_poll_timeout_for_backend_test(LLM_BACKEND_AZURE_OPENAI, true) == + TELEGRAM_POLL_TIMEOUT_ESP32); return 0; } From 98a6bab96bc13319f41c2841fdc28b858f8b724a Mon Sep 17 00:00:00 2001 From: Alwin Arrasyid Date: Fri, 27 Mar 2026 21:40:07 +0700 Subject: [PATCH 2/3] fix: provisioning for Azure OpenAI --- docs-site/reference/README_COMPLETE.md | 4 +- main/config.h | 2 +- main/llm.c | 15 +- main/llm.h | 2 - scripts/emulate.sh | 167 ++++-- scripts/provision-dev.sh | 12 +- scripts/provision.sh | 81 ++- test/api/provider_harness.py | 3 +- test/host/esp_err.h | 2 + test/host/mock_esp.h | 1 + test/host/test_api_provider_harness.py | 40 ++ test/host/test_install_provision_scripts.py | 614 +++++++++++++++++++- test/host/test_llm_runtime.c | 24 +- 13 files changed, 903 insertions(+), 64 deletions(-) diff --git a/docs-site/reference/README_COMPLETE.md b/docs-site/reference/README_COMPLETE.md index 29ff6b0..2b62fc0 100644 --- a/docs-site/reference/README_COMPLETE.md +++ b/docs-site/reference/README_COMPLETE.md @@ -630,11 +630,13 @@ export OPENAI_API_KEY=... # Azure OpenAI export AZURE_OPENAI_API_KEY=... export AZURE_OPENAI_API_URL="https://.openai.azure.com/openai/responses?api-version=2025-04-01-preview" +export AZURE_OPENAI_MODEL="" ./scripts/emulate.sh --live-api --live-api-provider azure-openai ``` `--live-api` keeps QEMU offline but proxies LLM requests over UART to a host bridge process. -`--live-api-provider auto` (default) infers provider from request format. +`--live-api-provider auto` (default) seeds the emulator runtime backend/model from the available host env, then infers provider from the emitted request format. +Set `ANTHROPIC_MODEL`, `OPENAI_MODEL`, or `AZURE_OPENAI_MODEL` to override the seeded model/deployment when needed. Use `--live-api-logs` only when debugging bridge timing/forwarding. Set `OPENAI_API_URL` to target an OpenAI-compatible endpoint other than the default. diff --git a/main/config.h b/main/config.h index 1ee5182..bbee5a6 100644 --- a/main/config.h +++ b/main/config.h @@ -58,7 +58,7 @@ typedef enum { #define LLM_DEFAULT_MODEL_ANTHROPIC "claude-sonnet-4-6" #define LLM_DEFAULT_MODEL_OPENAI "gpt-5.4" -#define LLM_DEFAULT_MODEL_AZURE_OPENAI "gpt-5.4" +#define LLM_DEFAULT_MODEL_AZURE_OPENAI "" #define LLM_DEFAULT_MODEL_OPENROUTER "openrouter/auto" #define LLM_DEFAULT_MODEL_OLLAMA "qwen3:8b" diff --git a/main/llm.c b/main/llm.c index 135c5ef..ac59ec1 100644 --- a/main/llm.c +++ b/main/llm.c @@ -509,6 +509,10 @@ esp_err_t llm_init(void) ESP_LOGW(TAG, "Ollama backend using default loopback URL; set llm_api_url for network access"); } + if (s_model[0] == '\0' && s_backend == LLM_BACKEND_AZURE_OPENAI) { + ESP_LOGW(TAG, "Azure OpenAI backend requires llm_model to be configured"); + } + #ifdef CONFIG_ZCLAW_STUB_LLM ESP_LOGW(TAG, "LLM stub mode enabled (QEMU testing)"); #endif @@ -574,12 +578,14 @@ const char *llm_get_model(void) return s_model; } -#if CONFIG_ZCLAW_STUB_LLM bool llm_stub_has_api_key_for_test(void) { +#if CONFIG_ZCLAW_STUB_LLM return s_api_key[0] != '\0'; -} +#else + return false; #endif +} bool llm_is_openai_format(void) { @@ -644,6 +650,11 @@ esp_err_t llm_request(const char *request_json, char *response_buf, size_t respo response_buf[0] = '\0'; + if (llm_get_model()[0] == '\0') { + ESP_LOGE(TAG, "No model configured for backend %s", llm_backend_name(s_backend)); + return ESP_ERR_INVALID_STATE; + } + #if CONFIG_ZCLAW_EMULATOR_LIVE_LLM // In emulator bridge mode, delegate HTTPS API calls to a host-side proxy. esp_err_t bridge_err = channel_llm_bridge_exchange(request_json, response_buf, response_buf_size, diff --git a/main/llm.h b/main/llm.h index 81e16ce..2da4963 100644 --- a/main/llm.h +++ b/main/llm.h @@ -37,9 +37,7 @@ bool llm_is_openai_format(void); // Check if backend uses the Responses API request/response shape. bool llm_uses_responses_api(void); -#if CONFIG_ZCLAW_STUB_LLM // Host-test helper: indicates whether an API key is currently loaded in runtime state. bool llm_stub_has_api_key_for_test(void); -#endif #endif // LLM_H diff --git a/scripts/emulate.sh b/scripts/emulate.sh index f175faf..949dd0f 100755 --- a/scripts/emulate.sh +++ b/scripts/emulate.sh @@ -5,13 +5,20 @@ set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -QEMU_BUILD_DIR="build-qemu" -QEMU_SDKCONFIG="sdkconfig.qemu" +QEMU_BUILD_DIR="${ZCLAW_QEMU_BUILD_DIR:-build-qemu}" +QEMU_SDKCONFIG="${ZCLAW_QEMU_SDKCONFIG:-$QEMU_BUILD_DIR/sdkconfig.qemu}" QEMU_PID_FILE="$QEMU_BUILD_DIR/qemu.pid" QEMU_SDKCONFIG_DEFAULTS_BASE="sdkconfig.defaults;sdkconfig.qemu.defaults" LIVE_API_MODE=0 LIVE_API_PROVIDER="auto" LIVE_API_LOGS=0 +LIVE_RUNTIME_BACKEND="" +LIVE_RUNTIME_MODEL="" +LIVE_RUNTIME_API_URL="" +LIVE_NVS_OFFSET=$((0x9000)) +LIVE_NVS_SIZE_HEX="0x4000" +DEFAULT_ANTHROPIC_MODEL="claude-sonnet-4-6" +DEFAULT_OPENAI_MODEL="gpt-5.4" while [[ $# -gt 0 ]]; do case "$1" in @@ -43,47 +50,30 @@ if [[ "$LIVE_API_PROVIDER" != "auto" && "$LIVE_API_PROVIDER" != "anthropic" && " exit 1 fi -cd "$PROJECT_DIR" - -# Check for QEMU -if ! command -v qemu-system-riscv32 &> /dev/null; then - echo "QEMU not found. Install it:" - echo " macOS: brew install qemu" - echo " Ubuntu: apt install qemu-system-misc" - exit 1 -fi +csv_escape() { + local value="${1//\"/\"\"}" + printf '"%s"' "$value" +} -# Find and source ESP-IDF -if [ -f "$HOME/esp/esp-idf/export.sh" ]; then - source "$HOME/esp/esp-idf/export.sh" -elif [ -f "$HOME/esp/v5.4/esp-idf/export.sh" ]; then - source "$HOME/esp/v5.4/esp-idf/export.sh" -elif [ -n "$IDF_PATH" ]; then - source "$IDF_PATH/export.sh" -else - echo "Error: ESP-IDF not found" - exit 1 -fi - -# Build emulator profile -echo "Building QEMU profile..." -# Always regenerate sdkconfig.qemu from defaults to avoid stale local overrides. -rm -f "$QEMU_SDKCONFIG" - -QEMU_SDKCONFIG_DEFAULTS="$QEMU_SDKCONFIG_DEFAULTS_BASE" -if [ "$LIVE_API_MODE" -eq 1 ]; then +resolve_live_runtime_config() { case "$LIVE_API_PROVIDER" in anthropic) if [ -z "${ANTHROPIC_API_KEY:-}" ]; then echo "Error: ANTHROPIC_API_KEY is required for --live-api-provider anthropic" exit 1 fi + LIVE_RUNTIME_BACKEND="anthropic" + LIVE_RUNTIME_MODEL="${ANTHROPIC_MODEL:-$DEFAULT_ANTHROPIC_MODEL}" + LIVE_RUNTIME_API_URL="" ;; openai) if [ -z "${OPENAI_API_KEY:-}" ]; then echo "Error: OPENAI_API_KEY is required for --live-api-provider openai" exit 1 fi + LIVE_RUNTIME_BACKEND="openai" + LIVE_RUNTIME_MODEL="${OPENAI_MODEL:-$DEFAULT_OPENAI_MODEL}" + LIVE_RUNTIME_API_URL="${OPENAI_API_URL:-}" ;; azure-openai) if [ -z "${AZURE_OPENAI_API_KEY:-}" ]; then @@ -94,16 +84,111 @@ if [ "$LIVE_API_MODE" -eq 1 ]; then echo "Error: AZURE_OPENAI_API_URL is required for --live-api-provider azure-openai" exit 1 fi + if [ -z "${AZURE_OPENAI_MODEL:-}" ]; then + echo "Error: AZURE_OPENAI_MODEL is required for --live-api-provider azure-openai" + exit 1 + fi + LIVE_RUNTIME_BACKEND="azure-openai" + LIVE_RUNTIME_MODEL="${AZURE_OPENAI_MODEL}" + LIVE_RUNTIME_API_URL="${AZURE_OPENAI_API_URL}" ;; auto) - if [ -z "${ANTHROPIC_API_KEY:-}" ] && [ -z "${OPENAI_API_KEY:-}" ]; then - echo "Error: set ANTHROPIC_API_KEY or OPENAI_API_KEY for --live-api mode" - exit 1 + if [ -n "${OPENAI_API_KEY:-}" ]; then + LIVE_RUNTIME_BACKEND="openai" + LIVE_RUNTIME_MODEL="${OPENAI_MODEL:-$DEFAULT_OPENAI_MODEL}" + LIVE_RUNTIME_API_URL="${OPENAI_API_URL:-}" + return 0 + fi + if [ -n "${ANTHROPIC_API_KEY:-}" ]; then + LIVE_RUNTIME_BACKEND="anthropic" + LIVE_RUNTIME_MODEL="${ANTHROPIC_MODEL:-$DEFAULT_ANTHROPIC_MODEL}" + LIVE_RUNTIME_API_URL="" + return 0 fi + if [ -n "${AZURE_OPENAI_API_KEY:-}" ] && [ -n "${AZURE_OPENAI_API_URL:-}" ]; then + if [ -z "${AZURE_OPENAI_MODEL:-}" ]; then + echo "Error: AZURE_OPENAI_MODEL is required for Azure OpenAI in --live-api auto mode" + exit 1 + fi + LIVE_RUNTIME_BACKEND="azure-openai" + LIVE_RUNTIME_MODEL="${AZURE_OPENAI_MODEL}" + LIVE_RUNTIME_API_URL="${AZURE_OPENAI_API_URL}" + return 0 + fi + echo "Error: set OPENAI_API_KEY, ANTHROPIC_API_KEY, or AZURE_OPENAI_API_KEY + AZURE_OPENAI_API_URL + AZURE_OPENAI_MODEL for --live-api mode" + exit 1 ;; esac +} + +seed_live_runtime_nvs() { + local flash_image="$1" + local nvs_gen="$IDF_PATH/components/nvs_flash/nvs_partition_generator/nvs_partition_gen.py" + local csv_file="$QEMU_BUILD_DIR/live-runtime.nvs.csv" + local nvs_bin="$QEMU_BUILD_DIR/live-runtime.nvs.bin" + + if [ ! -f "$nvs_gen" ]; then + echo "Error: nvs_partition_gen.py not found at $nvs_gen" + exit 1 + fi + + { + echo "key,type,encoding,value" + echo "zclaw,namespace,," + printf "llm_backend,data,string,%s\n" "$(csv_escape "$LIVE_RUNTIME_BACKEND")" + printf "llm_model,data,string,%s\n" "$(csv_escape "$LIVE_RUNTIME_MODEL")" + if [ -n "$LIVE_RUNTIME_API_URL" ]; then + printf "llm_api_url,data,string,%s\n" "$(csv_escape "$LIVE_RUNTIME_API_URL")" + fi + } > "$csv_file" + + python3 "$nvs_gen" generate "$csv_file" "$nvs_bin" "$LIVE_NVS_SIZE_HEX" + dd if="$nvs_bin" of="$flash_image" bs=1 seek="$LIVE_NVS_OFFSET" conv=notrunc >/dev/null 2>&1 +} + +require_nonempty_file() { + local path="$1" + local label="$2" + + if [ ! -s "$path" ]; then + echo "Error: $label is missing or empty at $path" + echo "Tip: remove the stale QEMU build dir and rerun: rm -rf $QEMU_BUILD_DIR" + exit 1 + fi +} + +cd "$PROJECT_DIR" + +# Check for QEMU +if ! command -v qemu-system-riscv32 &> /dev/null; then + echo "QEMU not found. Install it:" + echo " macOS: brew install qemu" + echo " Ubuntu: apt install qemu-system-misc" + exit 1 +fi + +# Find and source ESP-IDF +if [ -f "$HOME/esp/esp-idf/export.sh" ]; then + source "$HOME/esp/esp-idf/export.sh" +elif [ -f "$HOME/esp/v5.4/esp-idf/export.sh" ]; then + source "$HOME/esp/v5.4/esp-idf/export.sh" +elif [ -n "$IDF_PATH" ]; then + source "$IDF_PATH/export.sh" +else + echo "Error: ESP-IDF not found" + exit 1 +fi + +# Build emulator profile +echo "Building QEMU profile..." +# Always regenerate the QEMU sdkconfig from defaults to avoid stale local overrides. +mkdir -p "$QEMU_BUILD_DIR" +rm -f "$QEMU_SDKCONFIG" + +QEMU_SDKCONFIG_DEFAULTS="$QEMU_SDKCONFIG_DEFAULTS_BASE" +if [ "$LIVE_API_MODE" -eq 1 ]; then + resolve_live_runtime_config - mkdir -p "$QEMU_BUILD_DIR" LIVE_DEFAULTS="$QEMU_BUILD_DIR/sdkconfig.qemu.live.defaults" cat > "$LIVE_DEFAULTS" <<'EOF' CONFIG_ZCLAW_STUB_LLM=n @@ -125,18 +210,24 @@ idf.py \ -D SDKCONFIG_DEFAULTS="$QEMU_SDKCONFIG_DEFAULTS" \ build +require_nonempty_file "$QEMU_BUILD_DIR/bootloader/bootloader.bin" "QEMU bootloader image" +require_nonempty_file "$QEMU_BUILD_DIR/partition_table/partition-table.bin" "QEMU partition table" +require_nonempty_file "$QEMU_BUILD_DIR/zclaw.bin" "QEMU app image" + echo "Starting QEMU emulator..." if [ "$LIVE_API_MODE" -eq 1 ]; then echo "Live API mode enabled: requests are proxied from host -> API provider." echo "Provider selection: $LIVE_API_PROVIDER" + echo "Runtime backend: $LIVE_RUNTIME_BACKEND" + echo "Runtime model: $LIVE_RUNTIME_MODEL" if [ "$LIVE_API_PROVIDER" = "anthropic" ]; then echo "Using ANTHROPIC_API_KEY from host environment." elif [ "$LIVE_API_PROVIDER" = "openai" ]; then echo "Using OPENAI_API_KEY from host environment." elif [ "$LIVE_API_PROVIDER" = "azure-openai" ]; then - echo "Using AZURE_OPENAI_API_KEY and AZURE_OPENAI_API_URL from host environment." + echo "Using AZURE_OPENAI_API_KEY, AZURE_OPENAI_API_URL, and AZURE_OPENAI_MODEL from host environment." else - echo "Auto mode: bridge infers provider from request format (Anthropic/OpenAI)." + echo "Auto mode: runtime config is seeded from host env, then bridge infers provider from request format." fi else echo "Note: WiFi/TLS don't work in QEMU. Enable stub mode via menuconfig for testing." @@ -150,12 +241,16 @@ esptool.py --chip esp32c3 merge_bin -o "$QEMU_BUILD_DIR/merged.bin" \ 0x0 "$QEMU_BUILD_DIR/bootloader/bootloader.bin" \ 0x8000 "$QEMU_BUILD_DIR/partition_table/partition-table.bin" \ 0x20000 "$QEMU_BUILD_DIR/zclaw.bin" 2>/dev/null +require_nonempty_file "$QEMU_BUILD_DIR/merged.bin" "Merged QEMU flash image" # QEMU for ESP32 requires fixed-size flash images (2/4/8/16MB). # Pad merged image to 4MB so it is always accepted. QEMU_FLASH_IMAGE="$QEMU_BUILD_DIR/merged-qemu-4mb.bin" cp "$QEMU_BUILD_DIR/merged.bin" "$QEMU_FLASH_IMAGE" truncate -s 4M "$QEMU_FLASH_IMAGE" +if [ "$LIVE_API_MODE" -eq 1 ]; then + seed_live_runtime_nvs "$QEMU_FLASH_IMAGE" +fi # Run QEMU rm -f "$QEMU_PID_FILE" diff --git a/scripts/provision-dev.sh b/scripts/provision-dev.sh index 90d1164..ad339c0 100755 --- a/scripts/provision-dev.sh +++ b/scripts/provision-dev.sh @@ -39,7 +39,7 @@ Overrides: --ssid --pass --backend anthropic | openai | azure-openai | openrouter | ollama - --model + --model Model ID (Azure OpenAI uses deployment name) --api-key --api-url Custom API endpoint URL --tg-token @@ -66,7 +66,9 @@ ZCLAW_PORT=/dev/cu.usbmodem1101 ZCLAW_WIFI_SSID=YourWifi ZCLAW_WIFI_PASS=YourWifiPassword ZCLAW_BACKEND=openai -ZCLAW_MODEL=gpt-5.4 +# Model ID for the selected backend. +# For Azure OpenAI, set this to your deployment name; leave it blank until you know it. +ZCLAW_MODEL= ZCLAW_API_URL= # Prefer setting one API key here: @@ -367,6 +369,12 @@ if [ "$BACKEND" = "azure-openai" ] && [ -z "$API_URL" ]; then exit 1 fi +if [ "$BACKEND" = "azure-openai" ] && [ -z "$MODEL" ]; then + echo "Error: model/deployment name not set for Azure OpenAI backend." + echo "Set ZCLAW_MODEL in $ENV_FILE or pass --model." + exit 1 +fi + if [ "$SHOW_CONFIG" = true ]; then BOT_ID="" if BOT_ID="$(extract_bot_id "$TG_TOKEN" 2>/dev/null)"; then diff --git a/scripts/provision.sh b/scripts/provision.sh index fed58e5..ea1e4e1 100755 --- a/scripts/provision.sh +++ b/scripts/provision.sh @@ -30,7 +30,7 @@ Options: --ssid WiFi SSID (auto-detected when possible) --pass WiFi password (optional) --backend anthropic | openai | azure-openai | openrouter | ollama - --model Model ID (defaults by backend) + --model Model ID (Azure OpenAI uses deployment name; required there) --api-key LLM API key (required for anthropic/openai/azure-openai/openrouter) --api-url Optional custom API endpoint URL --tg-token Telegram bot token (optional) @@ -350,13 +350,20 @@ default_model_for_backend() { case "$1" in anthropic) echo "claude-sonnet-4-6" ;; openai) echo "gpt-5.4" ;; - azure-openai) echo "gpt-5.4" ;; + azure-openai) echo "" ;; openrouter) echo "openrouter/auto" ;; ollama) echo "qwen3:8b" ;; *) echo "claude-sonnet-4-6" ;; esac } +model_prompt_label_for_backend() { + case "$1" in + azure-openai) printf '%s\n' "Azure OpenAI deployment name" ;; + *) printf '%s\n' "Model ID" ;; + esac +} + MODEL_MENU_LABELS=() MODEL_MENU_VALUES=() @@ -374,8 +381,8 @@ load_model_menu_for_backend() { MODEL_MENU_VALUES=("gpt-5.4" "gpt-5-mini" "gpt-4.1-mini" "__custom__") ;; azure-openai) - MODEL_MENU_LABELS=("gpt-5.4 (default)" "gpt-5-mini" "gpt-4.1-mini" "Other model ID") - MODEL_MENU_VALUES=("gpt-5.4" "gpt-5-mini" "gpt-4.1-mini" "__custom__") + MODEL_MENU_LABELS=("Enter Azure deployment name") + MODEL_MENU_VALUES=("__custom__") ;; openrouter) MODEL_MENU_LABELS=("openrouter/auto (default)" "openai/gpt-5.2" "openai/gpt-5-mini" "anthropic/claude-sonnet-4.6" "anthropic/claude-haiku-4.5" "Other model ID") @@ -395,10 +402,31 @@ load_model_menu_for_backend() { prompt_for_model() { local backend="$1" local default_model="$2" + local prompt_label="" local choice="" local index local selected + prompt_label="$(model_prompt_label_for_backend "$backend")" + + if [ "$backend" = "azure-openai" ]; then + while true; do + if [ -n "$default_model" ]; then + read -r -p "$prompt_label (default: $default_model): " selected + selected="${selected:-$default_model}" + else + read -r -p "$prompt_label (required): " selected + fi + + if [ -n "$selected" ]; then + MODEL="$selected" + return 0 + fi + + echo "$prompt_label is required." + done + fi + load_model_menu_for_backend "$backend" while true; do @@ -421,13 +449,17 @@ prompt_for_model() { fi while true; do - read -r -p "Model ID (default: $default_model): " selected - selected="${selected:-$default_model}" + if [ -n "$default_model" ]; then + read -r -p "$prompt_label (default: $default_model): " selected + selected="${selected:-$default_model}" + else + read -r -p "$prompt_label (required): " selected + fi if [ -n "$selected" ]; then MODEL="$selected" return 0 fi - echo "Model ID is required." + echo "$prompt_label is required." done done } @@ -1153,7 +1185,13 @@ fi if [ -z "$MODEL" ]; then DEFAULT_MODEL="$(default_model_for_backend "$BACKEND")" if [ "$ASSUME_YES" = true ]; then - MODEL="$DEFAULT_MODEL" + if [ -z "$DEFAULT_MODEL" ] && [ "$BACKEND" != "azure-openai" ]; then + echo "Error: --model is required with --backend $BACKEND in --yes mode" + exit 1 + fi + if [ -n "$DEFAULT_MODEL" ]; then + MODEL="$DEFAULT_MODEL" + fi else prompt_for_model "$BACKEND" "$DEFAULT_MODEL" fi @@ -1187,6 +1225,11 @@ if [ "$BACKEND" = "azure-openai" ]; then echo "Error: invalid --api-url for Azure OpenAI. Expected https://.../openai/responses?api-version=... or https://.../openai/v1/responses?api-version=..." exit 1 fi + + if [ -z "$MODEL" ]; then + echo "Error: --model is required with --backend azure-openai in --yes mode (use your Azure deployment name)" + exit 1 + fi fi if [ "$BACKEND" != "ollama" ] && [ -z "$API_KEY" ]; then @@ -1263,11 +1306,23 @@ if [ "$VERIFY_API_KEY" = true ]; then echo "Valid API URL is required." fi elif [ "$BACKEND" = "azure-openai" ]; then - read -r -p "LLM API key (input is visible): " API_KEY - read -r -p "Azure OpenAI Responses API URL (for example https://.../openai/responses?api-version=...): " API_URL - API_URL="$(normalize_azure_openai_api_url "$API_URL" || true)" - if [ -z "$API_KEY" ] || [ -z "$API_URL" ]; then - echo "Valid API key and Azure OpenAI URL are required." + retry_model="" + retry_api_key="" + retry_api_url="" + read -r -p "$(model_prompt_label_for_backend "$BACKEND") (default: $MODEL): " retry_model + MODEL="${retry_model:-$MODEL}" + read -r -p "LLM API key (press Enter to keep current): " retry_api_key + if [ -n "$retry_api_key" ]; then + API_KEY="$retry_api_key" + fi + read -r -p "Azure OpenAI Responses API URL (press Enter to keep current): " retry_api_url + if [ -n "$retry_api_url" ]; then + API_URL="$(normalize_azure_openai_api_url "$retry_api_url" || true)" + else + API_URL="$(normalize_azure_openai_api_url "$API_URL" || true)" + fi + if [ -z "$MODEL" ] || [ -z "$API_KEY" ] || [ -z "$API_URL" ]; then + echo "Valid deployment name, API key, and Azure OpenAI URL are required." fi else read -r -p "LLM API key (input is visible): " API_KEY diff --git a/test/api/provider_harness.py b/test/api/provider_harness.py index 47060c5..432ba67 100644 --- a/test/api/provider_harness.py +++ b/test/api/provider_harness.py @@ -490,7 +490,8 @@ def _tool_defs_for_provider( def _openai_like_max_tokens_field(model: str) -> tuple[str, int]: # Mirror firmware behavior: GPT-5 chat-completions expects max_completion_tokens. - if model.lower().startswith("gpt-5"): + model_name = model.lower().rsplit("/", 1)[-1] + if model_name.startswith("gpt-5"): return ("max_completion_tokens", 1024) return ("max_tokens", 1024) diff --git a/test/host/esp_err.h b/test/host/esp_err.h index 50cbf24..5a2ac1e 100644 --- a/test/host/esp_err.h +++ b/test/host/esp_err.h @@ -14,6 +14,8 @@ static inline const char *esp_err_to_name(esp_err_t err) return "ESP_ERR_NO_MEM"; case ESP_ERR_INVALID_ARG: return "ESP_ERR_INVALID_ARG"; + case ESP_ERR_INVALID_STATE: + return "ESP_ERR_INVALID_STATE"; case ESP_ERR_NOT_FOUND: return "ESP_ERR_NOT_FOUND"; default: diff --git a/test/host/mock_esp.h b/test/host/mock_esp.h index 2db004d..9331c16 100644 --- a/test/host/mock_esp.h +++ b/test/host/mock_esp.h @@ -16,6 +16,7 @@ typedef int esp_err_t; #define ESP_FAIL -1 #define ESP_ERR_NO_MEM 0x101 #define ESP_ERR_INVALID_ARG 0x102 +#define ESP_ERR_INVALID_STATE 0x103 #define ESP_ERR_NOT_FOUND 0x105 // Mock logging diff --git a/test/host/test_api_provider_harness.py b/test/host/test_api_provider_harness.py index a20a536..d686f3a 100644 --- a/test/host/test_api_provider_harness.py +++ b/test/host/test_api_provider_harness.py @@ -30,11 +30,23 @@ def test_openai_gpt5_uses_max_completion_tokens(self) -> None: self.assertEqual(field, "max_completion_tokens") self.assertEqual(value, 1024) + def test_openai_prefixed_gpt5_uses_max_completion_tokens(self) -> None: + field, value = provider_harness._openai_like_max_tokens_field("openai/gpt-5.2") + self.assertEqual(field, "max_completion_tokens") + self.assertEqual(value, 1024) + def test_openai_non_gpt5_uses_max_tokens(self) -> None: field, value = provider_harness._openai_like_max_tokens_field("gpt-4.1-mini") self.assertEqual(field, "max_tokens") self.assertEqual(value, 1024) + def test_openai_prefixed_non_gpt5_uses_max_tokens(self) -> None: + field, value = provider_harness._openai_like_max_tokens_field( + "openai/gpt-4.1-mini" + ) + self.assertEqual(field, "max_tokens") + self.assertEqual(value, 1024) + def test_extract_anthropic_round_tool_call(self) -> None: response = { "stop_reason": "tool_use", @@ -177,6 +189,34 @@ def fake_post( request_json = payload["json"] self.assertEqual(request_json["messages"], messages) + def test_call_api_openrouter_prefixed_gpt5_uses_max_completion_tokens(self) -> None: + provider = provider_harness.PROVIDERS["openrouter"] + messages = [{"role": "user", "content": "Hello"}] + payload: dict[str, Any] = {} + + def fake_post( + url: str, headers: dict[str, str], json: dict[str, Any], timeout: int + ) -> Mock: + payload["url"] = url + payload["headers"] = headers + payload["json"] = json + payload["timeout"] = timeout + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = {"ok": True} + return response + + with patch.object(provider_harness, "httpx", SimpleNamespace(post=fake_post)): + result = provider_harness.call_api( + provider, messages, "test-key", "openai/gpt-5.2", user_tools=[] + ) + + self.assertEqual(result, {"ok": True}) + request_json = payload["json"] + self.assertEqual(request_json["model"], "openai/gpt-5.2") + self.assertEqual(request_json["max_completion_tokens"], 1024) + self.assertNotIn("max_tokens", request_json) + def test_call_api_azure_openai_uses_api_key_header_and_env_url(self) -> None: provider = provider_harness.PROVIDERS["azure-openai"] messages = [{"role": "user", "content": "Hello"}] diff --git a/test/host/test_install_provision_scripts.py b/test/host/test_install_provision_scripts.py index ad73ad3..587c267 100644 --- a/test/host/test_install_provision_scripts.py +++ b/test/host/test_install_provision_scripts.py @@ -21,6 +21,7 @@ ERASE_SH = PROJECT_ROOT / "scripts" / "erase.sh" BUILD_SH = PROJECT_ROOT / "scripts" / "build.sh" FLASH_SH = PROJECT_ROOT / "scripts" / "flash.sh" +EMULATE_SH = PROJECT_ROOT / "scripts" / "emulate.sh" def _write_executable(path: Path, content: str) -> None: @@ -259,6 +260,371 @@ def test_install_idf_chip_list_includes_esp32(self) -> None: install_text = INSTALL_SH.read_text(encoding="utf-8") self.assertIn('ESP_IDF_CHIPS="esp32,esp32c3,esp32c6,esp32s3"', install_text) + def test_emulate_live_api_auto_seeds_azure_runtime_from_host_env(self) -> None: + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + home = tmp / "home" + idf_dir = home / "esp" / "esp-idf" + nvs_gen = ( + idf_dir + / "components" + / "nvs_flash" + / "nvs_partition_generator" + / "nvs_partition_gen.py" + ) + nvs_gen.parent.mkdir(parents=True, exist_ok=True) + nvs_gen.write_text("# nvs generator stub path\n", encoding="utf-8") + (idf_dir / "export.sh").write_text( + 'export IDF_PATH="$HOME/esp/esp-idf"\n', + encoding="utf-8", + ) + + bin_dir = tmp / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + _write_executable( + bin_dir / "qemu-system-riscv32", + "#!/bin/sh\nexit 0\n", + ) + _write_executable( + bin_dir / "idf.py", + "#!/bin/sh\n" + "build_dir=''\n" + "while [ $# -gt 0 ]; do\n" + ' if [ "$1" = "-B" ]; then build_dir="$2"; shift 2; continue; fi\n' + " shift\n" + "done\n" + 'mkdir -p "$build_dir/bootloader" "$build_dir/partition_table"\n' + 'printf "boot" > "$build_dir/bootloader/bootloader.bin"\n' + 'printf "part" > "$build_dir/partition_table/partition-table.bin"\n' + 'printf "app" > "$build_dir/zclaw.bin"\n' + "exit 0\n", + ) + _write_executable( + bin_dir / "esptool.py", + "#!/bin/sh\n" + "out=''\n" + "while [ $# -gt 0 ]; do\n" + ' if [ "$1" = "-o" ]; then out="$2"; shift 2; continue; fi\n' + " shift\n" + "done\n" + 'printf "merged" > "$out"\n' + "exit 0\n", + ) + _write_executable( + bin_dir / "python3", + "#!/bin/sh\n" + 'if [ "$2" = "generate" ]; then\n' + ' cp "$3" "$CSV_CAPTURE"\n' + ' : > "$4"\n' + " exit 0\n" + "fi\n" + "exit 0\n", + ) + + env = os.environ.copy() + env["HOME"] = str(home) + env["PATH"] = f"{bin_dir}:/usr/bin:/bin:/usr/sbin:/sbin" + env["TERM"] = "dumb" + env["ZCLAW_QEMU_BUILD_DIR"] = str(tmp / "build-qemu") + env["ZCLAW_QEMU_SDKCONFIG"] = str(tmp / "sdkconfig.qemu") + env["CSV_CAPTURE"] = str(tmp / "captured-nvs.csv") + env.pop("ANTHROPIC_API_KEY", None) + env.pop("OPENAI_API_KEY", None) + env["AZURE_OPENAI_API_KEY"] = "azure-sk-test-xyz" + env["AZURE_OPENAI_API_URL"] = ( + "https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview" + ) + env["AZURE_OPENAI_MODEL"] = "demo-deployment" + + proc = subprocess.run( + [ + str(EMULATE_SH), + "--live-api", + "--live-api-provider", + "auto", + ], + cwd=PROJECT_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + captured_csv = ( + (tmp / "captured-nvs.csv").read_text(encoding="utf-8") + if (tmp / "captured-nvs.csv").exists() + else "" + ) + + output = f"{proc.stdout}\n{proc.stderr}" + self.assertEqual(proc.returncode, 0, msg=output) + self.assertIn("Provider selection: auto", output) + self.assertIn("Runtime backend: azure-openai", output) + self.assertIn("Runtime model: demo-deployment", output) + self.assertIn( + "Auto mode: runtime config is seeded from host env, then bridge infers provider from request format.", + output, + ) + self.assertIn('llm_backend,data,string,"azure-openai"', captured_csv) + self.assertIn('llm_model,data,string,"demo-deployment"', captured_csv) + self.assertIn( + 'llm_api_url,data,string,"https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview"', + captured_csv, + ) + + def test_emulate_live_api_explicit_azure_requires_model(self) -> None: + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + home = tmp / "home" + export_dir = home / "esp" / "esp-idf" + export_dir.mkdir(parents=True, exist_ok=True) + (export_dir / "export.sh").write_text( + 'export IDF_PATH="$HOME/esp/esp-idf"\n', + encoding="utf-8", + ) + + bin_dir = tmp / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + _write_executable( + bin_dir / "qemu-system-riscv32", + "#!/bin/sh\nexit 0\n", + ) + + env = os.environ.copy() + env["HOME"] = str(home) + env["PATH"] = f"{bin_dir}:/usr/bin:/bin:/usr/sbin:/sbin" + env["TERM"] = "dumb" + env["ZCLAW_QEMU_BUILD_DIR"] = str(tmp / "build-qemu") + env["ZCLAW_QEMU_SDKCONFIG"] = str(tmp / "sdkconfig.qemu") + env["AZURE_OPENAI_API_KEY"] = "azure-sk-test-xyz" + env["AZURE_OPENAI_API_URL"] = ( + "https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview" + ) + env.pop("AZURE_OPENAI_MODEL", None) + + proc = subprocess.run( + [ + str(EMULATE_SH), + "--live-api", + "--live-api-provider", + "azure-openai", + ], + cwd=PROJECT_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + output = f"{proc.stdout}\n{proc.stderr}" + self.assertNotEqual(proc.returncode, 0, msg=output) + self.assertIn( + "Error: AZURE_OPENAI_MODEL is required for --live-api-provider azure-openai", + output, + ) + + def test_emulate_live_api_auto_requires_azure_model_for_azure_only_env(self) -> None: + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + home = tmp / "home" + export_dir = home / "esp" / "esp-idf" + export_dir.mkdir(parents=True, exist_ok=True) + (export_dir / "export.sh").write_text( + 'export IDF_PATH="$HOME/esp/esp-idf"\n', + encoding="utf-8", + ) + + bin_dir = tmp / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + _write_executable( + bin_dir / "qemu-system-riscv32", + "#!/bin/sh\nexit 0\n", + ) + + env = os.environ.copy() + env["HOME"] = str(home) + env["PATH"] = f"{bin_dir}:/usr/bin:/bin:/usr/sbin:/sbin" + env["TERM"] = "dumb" + env["ZCLAW_QEMU_BUILD_DIR"] = str(tmp / "build-qemu") + env["ZCLAW_QEMU_SDKCONFIG"] = str(tmp / "sdkconfig.qemu") + env.pop("ANTHROPIC_API_KEY", None) + env.pop("OPENAI_API_KEY", None) + env["AZURE_OPENAI_API_KEY"] = "azure-sk-test-xyz" + env["AZURE_OPENAI_API_URL"] = ( + "https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview" + ) + env.pop("AZURE_OPENAI_MODEL", None) + + proc = subprocess.run( + [ + str(EMULATE_SH), + "--live-api", + "--live-api-provider", + "auto", + ], + cwd=PROJECT_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + output = f"{proc.stdout}\n{proc.stderr}" + self.assertNotEqual(proc.returncode, 0, msg=output) + self.assertIn( + "Error: AZURE_OPENAI_MODEL is required for Azure OpenAI in --live-api auto mode", + output, + ) + + def test_emulate_live_api_explicit_anthropic_seeds_runtime_backend(self) -> None: + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + home = tmp / "home" + idf_dir = home / "esp" / "esp-idf" + nvs_gen = ( + idf_dir + / "components" + / "nvs_flash" + / "nvs_partition_generator" + / "nvs_partition_gen.py" + ) + nvs_gen.parent.mkdir(parents=True, exist_ok=True) + nvs_gen.write_text("# nvs generator stub path\n", encoding="utf-8") + (idf_dir / "export.sh").write_text( + 'export IDF_PATH="$HOME/esp/esp-idf"\n', + encoding="utf-8", + ) + + bin_dir = tmp / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + _write_executable( + bin_dir / "qemu-system-riscv32", + "#!/bin/sh\nexit 0\n", + ) + _write_executable( + bin_dir / "idf.py", + "#!/bin/sh\n" + "build_dir=''\n" + "while [ $# -gt 0 ]; do\n" + ' if [ "$1" = "-B" ]; then build_dir="$2"; shift 2; continue; fi\n' + " shift\n" + "done\n" + 'mkdir -p "$build_dir/bootloader" "$build_dir/partition_table"\n' + 'printf "boot" > "$build_dir/bootloader/bootloader.bin"\n' + 'printf "part" > "$build_dir/partition_table/partition-table.bin"\n' + 'printf "app" > "$build_dir/zclaw.bin"\n' + "exit 0\n", + ) + _write_executable( + bin_dir / "esptool.py", + "#!/bin/sh\n" + "out=''\n" + "while [ $# -gt 0 ]; do\n" + ' if [ "$1" = "-o" ]; then out="$2"; shift 2; continue; fi\n' + " shift\n" + "done\n" + 'printf "merged" > "$out"\n' + "exit 0\n", + ) + _write_executable( + bin_dir / "python3", + "#!/bin/sh\n" + 'if [ "$2" = "generate" ]; then\n' + ' cp "$3" "$CSV_CAPTURE"\n' + ' : > "$4"\n' + " exit 0\n" + "fi\n" + "exit 0\n", + ) + + env = os.environ.copy() + env["HOME"] = str(home) + env["PATH"] = f"{bin_dir}:/usr/bin:/bin:/usr/sbin:/sbin" + env["TERM"] = "dumb" + env["ZCLAW_QEMU_BUILD_DIR"] = str(tmp / "build-qemu") + env["ZCLAW_QEMU_SDKCONFIG"] = str(tmp / "sdkconfig.qemu") + env["CSV_CAPTURE"] = str(tmp / "captured-nvs.csv") + env["ANTHROPIC_API_KEY"] = "anthropic-sk-test-xyz" + env.pop("ANTHROPIC_MODEL", None) + + proc = subprocess.run( + [ + str(EMULATE_SH), + "--live-api", + "--live-api-provider", + "anthropic", + ], + cwd=PROJECT_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + captured_csv = ( + (tmp / "captured-nvs.csv").read_text(encoding="utf-8") + if (tmp / "captured-nvs.csv").exists() + else "" + ) + + output = f"{proc.stdout}\n{proc.stderr}" + self.assertEqual(proc.returncode, 0, msg=output) + self.assertIn("Provider selection: anthropic", output) + self.assertIn("Runtime backend: anthropic", output) + self.assertIn("Runtime model: claude-sonnet-4-6", output) + self.assertIn('llm_backend,data,string,"anthropic"', captured_csv) + self.assertIn('llm_model,data,string,"claude-sonnet-4-6"', captured_csv) + + def test_emulate_live_api_auto_rejects_missing_provider_creds(self) -> None: + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + home = tmp / "home" + export_dir = home / "esp" / "esp-idf" + export_dir.mkdir(parents=True, exist_ok=True) + (export_dir / "export.sh").write_text( + 'export IDF_PATH="$HOME/esp/esp-idf"\n', + encoding="utf-8", + ) + + bin_dir = tmp / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + _write_executable( + bin_dir / "qemu-system-riscv32", + "#!/bin/sh\nexit 0\n", + ) + + env = os.environ.copy() + env["HOME"] = str(home) + env["PATH"] = f"{bin_dir}:/usr/bin:/bin:/usr/sbin:/sbin" + env["TERM"] = "dumb" + env["ZCLAW_QEMU_BUILD_DIR"] = str(tmp / "build-qemu") + env["ZCLAW_QEMU_SDKCONFIG"] = str(tmp / "sdkconfig.qemu") + env.pop("ANTHROPIC_API_KEY", None) + env.pop("OPENAI_API_KEY", None) + env.pop("AZURE_OPENAI_API_KEY", None) + env.pop("AZURE_OPENAI_API_URL", None) + + proc = subprocess.run( + [ + str(EMULATE_SH), + "--live-api", + "--live-api-provider", + "auto", + ], + cwd=PROJECT_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + output = f"{proc.stdout}\n{proc.stderr}" + self.assertNotEqual(proc.returncode, 0, msg=output) + self.assertIn( + "Error: set OPENAI_API_KEY, ANTHROPIC_API_KEY, or AZURE_OPENAI_API_KEY + AZURE_OPENAI_API_URL + AZURE_OPENAI_MODEL for --live-api mode", + output, + ) + def test_kconfig_defaults_uart_when_usb_serial_jtag_is_unsupported(self) -> None: kconfig_text = KCONFIG_PROJBUILD.read_text(encoding="utf-8") self.assertIn("config ZCLAW_CHANNEL_UART", kconfig_text) @@ -592,6 +958,7 @@ def _run_provision_api_check_fail( self, backend: str, api_url: str | None = None, + model: str | None = None, ) -> subprocess.CompletedProcess[str]: with tempfile.TemporaryDirectory() as td: tmp = Path(td) @@ -639,6 +1006,8 @@ def _run_provision_api_check_fail( "--api-key", "sk-test", ] + if model is not None: + cmd.extend(["--model", model]) if api_url is not None: cmd.extend(["--api-url", api_url]) @@ -656,6 +1025,7 @@ def _run_provision_api_check_capture_url( *, backend: str, api_url: str, + model: str | None = None, ) -> tuple[subprocess.CompletedProcess[str], str]: with tempfile.TemporaryDirectory() as td: tmp = Path(td) @@ -719,6 +1089,7 @@ def _run_provision_api_check_capture_url( "password123", "--backend", backend, + *(["--model", model] if model is not None else []), "--api-key", "sk-test", "--api-url", @@ -927,6 +1298,7 @@ def test_provision_azure_openai_api_check_runs_in_yes_mode(self) -> None: proc = self._run_provision_api_check_fail( "azure-openai", api_url="https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview", + model="gepete-5", ) output = f"{proc.stdout}\n{proc.stderr}" self.assertNotEqual(proc.returncode, 0, msg=output) @@ -962,6 +1334,7 @@ def test_provision_azure_openai_api_check_uses_exact_chat_url(self) -> None: proc, called_url = self._run_provision_api_check_capture_url( backend="azure-openai", api_url=api_url, + model="gepete-5", ) output = f"{proc.stdout}\n{proc.stderr}" self.assertNotEqual(proc.returncode, 0, msg=output) @@ -1005,6 +1378,8 @@ def test_provision_azure_openai_requires_api_url_in_yes_mode(self) -> None: "password123", "--backend", "azure-openai", + "--model", + "gepete-5", "--api-key", "sk-test", ], @@ -1022,6 +1397,22 @@ def test_provision_azure_openai_requires_api_url_in_yes_mode(self) -> None: output, ) + def test_provision_azure_openai_requires_model_in_yes_mode( + self, + ) -> None: + proc, _captured_csv = self._run_provision_capture_csv( + backend="azure-openai", + assume_yes=True, + api_key="sk-test", + api_url="https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview", + ) + output = f"{proc.stdout}\n{proc.stderr}" + self.assertNotEqual(proc.returncode, 0, msg=output) + self.assertIn( + "Error: --model is required with --backend azure-openai in --yes mode", + output, + ) + def test_provision_rejects_ssid_longer_than_32_chars(self) -> None: proc = self._run_provision_length_validation( ssid="S" * 33, @@ -1117,25 +1508,146 @@ def test_provision_interactive_openai_model_menu_accepts_custom_model(self) -> N self.assertIn("Select model for openai:", output) self.assertIn('llm_model,data,string,"custom-model-123"', captured_csv) - def test_provision_interactive_azure_openai_model_menu_defaults_to_first_choice( + def test_provision_interactive_azure_openai_model_prompt_accepts_custom_deployment( self, ) -> None: proc, captured_csv = self._run_provision_capture_csv( backend="azure-openai", assume_yes=False, - input_text="\nhttps://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview\ny\n\n\n", + input_text="gepete-5\nhttps://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview\ny\n\n\n", api_key="sk-test", ) output = f"{proc.stdout}\n{proc.stderr}" self.assertEqual(proc.returncode, 0, msg=output) - self.assertIn("Select model for azure-openai:", output) self.assertIn('llm_backend,data,string,"azure-openai"', captured_csv) - self.assertIn('llm_model,data,string,"gpt-5.4"', captured_csv) + self.assertIn('llm_model,data,string,"gepete-5"', captured_csv) self.assertIn( 'llm_api_url,data,string,"https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview"', captured_csv, ) + def test_provision_interactive_azure_openai_retry_can_fix_deployment_name( + self, + ) -> None: + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + home = tmp / "home" + idf_dir = home / "esp" / "esp-idf" + nvs_gen = ( + idf_dir + / "components" + / "nvs_flash" + / "nvs_partition_generator" + / "nvs_partition_gen.py" + ) + parttool = idf_dir / "components" / "partition_table" / "parttool.py" + nvs_gen.parent.mkdir(parents=True, exist_ok=True) + parttool.parent.mkdir(parents=True, exist_ok=True) + nvs_gen.write_text("# nvs generator stub path\n", encoding="utf-8") + parttool.write_text("# parttool stub path\n", encoding="utf-8") + (idf_dir / "export.sh").write_text( + 'export IDF_PATH="$HOME/esp/esp-idf"\n', + encoding="utf-8", + ) + + bin_dir = tmp / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + _write_executable( + bin_dir / "python", + "#!/bin/sh\n" + 'if [ "$2" = "generate" ]; then\n' + ' cp "$3" "$CSV_CAPTURE"\n' + ' : > "$4"\n' + " exit 0\n" + "fi\n" + "exit 0\n", + ) + _write_executable( + bin_dir / "curl", + "#!/bin/sh\n" + "out=''\n" + "body=''\n" + "while [ $# -gt 0 ]; do\n" + ' case "$1" in\n' + ' -o) out="$2"; shift 2 ;;\n' + ' -d) body="$2"; shift 2 ;;\n' + " -w|-H|--connect-timeout|--max-time) shift 2 ;;\n" + " -s|-S|-sS) shift ;;\n" + " *) shift ;;\n" + " esac\n" + "done\n" + 'model="unknown"\n' + 'case "$body" in\n' + ' *bad-deployment*) model="bad-deployment" ;;\n' + ' *good-deployment*) model="good-deployment" ;;\n' + "esac\n" + 'printf "%s\\n" "$model" >> "$CURL_MODELS_FILE"\n' + 'if [ "$model" = "good-deployment" ]; then\n' + ' if [ -n "$out" ]; then printf "%s" "{}" > "$out"; fi\n' + ' printf "%s" "200"\n' + "else\n" + ' if [ -n "$out" ]; then printf \'%s\' \'{"error":{"message":"deployment not found"}}\' > "$out"; fi\n' + ' printf "%s" "401"\n' + "fi\n", + ) + + env = os.environ.copy() + env["HOME"] = str(home) + env["PATH"] = f"{bin_dir}:/usr/bin:/bin:/usr/sbin:/sbin" + env["TERM"] = "dumb" + env["CSV_CAPTURE"] = str(tmp / "captured-nvs.csv") + env["CURL_MODELS_FILE"] = str(tmp / "curl-models.txt") + + proc = subprocess.run( + [ + str(PROVISION_SH), + "--port", + "/dev/null", + "--ssid", + "HomeNetwork", + "--pass", + "password123", + "--backend", + "azure-openai", + "--api-key", + "sk-test", + ], + cwd=PROJECT_ROOT, + env=env, + text=True, + input=( + "bad-deployment\n" + "https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview\n" + "y\n" + "good-deployment\n" + "\n" + "\n" + "y\n" + "\n" + "\n" + ), + capture_output=True, + check=False, + ) + + output = f"{proc.stdout}\n{proc.stderr}" + captured_csv = ( + (tmp / "captured-nvs.csv").read_text(encoding="utf-8") + if (tmp / "captured-nvs.csv").exists() + else "" + ) + curl_models = ( + (tmp / "curl-models.txt").read_text(encoding="utf-8") + if (tmp / "curl-models.txt").exists() + else "" + ) + + self.assertEqual(proc.returncode, 0, msg=output) + self.assertIn("deployment not found", output) + self.assertIn("bad-deployment\n", curl_models) + self.assertIn("good-deployment\n", curl_models) + self.assertIn('llm_model,data,string,"good-deployment"', captured_csv) + def test_provision_interactive_ollama_model_menu_defaults_to_qwen(self) -> None: proc, captured_csv = self._run_provision_capture_csv( backend="ollama", @@ -1500,7 +2012,8 @@ def test_provision_dev_write_template_creates_profile(self) -> None: self.assertTrue(env_file.exists(), msg=output) content = env_file.read_text(encoding="utf-8") self.assertIn("ZCLAW_WIFI_SSID", content) - self.assertIn("ZCLAW_MODEL=gpt-5.4", content) + self.assertIn("ZCLAW_MODEL=", content) + self.assertNotIn("ZCLAW_MODEL=gpt-5.4", content) self.assertIn("ZCLAW_API_KEY", content) self.assertIn("ZCLAW_API_URL", content) self.assertIn("AZURE_OPENAI_API_KEY", content) @@ -1630,6 +2143,7 @@ def test_provision_dev_uses_azure_provider_specific_env_key(self) -> None: [ "ZCLAW_WIFI_SSID=Trident", "ZCLAW_BACKEND=azure-openai", + "ZCLAW_MODEL=gepete-5", "ZCLAW_API_URL=https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview", "", ] @@ -1660,9 +2174,99 @@ def test_provision_dev_uses_azure_provider_specific_env_key(self) -> None: args_text = args_file.read_text(encoding="utf-8") self.assertIn("--backend", args_text) self.assertIn("azure-openai", args_text) + self.assertIn("--model", args_text) + self.assertIn("gepete-5", args_text) self.assertIn("--api-key", args_text) self.assertIn("azure-sk-test-xyz", args_text) + def test_provision_dev_azure_openai_requires_model(self) -> None: + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + env_file = tmp / "dev.env" + env_file.write_text( + "\n".join( + [ + "ZCLAW_WIFI_SSID=Trident", + "ZCLAW_BACKEND=azure-openai", + "ZCLAW_API_URL=https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview", + "", + ] + ), + encoding="utf-8", + ) + + env = os.environ.copy() + env["AZURE_OPENAI_API_KEY"] = "azure-sk-test-xyz" + + proc = subprocess.run( + [ + str(PROVISION_DEV_SH), + "--env-file", + str(env_file), + ], + cwd=PROJECT_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + output = f"{proc.stdout}\n{proc.stderr}" + self.assertNotEqual(proc.returncode, 0, msg=output) + self.assertIn( + "Error: model/deployment name not set for Azure OpenAI backend.", + output, + ) + + def test_provision_dev_azure_show_config_with_model( + self, + ) -> None: + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + env_file = tmp / "dev.env" + args_file = tmp / "args.txt" + stub = tmp / "mock-provision.sh" + + _write_executable( + stub, + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$ARGS_FILE"\n', + ) + env_file.write_text( + "\n".join( + [ + "ZCLAW_WIFI_SSID=Trident", + "ZCLAW_BACKEND=azure-openai", + "ZCLAW_MODEL=gepete-5", + "ZCLAW_API_URL=https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview", + "", + ] + ), + encoding="utf-8", + ) + + env = os.environ.copy() + env["ARGS_FILE"] = str(args_file) + env["ZCLAW_PROVISION_SCRIPT"] = str(stub) + env["AZURE_OPENAI_API_KEY"] = "azure-sk-test-xyz" + + proc = subprocess.run( + [ + str(PROVISION_DEV_SH), + "--env-file", + str(env_file), + "--show-config", + ], + cwd=PROJECT_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + output = f"{proc.stdout}\n{proc.stderr}" + self.assertEqual(proc.returncode, 0, msg=output) + self.assertIn("Model: gepete-5", output) + def test_provision_dev_forwards_multi_telegram_chat_ids(self) -> None: with tempfile.TemporaryDirectory() as td: tmp = Path(td) diff --git a/test/host/test_llm_runtime.c b/test/host/test_llm_runtime.c index 10881be..1c5a003 100644 --- a/test/host/test_llm_runtime.c +++ b/test/host/test_llm_runtime.c @@ -139,11 +139,26 @@ TEST(azure_openai_requires_explicit_api_url) configure_mock_store("azure-openai", NULL, "test-key", NULL); ASSERT(llm_init() == ESP_OK); ASSERT(strcmp(llm_get_api_url(), "") == 0); - ASSERT(strcmp(llm_get_model(), LLM_DEFAULT_MODEL_AZURE_OPENAI) == 0); + ASSERT(strcmp(llm_get_model(), "") == 0); ASSERT(llm_uses_responses_api()); return 0; } +TEST(azure_openai_requires_explicit_model_for_requests) +{ + char response[LLM_RESPONSE_BUF_SIZE] = {0}; + + configure_mock_store( + "azure-openai", + NULL, + "test-key", + "https://demo.openai.azure.com/openai/responses?api-version=2025-04-01-preview" + ); + ASSERT(llm_init() == ESP_OK); + ASSERT(llm_request("{\"message\":\"hello\"}", response, sizeof(response)) == ESP_ERR_INVALID_STATE); + return 0; +} + TEST(custom_api_url_override_applies_to_any_backend) { configure_mock_store("openai", NULL, "test-key", "http://192.168.1.50:11434/v1/chat/completions"); @@ -233,6 +248,13 @@ int test_llm_runtime_all(void) failures++; } + printf(" azure_openai_requires_explicit_model_for_requests... "); + if (test_azure_openai_requires_explicit_model_for_requests() == 0) { + printf("OK\n"); + } else { + failures++; + } + printf(" custom_api_url_override_applies_to_any_backend... "); if (test_custom_api_url_override_applies_to_any_backend() == 0) { printf("OK\n"); From f3d364b0f3b69813779a4e8b5c1514b83498c943 Mon Sep 17 00:00:00 2001 From: Alwin Arrasyid Date: Sat, 28 Mar 2026 10:17:30 +0700 Subject: [PATCH 3/3] fix: missing reasoning_item on response API --- main/agent.c | 96 +++++++++++++++++++------- main/json_util.c | 17 +++-- main/json_util.h | 3 +- test/host/test_agent.c | 14 ++-- test/host/test_json_util_integration.c | 51 ++++++++++++-- 5 files changed, 141 insertions(+), 40 deletions(-) diff --git a/main/agent.c b/main/agent.c index d18c146..fbb5db4 100644 --- a/main/agent.c +++ b/main/agent.c @@ -44,6 +44,7 @@ static char s_test_persona_value[16] = {0}; // Conversation history (rolling message buffer) static conversation_msg_t s_history[MAX_HISTORY_TURNS * 2]; static int s_history_len = 0; +static char s_responses_previous_response_id[128] = {0}; // Buffers (static to avoid stack overflow) static char s_response_buf[LLM_RESPONSE_BUF_SIZE]; @@ -147,6 +148,18 @@ static void history_add_response_item(const char *item_json) history_add("assistant", item_json, false, false, true, NULL, NULL); } +static void responses_set_previous_response_id(const char *response_id) +{ + if (!response_id) { + s_responses_previous_response_id[0] = '\0'; + return; + } + + strncpy(s_responses_previous_response_id, response_id, + sizeof(s_responses_previous_response_id) - 1); + s_responses_previous_response_id[sizeof(s_responses_previous_response_id) - 1] = '\0'; +} + static void queue_channel_response(const char *text) { if (!s_channel_output_queue) { @@ -407,6 +420,7 @@ static void process_message(const char *user_message, message_source_t source, i { ESP_LOGI(TAG, "Processing: %s", user_message); int history_turn_start = s_history_len; + char previous_response_id_turn_start[sizeof(s_responses_previous_response_id)] = {0}; bool is_non_command_message = !agent_is_slash_command(user_message); bool is_cron_trigger = agent_is_cron_trigger_message(user_message); bool telegram_polling_paused = false; @@ -419,6 +433,10 @@ static void process_message(const char *user_message, message_source_t source, i .rounds = 0, }; + strncpy(previous_response_id_turn_start, s_responses_previous_response_id, + sizeof(previous_response_id_turn_start) - 1); + previous_response_id_turn_start[sizeof(previous_response_id_turn_start) - 1] = '\0'; + if (agent_is_command(user_message, "resume")) { if (!s_messages_paused) { send_response("zclaw is already active.", reply_chat_id); @@ -517,19 +535,32 @@ static void process_message(const char *user_message, message_source_t source, i rounds++; metrics.rounds = rounds; + // Azure/OpenAI Responses requests can chain from the server-side response id, + // so only send the latest delta item once we have one. + const conversation_msg_t *request_history = s_history; + int request_history_len = s_history_len; + const char *previous_response_id = NULL; + if (llm_uses_responses_api() && s_responses_previous_response_id[0] != '\0') { + previous_response_id = s_responses_previous_response_id; + request_history = &s_history[s_history_len - 1]; + request_history_len = 1; + } + // Build request JSON (user message already in history) char *request = json_build_request( agent_build_system_prompt(s_persona, s_system_prompt_buf, sizeof(s_system_prompt_buf)), - s_history, - s_history_len, + request_history, + request_history_len, NULL, // User message already in history tools, - tool_count + tool_count, + previous_response_id ); if (!request) { ESP_LOGE(TAG, "Failed to build request JSON"); history_rollback_to(history_turn_start, "request build failed"); + responses_set_previous_response_id(previous_response_id_turn_start); send_response("Error: Failed to build request", reply_chat_id); telegram_resume_polling(); telegram_polling_paused = false; @@ -544,6 +575,7 @@ static void process_message(const char *user_message, message_source_t source, i if (!ratelimit_check(rate_reason, sizeof(rate_reason))) { free(request); history_rollback_to(history_turn_start, "rate limited"); + responses_set_previous_response_id(previous_response_id_turn_start); send_response(rate_reason, reply_chat_id); telegram_resume_polling(); telegram_polling_paused = false; @@ -615,6 +647,7 @@ static void process_message(const char *user_message, message_source_t source, i if (err != ESP_OK) { ESP_LOGE(TAG, "LLM request failed after %d retries", LLM_MAX_RETRIES); history_rollback_to(history_turn_start, "llm request failed"); + responses_set_previous_response_id(previous_response_id_turn_start); send_response("Error: Failed to contact LLM API after retries", reply_chat_id); telegram_resume_polling(); telegram_polling_paused = false; @@ -637,6 +670,7 @@ static void process_message(const char *user_message, message_source_t source, i &tool_input)) { ESP_LOGE(TAG, "Failed to parse response"); history_rollback_to(history_turn_start, "llm response parse failed"); + responses_set_previous_response_id(previous_response_id_turn_start); send_response("Error: Failed to parse LLM response", reply_chat_id); json_free_parsed_response(); telegram_resume_polling(); @@ -645,6 +679,15 @@ static void process_message(const char *user_message, message_source_t source, i return; } + if (llm_uses_responses_api()) { + const cJSON *parsed = json_get_parsed_response(); + const cJSON *response_id = parsed ? cJSON_GetObjectItem((cJSON *)parsed, "id") : NULL; + if (response_id && cJSON_IsString((cJSON *)response_id) && + response_id->valuestring[0] != '\0') { + responses_set_previous_response_id(response_id->valuestring); + } + } + // Check if it's a tool use if (tool_name[0] != '\0' && tool_input) { ESP_LOGI(TAG, "Tool call: %s (round %d)", tool_name, rounds); @@ -655,27 +698,30 @@ static void process_message(const char *user_message, message_source_t source, i if (llm_uses_responses_api()) { const cJSON *parsed = json_get_parsed_response(); const cJSON *output = parsed ? cJSON_GetObjectItem((cJSON *)parsed, "output") : NULL; - const cJSON *item = NULL; - if (output && cJSON_IsArray(output)) { - cJSON_ArrayForEach(item, output) { - if (!cJSON_IsObject((cJSON *)item)) { - continue; - } - - // Preserve every raw Responses output item for the next turn. - // OpenAI's Responses tool-calling flow expects the model's prior - // output items (especially reasoning and tool calls) to be fed - // back alongside function_call_output items. - cJSON *copy = cJSON_Duplicate((cJSON *)item, 1); - char *item_json = NULL; - if (copy) { - cJSON_DeleteItemFromObject(copy, "_parsed_arguments"); - item_json = cJSON_PrintUnformatted(copy); - cJSON_Delete(copy); - } - if (item_json) { - history_add_response_item(item_json); - free(item_json); + const cJSON *response_id = parsed ? cJSON_GetObjectItem((cJSON *)parsed, "id") : NULL; + if (!(response_id && cJSON_IsString((cJSON *)response_id) && + response_id->valuestring[0] != '\0')) { + const cJSON *item = NULL; + if (output && cJSON_IsArray(output)) { + cJSON_ArrayForEach(item, output) { + if (!cJSON_IsObject((cJSON *)item)) { + continue; + } + + // Fallback for Responses payloads that do not provide a + // top-level response id. In that case we still need to replay + // the prior raw output items to preserve reasoning state. + cJSON *copy = cJSON_Duplicate((cJSON *)item, 1); + char *item_json = NULL; + if (copy) { + cJSON_DeleteItemFromObject(copy, "_parsed_arguments"); + item_json = cJSON_PrintUnformatted(copy); + cJSON_Delete(copy); + } + if (item_json) { + history_add_response_item(item_json); + free(item_json); + } } } } @@ -742,6 +788,7 @@ static void process_message(const char *user_message, message_source_t source, i if (!done) { ESP_LOGW(TAG, "Max tool rounds reached"); + responses_set_previous_response_id(previous_response_id_turn_start); history_add("assistant", "(Reached max tool iterations)", false, false, false, NULL, NULL); send_response("(Reached max tool iterations)", reply_chat_id); telegram_resume_polling(); @@ -768,6 +815,7 @@ void agent_test_reset(void) { memset(s_history, 0, sizeof(s_history)); s_history_len = 0; + memset(s_responses_previous_response_id, 0, sizeof(s_responses_previous_response_id)); memset(s_response_buf, 0, sizeof(s_response_buf)); memset(s_tool_result_buf, 0, sizeof(s_tool_result_buf)); s_channel_output_queue = NULL; diff --git a/main/json_util.c b/main/json_util.c index 8a9e3be..ee4e36a 100644 --- a/main/json_util.c +++ b/main/json_util.c @@ -614,7 +614,8 @@ static char *build_responses_api_request( int history_len, const char *user_message, const tool_def_t *tools, - int tool_count) + int tool_count, + const char *previous_response_id) { cJSON *root = cJSON_CreateObject(); cJSON *input = NULL; @@ -629,6 +630,11 @@ static char *build_responses_api_request( goto fail; } + if (previous_response_id && previous_response_id[0] != '\0' && + !cJSON_AddStringToObject(root, "previous_response_id", previous_response_id)) { + goto fail; + } + cJSON *reasoning = cJSON_AddObjectToObject(root, "reasoning"); if (!reasoning || !cJSON_AddStringToObject(reasoning, "effort", "low")) { goto fail; @@ -646,7 +652,8 @@ static char *build_responses_api_request( } else if (history[i].is_tool_use) { item = create_responses_function_call_item(&history[i]); } else if (history[i].is_tool_result) { - if (!history_has_prior_tool_use(history, i, history[i].tool_id)) { + if ((!previous_response_id || previous_response_id[0] == '\0') && + !history_has_prior_tool_use(history, i, history[i].tool_id)) { ESP_LOGW(TAG, "Skipping orphan tool_result in history[%d] (id=%s)", i, history[i].tool_id); continue; @@ -814,13 +821,15 @@ char *json_build_request( int history_len, const char *user_message, const tool_def_t *tools, - int tool_count) + int tool_count, + const char *previous_response_id) { char *json_str; if (llm_uses_responses_api()) { json_str = build_responses_api_request(system_prompt, history, history_len, - user_message, tools, tool_count); + user_message, tools, tool_count, + previous_response_id); } else if (llm_is_openai_format()) { json_str = build_openai_request(system_prompt, history, history_len, user_message, tools, tool_count); diff --git a/main/json_util.h b/main/json_util.h index 0102de3..bcf699f 100644 --- a/main/json_util.h +++ b/main/json_util.h @@ -27,7 +27,8 @@ char *json_build_request( int history_len, const char *user_message, const struct tool_def *tools, - int tool_count + int tool_count, + const char *previous_response_id ); // Parse the API response, extracting: diff --git a/test/host/test_agent.c b/test/host/test_agent.c index cfccbf0..a74e20a 100644 --- a/test/host/test_agent.c +++ b/test/host/test_agent.c @@ -256,12 +256,13 @@ TEST(channel_output_allows_long_response) return 0; } -TEST(responses_tool_followup_preserves_all_output_items) +TEST(responses_tool_followup_uses_previous_response_id) { QueueHandle_t channel_q; char text[CHANNEL_TX_BUF_SIZE]; const char *tool_call_response = "{" + "\"id\":\"resp_tool_1\"," "\"output\":[" "{\"id\":\"rs_1\",\"type\":\"reasoning\",\"summary\":[]}," "{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\"," @@ -273,6 +274,7 @@ TEST(responses_tool_followup_preserves_all_output_items) "}"; const char *final_response = "{" + "\"id\":\"resp_final_1\"," "\"output\":[" "{\"type\":\"message\",\"role\":\"assistant\"," "\"content\":[{\"type\":\"output_text\",\"text\":\"done\"}]}" @@ -301,10 +303,10 @@ TEST(responses_tool_followup_preserves_all_output_items) last_request = mock_llm_last_request_json(); ASSERT(last_request != NULL); - ASSERT(strstr(last_request, "\"type\":\"reasoning\"") != NULL); - ASSERT(strstr(last_request, "\"type\":\"message\",\"role\":\"assistant\"") != NULL); - ASSERT(strstr(last_request, "\"type\":\"function_call\",\"call_id\":\"call_resp_1\"") != NULL); + ASSERT(strstr(last_request, "\"previous_response_id\":\"resp_tool_1\"") != NULL); ASSERT(strstr(last_request, "\"type\":\"function_call_output\",\"call_id\":\"call_resp_1\",\"output\":\"mock tool executed\"") != NULL); + ASSERT(strstr(last_request, "\"type\":\"reasoning\"") == NULL); + ASSERT(strstr(last_request, "\"type\":\"function_call\",\"call_id\":\"call_resp_1\"") == NULL); vQueueDelete(channel_q); return 0; @@ -1016,8 +1018,8 @@ int test_agent_all(void) failures++; } - printf(" responses_tool_followup_preserves_all_output_items... "); - if (test_responses_tool_followup_preserves_all_output_items() == 0) { + printf(" responses_tool_followup_uses_previous_response_id... "); + if (test_responses_tool_followup_uses_previous_response_id() == 0) { printf("OK\n"); } else { failures++; diff --git a/test/host/test_json_util_integration.c b/test/host/test_json_util_integration.c index 283de87..83eb7c2 100644 --- a/test/host/test_json_util_integration.c +++ b/test/host/test_json_util_integration.c @@ -47,7 +47,7 @@ TEST(build_anthropic_request) mock_llm_set_backend(LLM_BACKEND_ANTHROPIC, "claude-test-model"); char *request = json_build_request("sys prompt", NULL, 0, "hello", - s_test_tools, 1); + s_test_tools, 1, NULL); ASSERT(request != NULL); cJSON *root = cJSON_Parse(request); @@ -83,7 +83,7 @@ TEST(build_openai_request) mock_llm_set_backend(LLM_BACKEND_OPENAI, "gpt-5.4"); char *request = json_build_request("sys prompt", NULL, 0, "hello", - s_test_tools, 1); + s_test_tools, 1, NULL); ASSERT(request != NULL); cJSON *root = cJSON_Parse(request); @@ -130,7 +130,7 @@ TEST(build_openrouter_request) mock_llm_set_backend(LLM_BACKEND_OPENROUTER, "openrouter-test-model"); char *request = json_build_request("sys prompt", NULL, 0, "hello", - s_test_tools, 1); + s_test_tools, 1, NULL); ASSERT(request != NULL); cJSON *root = cJSON_Parse(request); @@ -151,7 +151,7 @@ TEST(build_azure_openai_request) mock_llm_set_backend(LLM_BACKEND_AZURE_OPENAI, "azure/gpt-5-mini"); char *request = json_build_request("sys prompt", NULL, 0, "hello", - s_test_tools, 1); + s_test_tools, 1, NULL); ASSERT(request != NULL); cJSON *root = cJSON_Parse(request); @@ -200,6 +200,40 @@ TEST(build_azure_openai_request) return 0; } +TEST(build_azure_openai_request_with_previous_response_id) +{ + mock_llm_set_backend(LLM_BACKEND_AZURE_OPENAI, "azure/gpt-5-mini"); + + conversation_msg_t history[1] = {0}; + strncpy(history[0].role, "user", sizeof(history[0].role) - 1); + strncpy(history[0].content, "hello again", sizeof(history[0].content) - 1); + + char *request = json_build_request("sys prompt", history, 1, NULL, + s_test_tools, 1, "resp_prev_123"); + ASSERT(request != NULL); + + cJSON *root = cJSON_Parse(request); + ASSERT(root != NULL); + + cJSON *previous_response_id = cJSON_GetObjectItem(root, "previous_response_id"); + ASSERT(previous_response_id != NULL && cJSON_IsString(previous_response_id)); + ASSERT_STR_EQ(previous_response_id->valuestring, "resp_prev_123"); + + cJSON *input = cJSON_GetObjectItem(root, "input"); + ASSERT(input != NULL && cJSON_IsArray(input)); + ASSERT(cJSON_GetArraySize(input) == 1); + + cJSON *first = cJSON_GetArrayItem(input, 0); + ASSERT(first != NULL); + cJSON *role = cJSON_GetObjectItem(first, "role"); + ASSERT(role != NULL && cJSON_IsString(role)); + ASSERT_STR_EQ(role->valuestring, "user"); + + cJSON_Delete(root); + free(request); + return 0; +} + TEST(build_openai_request_skips_orphan_tool_result) { mock_llm_set_backend(LLM_BACKEND_OPENAI, "gpt-test-model"); @@ -214,7 +248,7 @@ TEST(build_openai_request_skips_orphan_tool_result) strncpy(history[1].role, "user", sizeof(history[1].role) - 1); strncpy(history[1].content, "remember my name is Ted", sizeof(history[1].content) - 1); - char *request = json_build_request("sys prompt", history, 2, NULL, s_test_tools, 1); + char *request = json_build_request("sys prompt", history, 2, NULL, s_test_tools, 1, NULL); ASSERT(request != NULL); cJSON *root = cJSON_Parse(request); @@ -437,6 +471,13 @@ int test_json_util_integration_all(void) failures++; } + printf(" build_azure_openai_request_with_previous_response_id... "); + if (test_build_azure_openai_request_with_previous_response_id() == 0) { + printf("OK\n"); + } else { + failures++; + } + printf(" build_openai_request_skips_orphan_tool_result... "); if (test_build_openai_request_skips_orphan_tool_result() == 0) { printf("OK\n");