diff --git a/README.md b/README.md index 0c35c3b..15832ce 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ MimiClaw turns a tiny ESP32-S3 board into a personal AI assistant. Plug it into ![](assets/mimiclaw.png) -You send a message on Telegram. The ESP32-S3 picks it up over WiFi, feeds it into an agent loop — the LLM thinks, calls tools, reads memory — and sends the reply back. Supports both **Anthropic (Claude)** and **OpenAI (GPT)** as providers, switchable at runtime. Everything runs on a single $5 chip with all your data stored locally on flash. +You send a message on Telegram. The ESP32-S3 picks it up over WiFi, feeds it into an agent loop — the LLM thinks, calls tools, reads memory — and sends the reply back. Supports **Anthropic (Claude)**, **OpenAI (GPT)**, **SiliconFlow** (Chinese LLM providers), and **Volcengine** (ByteDance Doubao models) as providers, switchable at runtime. Everything runs on a single $5 chip with all your data stored locally on flash. ## Quick Start @@ -40,7 +40,7 @@ You send a message on Telegram. The ESP32-S3 picks it up over WiFi, feeds it int - An **ESP32-S3 dev board** with 16 MB flash and 8 MB PSRAM (e.g. Xiaozhi AI board, ~$10) - A **USB Type-C cable** - A **Telegram bot token** — talk to [@BotFather](https://t.me/BotFather) on Telegram to create one -- An **Anthropic API key** — from [console.anthropic.com](https://console.anthropic.com), or an **OpenAI API key** — from [platform.openai.com](https://platform.openai.com) +- An **Anthropic API key** — from [console.anthropic.com](https://console.anthropic.com), or an **OpenAI API key** — from [platform.openai.com](https://platform.openai.com), or a **SiliconFlow API key** — from [siliconflow.cn](https://siliconflow.cn), or a **Volcengine API key** — from [volcengine.com](https://volcengine.com) ### Install @@ -128,11 +128,19 @@ Edit `main/mimi_secrets.h`: #define MIMI_SECRET_WIFI_PASS "YourWiFiPassword" #define MIMI_SECRET_TG_TOKEN "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" #define MIMI_SECRET_API_KEY "sk-ant-api03-xxxxx" -#define MIMI_SECRET_MODEL_PROVIDER "anthropic" // "anthropic" or "openai" +#define MIMI_SECRET_MODEL_PROVIDER "anthropic" // "anthropic", "openai", "siliconflow", or "volcengine" #define MIMI_SECRET_SEARCH_KEY "" // optional: Brave Search API key #define MIMI_SECRET_TAVILY_KEY "" // optional: Tavily API key (preferred) #define MIMI_SECRET_PROXY_HOST "" // optional: e.g. "10.0.0.1" #define MIMI_SECRET_PROXY_PORT "" // optional: e.g. "7897" + +/* Optional: SiliconFlow API (OpenAI-compatible) */ +#define MIMI_SECRET_SILICONFLOW_API_KEY "" +#define MIMI_SECRET_SILICONFLOW_BASE_URL "https://api.siliconflow.cn/v1" + +/* Optional: Volcengine API (OpenAI-compatible, ByteDance Doubao models) */ +#define MIMI_SECRET_VOLCENGINE_API_KEY "" +#define MIMI_SECRET_VOLCENGINE_BASE_URL "https://ark.cn-beijing.volces.com/api/v3" ``` Then build and flash: @@ -169,12 +177,16 @@ Connect via serial to configure or debug. **Config commands** let you change set mimi> wifi_set MySSID MyPassword # change WiFi network mimi> set_tg_token 123456:ABC... # change Telegram bot token mimi> set_api_key sk-ant-api03-... # change API key (Anthropic or OpenAI) -mimi> set_model_provider openai # switch provider (anthropic|openai) +mimi> set_model_provider openai # switch provider (anthropic|openai|siliconflow|volcengine) mimi> set_model gpt-4o # change LLM model mimi> set_proxy 127.0.0.1 7897 # set HTTP proxy mimi> clear_proxy # remove proxy mimi> set_search_key BSA... # set Brave Search API key mimi> set_tavily_key tvly-... # set Tavily API key (preferred) +mimi> set_siliconflow_key sk-... # set SiliconFlow API key +mimi> set_siliconflow_url https://api.siliconflow.cn/v1 # set SiliconFlow Base URL +mimi> set_volcengine_key sk-... # set Volcengine API key +mimi> set_volcengine_url https://ark.cn-beijing.volces.com/api/v3 # set Volcengine Base URL mimi> config_show # show all config (masked) mimi> config_reset # clear NVS, revert to build-time defaults ``` @@ -250,7 +262,7 @@ MimiClaw stores everything as plain text files you can read and edit: ## Tools -MimiClaw supports tool calling for both Anthropic and OpenAI — the LLM can call tools during a conversation and loop until the task is done (ReAct pattern). +MimiClaw supports tool calling for all LLM providers — the LLM can call tools during a conversation and loop until the task is done (ReAct pattern). Supported providers include Anthropic (Claude), OpenAI (GPT), SiliconFlow, and Volcengine. | Tool | Description | |------|-------------| @@ -280,7 +292,7 @@ This turns MimiClaw into a proactive assistant — write tasks to `HEARTBEAT.md` - **OTA updates** — flash new firmware over WiFi, no USB needed - **Dual-core** — network I/O and AI processing run on separate CPU cores - **HTTP proxy** — CONNECT tunnel support for restricted networks -- **Multi-provider** — supports both Anthropic (Claude) and OpenAI (GPT), switchable at runtime +- **Multi-provider** — supports Anthropic (Claude), OpenAI (GPT), SiliconFlow, and Volcengine, switchable at runtime - **Cron scheduler** — the AI can schedule its own recurring and one-shot tasks, persisted across reboots - **Heartbeat** — periodically checks a task file and prompts the AI to act autonomously - **Tool use** — ReAct agent loop with tool calling for both providers diff --git a/main/cli/serial_cli.c b/main/cli/serial_cli.c index a77fe28..b86ae03 100644 --- a/main/cli/serial_cli.c +++ b/main/cli/serial_cli.c @@ -171,6 +171,80 @@ static int cmd_set_model_provider(int argc, char **argv) return 0; } +/* --- set_siliconflow_key command --- */ +static struct { + struct arg_str *key; + struct arg_end *end; +} siliconflow_key_args; + +static int cmd_set_siliconflow_key(int argc, char **argv) +{ + int nerrors = arg_parse(argc, argv, (void **)&siliconflow_key_args); + if (nerrors != 0) { + arg_print_errors(stderr, siliconflow_key_args.end, argv[0]); + return 1; + } + llm_set_api_key(siliconflow_key_args.key->sval[0]); + llm_provider_set_api_key("siliconflow", siliconflow_key_args.key->sval[0]); + printf("SiliconFlow API key saved.\n"); + return 0; +} + +/* --- set_siliconflow_url command --- */ +static struct { + struct arg_str *url; + struct arg_end *end; +} siliconflow_url_args; + +static int cmd_set_siliconflow_url(int argc, char **argv) +{ + int nerrors = arg_parse(argc, argv, (void **)&siliconflow_url_args); + if (nerrors != 0) { + arg_print_errors(stderr, siliconflow_url_args.end, argv[0]); + return 1; + } + llm_set_base_url("siliconflow", siliconflow_url_args.url->sval[0]); + printf("SiliconFlow Base URL saved.\n"); + return 0; +} + +/* --- set_volcengine_key command --- */ +static struct { + struct arg_str *key; + struct arg_end *end; +} volcengine_key_args; + +static int cmd_set_volcengine_key(int argc, char **argv) +{ + int nerrors = arg_parse(argc, argv, (void **)&volcengine_key_args); + if (nerrors != 0) { + arg_print_errors(stderr, volcengine_key_args.end, argv[0]); + return 1; + } + llm_set_api_key(volcengine_key_args.key->sval[0]); + llm_provider_set_api_key("volcengine", volcengine_key_args.key->sval[0]); + printf("Volcengine API key saved.\n"); + return 0; +} + +/* --- set_volcengine_url command --- */ +static struct { + struct arg_str *url; + struct arg_end *end; +} volcengine_url_args; + +static int cmd_set_volcengine_url(int argc, char **argv) +{ + int nerrors = arg_parse(argc, argv, (void **)&volcengine_url_args); + if (nerrors != 0) { + arg_print_errors(stderr, volcengine_url_args.end, argv[0]); + return 1; + } + llm_set_base_url("volcengine", volcengine_url_args.url->sval[0]); + printf("Volcengine Base URL saved.\n"); + return 0; +} + /* --- memory_read command --- */ static int cmd_memory_read(int argc, char **argv) { @@ -563,6 +637,18 @@ static int cmd_config_show(int argc, char **argv) print_config("API Key", MIMI_NVS_LLM, MIMI_NVS_KEY_API_KEY, MIMI_SECRET_API_KEY, true); print_config("Model", MIMI_NVS_LLM, MIMI_NVS_KEY_MODEL, MIMI_SECRET_MODEL, false); print_config("Provider", MIMI_NVS_LLM, MIMI_NVS_KEY_PROVIDER, MIMI_SECRET_MODEL_PROVIDER, false); + + /* Provider-specific configurations */ + printf(" --- Provider-specific configs ---\n"); + print_config("Anthropic Key", MIMI_NVS_LLM, MIMI_NVS_KEY_ANTHROPIC_API_KEY, "", true); + print_config("Anthropic URL", MIMI_NVS_LLM, MIMI_NVS_KEY_ANTHROPIC_BASE_URL, "", false); + print_config("OpenAI Key", MIMI_NVS_LLM, MIMI_NVS_KEY_OPENAI_API_KEY, "", true); + print_config("OpenAI URL", MIMI_NVS_LLM, MIMI_NVS_KEY_OPENAI_BASE_URL, "", false); + print_config("SiliconFlow Key", MIMI_NVS_LLM, MIMI_NVS_KEY_SILICONFLOW_API_KEY, "", true); + print_config("SiliconFlow URL", MIMI_NVS_LLM, MIMI_NVS_KEY_SILICONFLOW_BASE_URL, "", false); + print_config("Volcengine Key", MIMI_NVS_LLM, MIMI_NVS_KEY_VOLCENGINE_API_KEY, "", true); + print_config("Volcengine URL", MIMI_NVS_LLM, MIMI_NVS_KEY_VOLCENGINE_BASE_URL, "", false); + print_config("Proxy Host", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_HOST, MIMI_SECRET_PROXY_HOST, false); print_config_u16("Proxy Port", MIMI_NVS_PROXY, MIMI_NVS_KEY_PROXY_PORT, MIMI_SECRET_PROXY_PORT); print_config("Search Key", MIMI_NVS_SEARCH, MIMI_NVS_KEY_API_KEY, MIMI_SECRET_SEARCH_KEY, true); @@ -887,7 +973,7 @@ esp_err_t serial_cli_init(void) esp_console_cmd_register(&model_cmd); /* set_model_provider */ - provider_args.provider = arg_str1(NULL, NULL, "", "Model provider (anthropic|openai)"); + provider_args.provider = arg_str1(NULL, NULL, "", "Model provider (anthropic|openai|siliconflow|volcengine)"); provider_args.end = arg_end(1); esp_console_cmd_t provider_cmd = { .command = "set_model_provider", @@ -897,6 +983,50 @@ esp_err_t serial_cli_init(void) }; esp_console_cmd_register(&provider_cmd); + /* set_siliconflow_key */ + siliconflow_key_args.key = arg_str1(NULL, NULL, "", "SiliconFlow API key"); + siliconflow_key_args.end = arg_end(1); + esp_console_cmd_t siliconflow_key_cmd = { + .command = "set_siliconflow_key", + .help = "Set SiliconFlow API key", + .func = &cmd_set_siliconflow_key, + .argtable = &siliconflow_key_args, + }; + esp_console_cmd_register(&siliconflow_key_cmd); + + /* set_siliconflow_url */ + siliconflow_url_args.url = arg_str1(NULL, NULL, "", "SiliconFlow Base URL"); + siliconflow_url_args.end = arg_end(1); + esp_console_cmd_t siliconflow_url_cmd = { + .command = "set_siliconflow_url", + .help = "Set SiliconFlow Base URL", + .func = &cmd_set_siliconflow_url, + .argtable = &siliconflow_url_args, + }; + esp_console_cmd_register(&siliconflow_url_cmd); + + /* set_volcengine_key */ + volcengine_key_args.key = arg_str1(NULL, NULL, "", "Volcengine API key"); + volcengine_key_args.end = arg_end(1); + esp_console_cmd_t volcengine_key_cmd = { + .command = "set_volcengine_key", + .help = "Set Volcengine API key", + .func = &cmd_set_volcengine_key, + .argtable = &volcengine_key_args, + }; + esp_console_cmd_register(&volcengine_key_cmd); + + /* set_volcengine_url */ + volcengine_url_args.url = arg_str1(NULL, NULL, "", "Volcengine Base URL"); + volcengine_url_args.end = arg_end(1); + esp_console_cmd_t volcengine_url_cmd = { + .command = "set_volcengine_url", + .help = "Set Volcengine Base URL", + .func = &cmd_set_volcengine_url, + .argtable = &volcengine_url_args, + }; + esp_console_cmd_register(&volcengine_url_cmd); + /* skill_list */ esp_console_cmd_t skill_list_cmd = { .command = "skill_list", diff --git a/main/llm/llm_provider.c b/main/llm/llm_provider.c new file mode 100644 index 0000000..10d78aa --- /dev/null +++ b/main/llm/llm_provider.c @@ -0,0 +1,298 @@ +#include "llm_provider.h" +#include "mimi_config.h" +#include "nvs.h" + +#include +#include +#include +#include "esp_log.h" +#include "esp_err.h" + +static const char *TAG = "llm_provider"; + +#define LLM_API_KEY_MAX_LEN 320 + +/* Provider registry - all supported providers */ +const llm_provider_config_t llm_providers[] = { + { + .name = "anthropic", + .default_api_url = MIMI_LLM_API_URL, + .default_host = "api.anthropic.com", + .default_path = "/v1/messages", + .is_openai_compatible = false, + }, + { + .name = "openai", + .default_api_url = MIMI_OPENAI_API_URL, + .default_host = "api.openai.com", + .default_path = "/v1/chat/completions", + .is_openai_compatible = true, + }, + { + .name = "siliconflow", + .default_api_url = MIMI_SILICONFLOW_API_URL, + .default_host = "api.siliconflow.cn", + .default_path = "/v1/chat/completions", + .is_openai_compatible = true, + }, + { + .name = "volcengine", + .default_api_url = MIMI_VOLCENGINE_API_URL, + .default_host = "ark.cn-beijing.volces.com", + .default_path = "/v1/chat/completions", + .is_openai_compatible = true, + }, +}; + +const int llm_provider_count = sizeof(llm_providers) / sizeof(llm_providers[0]); + +/* Current provider state */ +static const llm_provider_config_t *s_current_provider = &llm_providers[0]; /* Default to anthropic */ +static char s_api_key[LLM_API_KEY_MAX_LEN] = {0}; +static char s_base_url[256] = {0}; + +/* Helper function to get NVS key for provider API key */ +static const char *get_provider_api_key_nvs_key(const char *provider_name) { + if (strcmp(provider_name, "anthropic") == 0) return MIMI_NVS_KEY_ANTHROPIC_API_KEY; + if (strcmp(provider_name, "openai") == 0) return MIMI_NVS_KEY_OPENAI_API_KEY; + if (strcmp(provider_name, "siliconflow") == 0) return MIMI_NVS_KEY_SILICONFLOW_API_KEY; + if (strcmp(provider_name, "volcengine") == 0) return MIMI_NVS_KEY_VOLCENGINE_API_KEY; + return NULL; +} + +/* Helper function to get NVS key for provider Base URL */ +static const char *get_provider_base_url_nvs_key(const char *provider_name) { + if (strcmp(provider_name, "anthropic") == 0) return MIMI_NVS_KEY_ANTHROPIC_BASE_URL; + if (strcmp(provider_name, "openai") == 0) return MIMI_NVS_KEY_OPENAI_BASE_URL; + if (strcmp(provider_name, "siliconflow") == 0) return MIMI_NVS_KEY_SILICONFLOW_BASE_URL; + if (strcmp(provider_name, "volcengine") == 0) return MIMI_NVS_KEY_VOLCENGINE_BASE_URL; + return NULL; +} + +/* Find provider configuration by name */ +const llm_provider_config_t *llm_provider_find(const char *name) { + if (!name) return NULL; + + for (int i = 0; i < llm_provider_count; i++) { + if (strcmp(llm_providers[i].name, name) == 0) { + return &llm_providers[i]; + } + } + return NULL; +} + +/* Get current provider configuration */ +const llm_provider_config_t *llm_provider_current(void) { + return s_current_provider; +} + +/* Set current provider by name */ +void llm_provider_set_current(const char *name) { + const llm_provider_config_t *provider = llm_provider_find(name); + if (provider) { + s_current_provider = provider; + /* Load provider-specific configuration */ + llm_provider_init(); + ESP_LOGI(TAG, "Current provider set to: %s", name); + } else { + ESP_LOGW(TAG, "Unknown provider: %s", name); + } +} + +/* Get current provider name */ +const char *llm_provider_current_name(void) { + return s_current_provider ? s_current_provider->name : "unknown"; +} + +/* Check if current provider is OpenAI-compatible */ +bool llm_provider_is_openai_compatible(void) { + return s_current_provider ? s_current_provider->is_openai_compatible : false; +} + +/* Get API URL for current provider (with dynamic Base URL support) */ +const char *llm_provider_api_url(void) { + if (s_base_url[0] != '\0') { + /* Use configured Base URL */ + static char full_url[512]; + snprintf(full_url, sizeof(full_url), "%s%s", s_base_url, s_current_provider->default_path); + return full_url; + } + /* Use default API URL */ + return s_current_provider ? s_current_provider->default_api_url : ""; +} + +/* Get hostname for current provider */ +const char *llm_provider_host(void) { + if (s_base_url[0] != '\0') { + /* Extract hostname from Base URL */ + static char hostname[256]; + const char *start = s_base_url; + if (strncmp(start, "https://", 8) == 0) start += 8; + else if (strncmp(start, "http://", 7) == 0) start += 7; + + const char *end = strchr(start, '/'); + if (end) { + size_t len = end - start; + if (len < sizeof(hostname)) { + memcpy(hostname, start, len); + hostname[len] = '\0'; + return hostname; + } + } + /* No path, copy whole string */ + strncpy(hostname, start, sizeof(hostname) - 1); + hostname[sizeof(hostname) - 1] = '\0'; + return hostname; + } + /* Use default host */ + return s_current_provider ? s_current_provider->default_host : ""; +} + +/* Get API path for current provider */ +const char *llm_provider_path(void) { + return s_current_provider ? s_current_provider->default_path : ""; +} + +/* Provider-specific API key management */ +void llm_provider_set_api_key(const char *provider_name, const char *api_key) { + if (!provider_name || !api_key) return; + + const char *nvs_key = get_provider_api_key_nvs_key(provider_name); + if (!nvs_key) { + ESP_LOGW(TAG, "No NVS key for provider: %s", provider_name); + return; + } + + nvs_handle_t nvs; + if (nvs_open(MIMI_NVS_LLM, NVS_READWRITE, &nvs) == ESP_OK) { + nvs_set_str(nvs, nvs_key, api_key); + nvs_commit(nvs); + nvs_close(nvs); + ESP_LOGI(TAG, "API key saved for provider: %s", provider_name); + } + + /* If this is the current provider, update in-memory key */ + if (strcmp(provider_name, s_current_provider->name) == 0) { + strncpy(s_api_key, api_key, sizeof(s_api_key) - 1); + s_api_key[sizeof(s_api_key) - 1] = '\0'; + } +} + +/* Provider-specific Base URL management */ +void llm_provider_set_base_url(const char *provider_name, const char *base_url) { + if (!provider_name || !base_url) return; + + const char *nvs_key = get_provider_base_url_nvs_key(provider_name); + if (!nvs_key) { + ESP_LOGW(TAG, "No NVS key for provider: %s", provider_name); + return; + } + + nvs_handle_t nvs; + if (nvs_open(MIMI_NVS_LLM, NVS_READWRITE, &nvs) == ESP_OK) { + nvs_set_str(nvs, nvs_key, base_url); + nvs_commit(nvs); + nvs_close(nvs); + ESP_LOGI(TAG, "Base URL saved for provider: %s", provider_name); + } + + /* If this is the current provider, update in-memory Base URL */ + if (strcmp(provider_name, s_current_provider->name) == 0) { + strncpy(s_base_url, base_url, sizeof(s_base_url) - 1); + s_base_url[sizeof(s_base_url) - 1] = '\0'; + } +} + +/* Get API key for a provider */ +const char *llm_provider_get_api_key(const char *provider_name) { + if (!provider_name) return NULL; + + /* If this is the current provider, return in-memory key */ + if (strcmp(provider_name, s_current_provider->name) == 0) { + return s_api_key; + } + + /* Otherwise, load from NVS */ + static char key_buffer[LLM_API_KEY_MAX_LEN] = {0}; + const char *nvs_key = get_provider_api_key_nvs_key(provider_name); + if (!nvs_key) return NULL; + + nvs_handle_t nvs; + if (nvs_open(MIMI_NVS_LLM, NVS_READONLY, &nvs) == ESP_OK) { + size_t len = sizeof(key_buffer); + if (nvs_get_str(nvs, nvs_key, key_buffer, &len) == ESP_OK && key_buffer[0]) { + nvs_close(nvs); + return key_buffer; + } + nvs_close(nvs); + } + return NULL; +} + +/* Get Base URL for a provider */ +const char *llm_provider_get_base_url(const char *provider_name) { + if (!provider_name) return NULL; + + /* If this is the current provider, return in-memory Base URL */ + if (strcmp(provider_name, s_current_provider->name) == 0) { + return s_base_url[0] ? s_base_url : NULL; + } + + /* Otherwise, load from NVS */ + static char url_buffer[256] = {0}; + const char *nvs_key = get_provider_base_url_nvs_key(provider_name); + if (!nvs_key) return NULL; + + nvs_handle_t nvs; + if (nvs_open(MIMI_NVS_LLM, NVS_READONLY, &nvs) == ESP_OK) { + size_t len = sizeof(url_buffer); + if (nvs_get_str(nvs, nvs_key, url_buffer, &len) == ESP_OK && url_buffer[0]) { + nvs_close(nvs); + return url_buffer; + } + nvs_close(nvs); + } + return NULL; +} + +/* Initialize provider system (load from NVS) */ +void llm_provider_init(void) { + /* Load API key for current provider */ + const char *api_key = llm_provider_get_api_key(s_current_provider->name); + if (api_key) { + strncpy(s_api_key, api_key, sizeof(s_api_key) - 1); + s_api_key[sizeof(s_api_key) - 1] = '\0'; + } else { + s_api_key[0] = '\0'; + } + + /* Load Base URL for current provider */ + const char *base_url = llm_provider_get_base_url(s_current_provider->name); + if (base_url) { + strncpy(s_base_url, base_url, sizeof(s_base_url) - 1); + s_base_url[sizeof(s_base_url) - 1] = '\0'; + } else { + s_base_url[0] = '\0'; + } + + ESP_LOGI(TAG, "Provider initialized: %s (API key: %s, Base URL: %s)", + s_current_provider->name, + s_api_key[0] ? "set" : "not set", + s_base_url[0] ? s_base_url : "default"); +} + +/* Save provider configuration to NVS */ +void llm_provider_save_config(const char *provider_name) { + /* This function is intentionally left empty - + individual set functions already save to NVS */ +} + +/* Common authentication header setup for OpenAI-compatible providers */ +void llm_provider_set_auth_headers(esp_http_client_handle_t client, const char *api_key) { + if (!client || !api_key) return; + + /* All OpenAI-compatible providers use Bearer token authentication */ + char auth[LLM_API_KEY_MAX_LEN + 16]; + snprintf(auth, sizeof(auth), "Bearer %s", api_key); + esp_http_client_set_header(client, "Authorization", auth); +} \ No newline at end of file diff --git a/main/llm/llm_provider.h b/main/llm/llm_provider.h new file mode 100644 index 0000000..713167a --- /dev/null +++ b/main/llm/llm_provider.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* Forward declaration for esp_http_client_handle_t */ +typedef struct esp_http_client* esp_http_client_handle_t; + +/* Provider configuration structure */ +typedef struct { + const char *name; /* Provider name, e.g., "anthropic", "openai", "siliconflow", "volcengine" */ + const char *default_api_url; /* Default API URL */ + const char *default_host; /* Default hostname */ + const char *default_path; /* Default API path */ + bool is_openai_compatible; /* Whether this provider uses OpenAI-compatible API */ +} llm_provider_config_t; + +/* Provider registry - all supported providers */ +extern const llm_provider_config_t llm_providers[]; +extern const int llm_provider_count; + +/* Find provider configuration by name */ +const llm_provider_config_t *llm_provider_find(const char *name); + +/* Get current provider configuration */ +const llm_provider_config_t *llm_provider_current(void); + +/* Set current provider by name */ +void llm_provider_set_current(const char *name); + +/* Get current provider name */ +const char *llm_provider_current_name(void); + +/* Check if current provider is OpenAI-compatible */ +bool llm_provider_is_openai_compatible(void); + +/* Get API URL for current provider (with dynamic Base URL support) */ +const char *llm_provider_api_url(void); + +/* Get hostname for current provider */ +const char *llm_provider_host(void); + +/* Get API path for current provider */ +const char *llm_provider_path(void); + +/* Provider-specific API key and Base URL management */ +void llm_provider_set_api_key(const char *provider_name, const char *api_key); +void llm_provider_set_base_url(const char *provider_name, const char *base_url); +const char *llm_provider_get_api_key(const char *provider_name); +const char *llm_provider_get_base_url(const char *provider_name); + +/* Initialize provider system (load from NVS) */ +void llm_provider_init(void); + +/* Save provider configuration to NVS */ +void llm_provider_save_config(const char *provider_name); + +/* Common authentication header setup for OpenAI-compatible providers */ +void llm_provider_set_auth_headers(esp_http_client_handle_t client, const char *api_key); + +#ifdef __cplusplus +} +#endif \ No newline at end of file diff --git a/main/llm/llm_proxy.c b/main/llm/llm_proxy.c index c6fa1b8..09f73cc 100644 --- a/main/llm/llm_proxy.c +++ b/main/llm/llm_proxy.c @@ -1,4 +1,5 @@ #include "llm_proxy.h" +#include "llm_provider.h" #include "mimi_config.h" #include "proxy/http_proxy.h" @@ -184,28 +185,31 @@ static esp_err_t http_event_handler(esp_http_client_event_t *evt) static bool provider_is_openai(void) { - return strcmp(s_provider, "openai") == 0; + return llm_provider_is_openai_compatible(); } static const char *llm_api_url(void) { - return provider_is_openai() ? MIMI_OPENAI_API_URL : MIMI_LLM_API_URL; + return llm_provider_api_url(); } static const char *llm_api_host(void) { - return provider_is_openai() ? "api.openai.com" : "api.anthropic.com"; + return llm_provider_host(); } static const char *llm_api_path(void) { - return provider_is_openai() ? "/v1/chat/completions" : "/v1/messages"; + return llm_provider_path(); } /* ── Init ─────────────────────────────────────────────────────── */ esp_err_t llm_proxy_init(void) { + /* Initialize provider system */ + llm_provider_init(); + /* Start with build-time defaults */ if (MIMI_SECRET_API_KEY[0] != '\0') { safe_copy(s_api_key, sizeof(s_api_key), MIMI_SECRET_API_KEY); @@ -215,6 +219,8 @@ esp_err_t llm_proxy_init(void) } if (MIMI_SECRET_MODEL_PROVIDER[0] != '\0') { safe_copy(s_provider, sizeof(s_provider), MIMI_SECRET_MODEL_PROVIDER); + /* Set current provider based on build-time default */ + llm_provider_set_current(s_provider); } /* NVS overrides take highest priority (set via CLI) */ @@ -234,10 +240,18 @@ esp_err_t llm_proxy_init(void) len = sizeof(provider_tmp); if (nvs_get_str(nvs, MIMI_NVS_KEY_PROVIDER, provider_tmp, &len) == ESP_OK && provider_tmp[0]) { safe_copy(s_provider, sizeof(s_provider), provider_tmp); + /* Set current provider based on NVS override */ + llm_provider_set_current(s_provider); } nvs_close(nvs); } + /* Load provider-specific API key if available */ + const char *provider_api_key = llm_provider_get_api_key(s_provider); + if (provider_api_key && provider_api_key[0]) { + safe_copy(s_api_key, sizeof(s_api_key), provider_api_key); + } + if (s_api_key[0]) { ESP_LOGI(TAG, "LLM proxy initialized (provider: %s, model: %s)", s_provider, s_model); } else { @@ -265,13 +279,15 @@ static esp_err_t llm_http_direct(const char *post_data, resp_buf_t *rb, int *out esp_http_client_set_method(client, HTTP_METHOD_POST); esp_http_client_set_header(client, "Content-Type", "application/json"); - if (provider_is_openai()) { + + /* Use provider-specific authentication */ + if (llm_provider_is_openai_compatible()) { + /* OpenAI-compatible providers use Bearer token authentication */ if (s_api_key[0]) { - char auth[LLM_API_KEY_MAX_LEN + 16]; - snprintf(auth, sizeof(auth), "Bearer %s", s_api_key); - esp_http_client_set_header(client, "Authorization", auth); + llm_provider_set_auth_headers(client, s_api_key); } } else { + /* Anthropic uses x-api-key authentication */ esp_http_client_set_header(client, "x-api-key", s_api_key); esp_http_client_set_header(client, "anthropic-version", MIMI_LLM_API_VERSION); } @@ -293,7 +309,9 @@ static esp_err_t llm_http_via_proxy(const char *post_data, resp_buf_t *rb, int * int body_len = strlen(post_data); char header[1024]; int hlen = 0; - if (provider_is_openai()) { + + if (llm_provider_is_openai_compatible()) { + /* OpenAI-compatible providers use Bearer token authentication */ hlen = snprintf(header, sizeof(header), "POST %s HTTP/1.1\r\n" "Host: %s\r\n" @@ -303,6 +321,7 @@ static esp_err_t llm_http_via_proxy(const char *post_data, resp_buf_t *rb, int * "Connection: close\r\n\r\n", llm_api_path(), llm_api_host(), s_api_key, body_len); } else { + /* Anthropic uses x-api-key authentication */ hlen = snprintf(header, sizeof(header), "POST %s HTTP/1.1\r\n" "Host: %s\r\n" @@ -559,13 +578,13 @@ esp_err_t llm_chat_tools(const char *system_prompt, /* Build request body (non-streaming) */ cJSON *body = cJSON_CreateObject(); cJSON_AddStringToObject(body, "model", s_model); - if (provider_is_openai()) { + if (llm_provider_is_openai_compatible()) { cJSON_AddNumberToObject(body, "max_completion_tokens", MIMI_LLM_MAX_TOKENS); } else { cJSON_AddNumberToObject(body, "max_tokens", MIMI_LLM_MAX_TOKENS); } - if (provider_is_openai()) { + if (llm_provider_is_openai_compatible()) { cJSON *openai_msgs = convert_messages_openai(system_prompt, messages); cJSON_AddItemToObject(body, "messages", openai_msgs); @@ -635,7 +654,7 @@ esp_err_t llm_chat_tools(const char *system_prompt, return ESP_FAIL; } - if (provider_is_openai()) { + if (llm_provider_is_openai_compatible()) { cJSON *choices = cJSON_GetObjectItem(root, "choices"); cJSON *choice0 = choices && cJSON_IsArray(choices) ? cJSON_GetArrayItem(choices, 0) : NULL; if (choice0) { @@ -780,7 +799,11 @@ esp_err_t llm_set_api_key(const char *api_key) nvs_close(nvs); safe_copy(s_api_key, sizeof(s_api_key), api_key); - ESP_LOGI(TAG, "API key saved"); + + /* Also save to provider-specific NVS key */ + llm_provider_set_api_key(s_provider, api_key); + + ESP_LOGI(TAG, "API key saved for provider: %s", s_provider); return ESP_OK; } @@ -806,6 +829,43 @@ esp_err_t llm_set_provider(const char *provider) nvs_close(nvs); safe_copy(s_provider, sizeof(s_provider), provider); + + /* Update current provider in the provider system */ + llm_provider_set_current(provider); + + /* Load provider-specific API key if available */ + const char *provider_api_key = llm_provider_get_api_key(provider); + if (provider_api_key && provider_api_key[0]) { + safe_copy(s_api_key, sizeof(s_api_key), provider_api_key); + } else { + s_api_key[0] = '\0'; + } + ESP_LOGI(TAG, "Provider set to: %s", s_provider); return ESP_OK; } + +esp_err_t llm_set_base_url(const char *provider, const char *base_url) +{ + if (!provider || !base_url) return ESP_ERR_INVALID_ARG; + + /* Save to provider-specific NVS key */ + llm_provider_set_base_url(provider, base_url); + + /* If this is the current provider, update in-memory Base URL */ + if (strcmp(provider, s_provider) == 0) { + /* Reload provider configuration to pick up new Base URL */ + llm_provider_init(); + } + + ESP_LOGI(TAG, "Base URL set for provider: %s", provider); + return ESP_OK; +} + +const char *llm_get_base_url(const char *provider) +{ + if (!provider) return NULL; + + /* Get Base URL from provider system */ + return llm_provider_get_base_url(provider); +} diff --git a/main/llm/llm_proxy.h b/main/llm/llm_proxy.h index b667f62..516bbbf 100644 --- a/main/llm/llm_proxy.h +++ b/main/llm/llm_proxy.h @@ -27,6 +27,16 @@ esp_err_t llm_set_provider(const char *provider); */ esp_err_t llm_set_model(const char *model); +/** + * Save the Base URL for a provider to NVS. + */ +esp_err_t llm_set_base_url(const char *provider, const char *base_url); + +/** + * Get the Base URL for a provider from NVS. + */ +const char *llm_get_base_url(const char *provider); + /* ── Tool Use Support ──────────────────────────────────────────── */ typedef struct { diff --git a/main/mimi_config.h b/main/mimi_config.h index f205c54..55eb7d0 100644 --- a/main/mimi_config.h +++ b/main/mimi_config.h @@ -88,6 +88,8 @@ #define MIMI_LLM_MAX_TOKENS 4096 #define MIMI_LLM_API_URL "https://api.anthropic.com/v1/messages" #define MIMI_OPENAI_API_URL "https://api.openai.com/v1/chat/completions" +#define MIMI_SILICONFLOW_API_URL "https://api.siliconflow.cn/v1/chat/completions" +#define MIMI_VOLCENGINE_API_URL "https://ark.cn-beijing.volces.com/api/v3/chat/completions" #define MIMI_LLM_API_VERSION "2023-06-01" #define MIMI_LLM_STREAM_BUF_SIZE (32 * 1024) #define MIMI_LLM_LOG_VERBOSE_PAYLOAD 0 @@ -154,6 +156,16 @@ #define MIMI_NVS_KEY_PROXY_PORT "port" #define MIMI_NVS_KEY_PROXY_TYPE "proxy_type" +/* Provider-specific NVS Keys */ +#define MIMI_NVS_KEY_ANTHROPIC_API_KEY "anthropic_api_key" +#define MIMI_NVS_KEY_ANTHROPIC_BASE_URL "anthropic_base_url" +#define MIMI_NVS_KEY_OPENAI_API_KEY "openai_api_key" +#define MIMI_NVS_KEY_OPENAI_BASE_URL "openai_base_url" +#define MIMI_NVS_KEY_SILICONFLOW_API_KEY "siliconflow_api_key" +#define MIMI_NVS_KEY_SILICONFLOW_BASE_URL "siliconflow_base_url" +#define MIMI_NVS_KEY_VOLCENGINE_API_KEY "volcengine_api_key" +#define MIMI_NVS_KEY_VOLCENGINE_BASE_URL "volcengine_base_url" + /* WiFi Onboarding (Captive Portal) */ #define MIMI_ONBOARD_AP_PREFIX "MimiClaw-" #define MIMI_ONBOARD_AP_PASS "" /* open network */ diff --git a/main/mimi_secrets.h.example b/main/mimi_secrets.h.example index ecebf54..c5050f4 100644 --- a/main/mimi_secrets.h.example +++ b/main/mimi_secrets.h.example @@ -26,6 +26,14 @@ #define MIMI_SECRET_MODEL "" #define MIMI_SECRET_MODEL_PROVIDER "anthropic" +/* SiliconFlow API (OpenAI-compatible) */ +#define MIMI_SECRET_SILICONFLOW_API_KEY "" +#define MIMI_SECRET_SILICONFLOW_BASE_URL "https://api.siliconflow.cn/v1" + +/* Volcengine API (OpenAI-compatible, ByteDance Doubao models) */ +#define MIMI_SECRET_VOLCENGINE_API_KEY "" +#define MIMI_SECRET_VOLCENGINE_BASE_URL "https://ark.cn-beijing.volces.com/api/v3" + /* HTTP Proxy (leave empty or set both) */ #define MIMI_SECRET_PROXY_HOST "" #define MIMI_SECRET_PROXY_PORT ""