881 lines
32 KiB
C
881 lines
32 KiB
C
#include "llm_proxy.h"
|
|
#include "llm_provider.h"
|
|
#include "mimi_config.h"
|
|
#include "proxy/http_proxy.h"
|
|
|
|
#include <string.h>
|
|
#include <stdlib.h>
|
|
#include <stdbool.h>
|
|
#include "esp_log.h"
|
|
#include "esp_http_client.h"
|
|
#include "esp_crt_bundle.h"
|
|
#include "esp_heap_caps.h"
|
|
#include "nvs.h"
|
|
#include "cJSON.h"
|
|
|
|
static const char *TAG = "llm";
|
|
|
|
#define LLM_API_KEY_MAX_LEN 320
|
|
#define LLM_MODEL_MAX_LEN 64
|
|
#define LLM_DUMP_MAX_BYTES (16 * 1024)
|
|
#define LLM_DUMP_CHUNK_BYTES 320
|
|
|
|
static char s_api_key[LLM_API_KEY_MAX_LEN] = {0};
|
|
static char s_model[LLM_MODEL_MAX_LEN] = MIMI_LLM_DEFAULT_MODEL;
|
|
static char s_provider[16] = MIMI_LLM_PROVIDER_DEFAULT;
|
|
|
|
static void llm_log_payload(const char *label, const char *payload)
|
|
{
|
|
if (!payload) {
|
|
ESP_LOGI(TAG, "%s: <null>", label);
|
|
return;
|
|
}
|
|
|
|
size_t total = strlen(payload);
|
|
#if MIMI_LLM_LOG_VERBOSE_PAYLOAD
|
|
size_t shown = total > LLM_DUMP_MAX_BYTES ? LLM_DUMP_MAX_BYTES : total;
|
|
ESP_LOGI(TAG, "%s (%u bytes)%s",
|
|
label,
|
|
(unsigned)total,
|
|
(shown < total) ? " [truncated]" : "");
|
|
|
|
char chunk[LLM_DUMP_CHUNK_BYTES + 1];
|
|
for (size_t off = 0; off < shown; off += LLM_DUMP_CHUNK_BYTES) {
|
|
size_t n = shown - off;
|
|
if (n > LLM_DUMP_CHUNK_BYTES) {
|
|
n = LLM_DUMP_CHUNK_BYTES;
|
|
}
|
|
memcpy(chunk, payload + off, n);
|
|
chunk[n] = '\0';
|
|
ESP_LOGI(TAG, "%s[%u]: %s", label, (unsigned)off, chunk);
|
|
}
|
|
#else
|
|
if (MIMI_LLM_LOG_PREVIEW_BYTES > 0) {
|
|
size_t shown = total > MIMI_LLM_LOG_PREVIEW_BYTES ? MIMI_LLM_LOG_PREVIEW_BYTES : total;
|
|
char preview[MIMI_LLM_LOG_PREVIEW_BYTES + 1];
|
|
memcpy(preview, payload, shown);
|
|
preview[shown] = '\0';
|
|
for (size_t i = 0; i < shown; i++) {
|
|
if (preview[i] == '\n' || preview[i] == '\r' || preview[i] == '\t') {
|
|
preview[i] = ' ';
|
|
}
|
|
}
|
|
ESP_LOGI(TAG, "%s (%u bytes): %s%s",
|
|
label,
|
|
(unsigned)total,
|
|
preview,
|
|
(shown < total) ? " ..." : "");
|
|
} else {
|
|
ESP_LOGI(TAG, "%s (%u bytes)", label, (unsigned)total);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
static void safe_copy(char *dst, size_t dst_size, const char *src)
|
|
{
|
|
if (!dst || dst_size == 0) return;
|
|
if (!src) {
|
|
dst[0] = '\0';
|
|
return;
|
|
}
|
|
size_t n = strnlen(src, dst_size - 1);
|
|
memcpy(dst, src, n);
|
|
dst[n] = '\0';
|
|
}
|
|
|
|
/* ── Response buffer ──────────────────────────────────────────── */
|
|
|
|
typedef struct {
|
|
char *data;
|
|
size_t len;
|
|
size_t cap;
|
|
} resp_buf_t;
|
|
|
|
static esp_err_t resp_buf_init(resp_buf_t *rb, size_t initial_cap)
|
|
{
|
|
rb->data = heap_caps_calloc(1, initial_cap, MALLOC_CAP_SPIRAM);
|
|
if (!rb->data) return ESP_ERR_NO_MEM;
|
|
rb->len = 0;
|
|
rb->cap = initial_cap;
|
|
return ESP_OK;
|
|
}
|
|
|
|
static esp_err_t resp_buf_append(resp_buf_t *rb, const char *data, size_t len)
|
|
{
|
|
while (rb->len + len >= rb->cap) {
|
|
size_t new_cap = rb->cap * 2;
|
|
char *tmp = heap_caps_realloc(rb->data, new_cap, MALLOC_CAP_SPIRAM);
|
|
if (!tmp) return ESP_ERR_NO_MEM;
|
|
rb->data = tmp;
|
|
rb->cap = new_cap;
|
|
}
|
|
memcpy(rb->data + rb->len, data, len);
|
|
rb->len += len;
|
|
rb->data[rb->len] = '\0';
|
|
return ESP_OK;
|
|
}
|
|
|
|
static void resp_buf_free(resp_buf_t *rb)
|
|
{
|
|
free(rb->data);
|
|
rb->data = NULL;
|
|
rb->len = 0;
|
|
rb->cap = 0;
|
|
}
|
|
|
|
/* ── Chunked transfer encoding decoder ───────────────────────── */
|
|
|
|
static void resp_buf_decode_chunked(resp_buf_t *rb)
|
|
{
|
|
if (!rb->data || rb->len == 0) return;
|
|
|
|
/* Quick check: if body starts with '{' or '[', it's not chunked */
|
|
size_t i = 0;
|
|
while (i < rb->len && (rb->data[i] == ' ' || rb->data[i] == '\t')) i++;
|
|
if (i < rb->len && (rb->data[i] == '{' || rb->data[i] == '[')) return;
|
|
|
|
/* Try to decode chunked encoding in-place */
|
|
char *src = rb->data;
|
|
char *dst = rb->data;
|
|
char *end = rb->data + rb->len;
|
|
|
|
while (src < end) {
|
|
/* Parse hex chunk size */
|
|
char *line_end = strstr(src, "\r\n");
|
|
if (!line_end) break;
|
|
|
|
unsigned long chunk_size = strtoul(src, NULL, 16);
|
|
if (chunk_size == 0) break; /* terminal chunk */
|
|
|
|
src = line_end + 2; /* skip past \r\n after size */
|
|
|
|
if (src + chunk_size > end) {
|
|
/* Incomplete chunk, copy what we have */
|
|
size_t avail = end - src;
|
|
memmove(dst, src, avail);
|
|
dst += avail;
|
|
break;
|
|
}
|
|
|
|
memmove(dst, src, chunk_size);
|
|
dst += chunk_size;
|
|
src += chunk_size;
|
|
|
|
/* Skip trailing \r\n after chunk data */
|
|
if (src + 2 <= end && src[0] == '\r' && src[1] == '\n') {
|
|
src += 2;
|
|
}
|
|
}
|
|
|
|
rb->len = dst - rb->data;
|
|
rb->data[rb->len] = '\0';
|
|
}
|
|
|
|
/* ── HTTP event handler (for esp_http_client direct path) ─────── */
|
|
|
|
static esp_err_t http_event_handler(esp_http_client_event_t *evt)
|
|
{
|
|
resp_buf_t *rb = (resp_buf_t *)evt->user_data;
|
|
if (evt->event_id == HTTP_EVENT_ON_DATA) {
|
|
resp_buf_append(rb, (const char *)evt->data, evt->data_len);
|
|
}
|
|
return ESP_OK;
|
|
}
|
|
|
|
/* ── Provider helpers ──────────────────────────────────────────── */
|
|
|
|
static const char *llm_api_url(void)
|
|
{
|
|
return llm_provider_api_url();
|
|
}
|
|
|
|
static const char *llm_api_host(void)
|
|
{
|
|
return llm_provider_host();
|
|
}
|
|
|
|
static const char *llm_api_path(void)
|
|
{
|
|
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);
|
|
}
|
|
if (MIMI_SECRET_MODEL[0] != '\0') {
|
|
safe_copy(s_model, sizeof(s_model), MIMI_SECRET_MODEL);
|
|
}
|
|
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) */
|
|
nvs_handle_t nvs;
|
|
if (nvs_open(MIMI_NVS_LLM, NVS_READONLY, &nvs) == ESP_OK) {
|
|
char tmp[LLM_API_KEY_MAX_LEN] = {0};
|
|
size_t len = sizeof(tmp);
|
|
if (nvs_get_str(nvs, MIMI_NVS_KEY_API_KEY, tmp, &len) == ESP_OK && tmp[0]) {
|
|
safe_copy(s_api_key, sizeof(s_api_key), tmp);
|
|
}
|
|
char model_tmp[LLM_MODEL_MAX_LEN] = {0};
|
|
len = sizeof(model_tmp);
|
|
if (nvs_get_str(nvs, MIMI_NVS_KEY_MODEL, model_tmp, &len) == ESP_OK && model_tmp[0]) {
|
|
safe_copy(s_model, sizeof(s_model), model_tmp);
|
|
}
|
|
char provider_tmp[16] = {0};
|
|
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 from NVS */
|
|
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);
|
|
}
|
|
|
|
/* Fall back to build-time provider-specific API key if NVS key is empty */
|
|
if (s_api_key[0] == '\0') {
|
|
if (strcmp(s_provider, "siliconflow") == 0 && MIMI_SECRET_SILICONFLOW_API_KEY[0] != '\0') {
|
|
safe_copy(s_api_key, sizeof(s_api_key), MIMI_SECRET_SILICONFLOW_API_KEY);
|
|
} else if (strcmp(s_provider, "volcengine") == 0 && MIMI_SECRET_VOLCENGINE_API_KEY[0] != '\0') {
|
|
safe_copy(s_api_key, sizeof(s_api_key), MIMI_SECRET_VOLCENGINE_API_KEY);
|
|
} else if (strcmp(s_provider, "openai") == 0 && MIMI_SECRET_OPENAI_API_KEY[0] != '\0') {
|
|
safe_copy(s_api_key, sizeof(s_api_key), MIMI_SECRET_OPENAI_API_KEY);
|
|
} else if (strcmp(s_provider, "anthropic") == 0 && MIMI_SECRET_ANTHROPIC_API_KEY[0] != '\0') {
|
|
safe_copy(s_api_key, sizeof(s_api_key), MIMI_SECRET_ANTHROPIC_API_KEY);
|
|
}
|
|
}
|
|
|
|
if (s_api_key[0]) {
|
|
ESP_LOGI(TAG, "LLM proxy initialized (provider: %s, model: %s)", s_provider, s_model);
|
|
} else {
|
|
ESP_LOGW(TAG, "No API key. Use CLI: set_api_key <KEY>");
|
|
}
|
|
return ESP_OK;
|
|
}
|
|
|
|
/* ── Direct path: esp_http_client ───────────────────────────── */
|
|
|
|
static esp_err_t llm_http_direct(const char *post_data, resp_buf_t *rb, int *out_status)
|
|
{
|
|
esp_http_client_config_t config = {
|
|
.url = llm_api_url(),
|
|
.event_handler = http_event_handler,
|
|
.user_data = rb,
|
|
.timeout_ms = 120 * 1000,
|
|
.buffer_size = 4096,
|
|
.buffer_size_tx = 4096,
|
|
.crt_bundle_attach = esp_crt_bundle_attach,
|
|
};
|
|
|
|
esp_http_client_handle_t client = esp_http_client_init(&config);
|
|
if (!client) return ESP_FAIL;
|
|
|
|
esp_http_client_set_method(client, HTTP_METHOD_POST);
|
|
esp_http_client_set_header(client, "Content-Type", "application/json");
|
|
|
|
/* Use provider-specific authentication */
|
|
if (llm_provider_is_openai_compatible()) {
|
|
/* OpenAI-compatible providers use Bearer token authentication */
|
|
if (s_api_key[0]) {
|
|
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);
|
|
}
|
|
esp_http_client_set_post_field(client, post_data, strlen(post_data));
|
|
|
|
esp_err_t err = esp_http_client_perform(client);
|
|
*out_status = esp_http_client_get_status_code(client);
|
|
esp_http_client_cleanup(client);
|
|
return err;
|
|
}
|
|
|
|
/* ── Proxy path: manual HTTP over CONNECT tunnel ────────────── */
|
|
|
|
static esp_err_t llm_http_via_proxy(const char *post_data, resp_buf_t *rb, int *out_status)
|
|
{
|
|
proxy_conn_t *conn = proxy_conn_open(llm_api_host(), 443, 30000);
|
|
if (!conn) return ESP_ERR_HTTP_CONNECT;
|
|
|
|
int body_len = strlen(post_data);
|
|
char header[1024];
|
|
int hlen = 0;
|
|
|
|
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"
|
|
"Content-Type: application/json\r\n"
|
|
"Authorization: Bearer %s\r\n"
|
|
"Content-Length: %d\r\n"
|
|
"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"
|
|
"Content-Type: application/json\r\n"
|
|
"x-api-key: %s\r\n"
|
|
"anthropic-version: %s\r\n"
|
|
"Content-Length: %d\r\n"
|
|
"Connection: close\r\n\r\n",
|
|
llm_api_path(), llm_api_host(), s_api_key, MIMI_LLM_API_VERSION, body_len);
|
|
}
|
|
|
|
if (proxy_conn_write(conn, header, hlen) < 0 ||
|
|
proxy_conn_write(conn, post_data, body_len) < 0) {
|
|
proxy_conn_close(conn);
|
|
return ESP_ERR_HTTP_WRITE_DATA;
|
|
}
|
|
|
|
/* Read full response into buffer */
|
|
char tmp[4096];
|
|
while (1) {
|
|
int n = proxy_conn_read(conn, tmp, sizeof(tmp), 120000);
|
|
if (n <= 0) break;
|
|
if (resp_buf_append(rb, tmp, n) != ESP_OK) break;
|
|
}
|
|
proxy_conn_close(conn);
|
|
|
|
/* Parse status line */
|
|
*out_status = 0;
|
|
if (rb->len > 5 && strncmp(rb->data, "HTTP/", 5) == 0) {
|
|
const char *sp = strchr(rb->data, ' ');
|
|
if (sp) *out_status = atoi(sp + 1);
|
|
}
|
|
|
|
/* Strip HTTP headers, keep body only */
|
|
char *body = strstr(rb->data, "\r\n\r\n");
|
|
if (body) {
|
|
body += 4;
|
|
size_t blen = rb->len - (body - rb->data);
|
|
memmove(rb->data, body, blen);
|
|
rb->len = blen;
|
|
rb->data[rb->len] = '\0';
|
|
}
|
|
|
|
/* Decode chunked transfer encoding if present */
|
|
resp_buf_decode_chunked(rb);
|
|
|
|
return ESP_OK;
|
|
}
|
|
|
|
/* ── Shared HTTP dispatch ─────────────────────────────────────── */
|
|
|
|
static esp_err_t llm_http_call(const char *post_data, resp_buf_t *rb, int *out_status)
|
|
{
|
|
if (http_proxy_is_enabled()) {
|
|
return llm_http_via_proxy(post_data, rb, out_status);
|
|
} else {
|
|
return llm_http_direct(post_data, rb, out_status);
|
|
}
|
|
}
|
|
|
|
static cJSON *convert_tools_openai(const char *tools_json)
|
|
{
|
|
if (!tools_json) return NULL;
|
|
cJSON *arr = cJSON_Parse(tools_json);
|
|
if (!arr || !cJSON_IsArray(arr)) {
|
|
cJSON_Delete(arr);
|
|
return NULL;
|
|
}
|
|
cJSON *out = cJSON_CreateArray();
|
|
cJSON *tool;
|
|
cJSON_ArrayForEach(tool, arr) {
|
|
cJSON *name = cJSON_GetObjectItem(tool, "name");
|
|
cJSON *desc = cJSON_GetObjectItem(tool, "description");
|
|
cJSON *schema = cJSON_GetObjectItem(tool, "input_schema");
|
|
if (!name || !cJSON_IsString(name)) continue;
|
|
|
|
cJSON *func = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(func, "name", name->valuestring);
|
|
if (desc && cJSON_IsString(desc)) {
|
|
cJSON_AddStringToObject(func, "description", desc->valuestring);
|
|
}
|
|
if (schema) {
|
|
cJSON_AddItemToObject(func, "parameters", cJSON_Duplicate(schema, 1));
|
|
}
|
|
|
|
cJSON *wrap = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(wrap, "type", "function");
|
|
cJSON_AddItemToObject(wrap, "function", func);
|
|
cJSON_AddItemToArray(out, wrap);
|
|
}
|
|
cJSON_Delete(arr);
|
|
return out;
|
|
}
|
|
|
|
static cJSON *convert_messages_openai(const char *system_prompt, cJSON *messages)
|
|
{
|
|
cJSON *out = cJSON_CreateArray();
|
|
if (system_prompt && system_prompt[0]) {
|
|
cJSON *sys = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(sys, "role", "system");
|
|
cJSON_AddStringToObject(sys, "content", system_prompt);
|
|
cJSON_AddItemToArray(out, sys);
|
|
}
|
|
|
|
if (!messages || !cJSON_IsArray(messages)) return out;
|
|
|
|
cJSON *msg;
|
|
cJSON_ArrayForEach(msg, messages) {
|
|
cJSON *role = cJSON_GetObjectItem(msg, "role");
|
|
cJSON *content = cJSON_GetObjectItem(msg, "content");
|
|
if (!role || !cJSON_IsString(role)) continue;
|
|
|
|
if (content && cJSON_IsString(content)) {
|
|
cJSON *m = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(m, "role", role->valuestring);
|
|
cJSON_AddStringToObject(m, "content", content->valuestring);
|
|
cJSON_AddItemToArray(out, m);
|
|
continue;
|
|
}
|
|
|
|
if (!content || !cJSON_IsArray(content)) continue;
|
|
|
|
if (strcmp(role->valuestring, "assistant") == 0) {
|
|
cJSON *m = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(m, "role", "assistant");
|
|
|
|
/* collect text */
|
|
char *text_buf = NULL;
|
|
size_t off = 0;
|
|
cJSON *block;
|
|
cJSON *tool_calls = NULL;
|
|
cJSON_ArrayForEach(block, content) {
|
|
cJSON *btype = cJSON_GetObjectItem(block, "type");
|
|
if (btype && cJSON_IsString(btype) && strcmp(btype->valuestring, "text") == 0) {
|
|
cJSON *text = cJSON_GetObjectItem(block, "text");
|
|
if (text && cJSON_IsString(text)) {
|
|
size_t tlen = strlen(text->valuestring);
|
|
char *tmp = realloc(text_buf, off + tlen + 1);
|
|
if (tmp) {
|
|
text_buf = tmp;
|
|
memcpy(text_buf + off, text->valuestring, tlen);
|
|
off += tlen;
|
|
text_buf[off] = '\0';
|
|
}
|
|
}
|
|
} else if (btype && cJSON_IsString(btype) && strcmp(btype->valuestring, "tool_use") == 0) {
|
|
if (!tool_calls) tool_calls = cJSON_CreateArray();
|
|
cJSON *id = cJSON_GetObjectItem(block, "id");
|
|
cJSON *name = cJSON_GetObjectItem(block, "name");
|
|
cJSON *input = cJSON_GetObjectItem(block, "input");
|
|
if (!name || !cJSON_IsString(name)) continue;
|
|
|
|
cJSON *tc = cJSON_CreateObject();
|
|
if (id && cJSON_IsString(id)) {
|
|
cJSON_AddStringToObject(tc, "id", id->valuestring);
|
|
}
|
|
cJSON_AddStringToObject(tc, "type", "function");
|
|
cJSON *func = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(func, "name", name->valuestring);
|
|
if (input) {
|
|
char *args = cJSON_PrintUnformatted(input);
|
|
if (args) {
|
|
cJSON_AddStringToObject(func, "arguments", args);
|
|
free(args);
|
|
}
|
|
}
|
|
cJSON_AddItemToObject(tc, "function", func);
|
|
cJSON_AddItemToArray(tool_calls, tc);
|
|
}
|
|
}
|
|
if (text_buf) {
|
|
cJSON_AddStringToObject(m, "content", text_buf);
|
|
} else {
|
|
cJSON_AddStringToObject(m, "content", "");
|
|
}
|
|
if (tool_calls) {
|
|
cJSON_AddItemToObject(m, "tool_calls", tool_calls);
|
|
}
|
|
cJSON_AddItemToArray(out, m);
|
|
free(text_buf);
|
|
} else if (strcmp(role->valuestring, "user") == 0) {
|
|
/* tool_result blocks become role=tool */
|
|
cJSON *block;
|
|
bool has_user_text = false;
|
|
char *text_buf = NULL;
|
|
size_t off = 0;
|
|
cJSON_ArrayForEach(block, content) {
|
|
cJSON *btype = cJSON_GetObjectItem(block, "type");
|
|
if (btype && cJSON_IsString(btype) && strcmp(btype->valuestring, "tool_result") == 0) {
|
|
cJSON *tool_id = cJSON_GetObjectItem(block, "tool_use_id");
|
|
cJSON *tcontent = cJSON_GetObjectItem(block, "content");
|
|
if (!tool_id || !cJSON_IsString(tool_id)) continue;
|
|
cJSON *tm = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(tm, "role", "tool");
|
|
cJSON_AddStringToObject(tm, "tool_call_id", tool_id->valuestring);
|
|
if (tcontent && cJSON_IsString(tcontent)) {
|
|
cJSON_AddStringToObject(tm, "content", tcontent->valuestring);
|
|
} else {
|
|
cJSON_AddStringToObject(tm, "content", "");
|
|
}
|
|
cJSON_AddItemToArray(out, tm);
|
|
} else if (btype && cJSON_IsString(btype) && strcmp(btype->valuestring, "text") == 0) {
|
|
cJSON *text = cJSON_GetObjectItem(block, "text");
|
|
if (text && cJSON_IsString(text)) {
|
|
size_t tlen = strlen(text->valuestring);
|
|
char *tmp = realloc(text_buf, off + tlen + 1);
|
|
if (tmp) {
|
|
text_buf = tmp;
|
|
memcpy(text_buf + off, text->valuestring, tlen);
|
|
off += tlen;
|
|
text_buf[off] = '\0';
|
|
}
|
|
has_user_text = true;
|
|
}
|
|
}
|
|
}
|
|
if (has_user_text) {
|
|
cJSON *um = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(um, "role", "user");
|
|
cJSON_AddStringToObject(um, "content", text_buf);
|
|
cJSON_AddItemToArray(out, um);
|
|
}
|
|
free(text_buf);
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/* ── Public: chat with tools (non-streaming) ──────────────────── */
|
|
|
|
void llm_response_free(llm_response_t *resp)
|
|
{
|
|
free(resp->text);
|
|
resp->text = NULL;
|
|
resp->text_len = 0;
|
|
for (int i = 0; i < resp->call_count; i++) {
|
|
free(resp->calls[i].input);
|
|
resp->calls[i].input = NULL;
|
|
}
|
|
resp->call_count = 0;
|
|
resp->tool_use = false;
|
|
}
|
|
|
|
esp_err_t llm_chat_tools(const char *system_prompt,
|
|
cJSON *messages,
|
|
const char *tools_json,
|
|
llm_response_t *resp)
|
|
{
|
|
memset(resp, 0, sizeof(*resp));
|
|
|
|
if (s_api_key[0] == '\0') return ESP_ERR_INVALID_STATE;
|
|
|
|
/* Build request body (non-streaming) */
|
|
cJSON *body = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(body, "model", s_model);
|
|
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 (llm_provider_is_openai_compatible()) {
|
|
cJSON *openai_msgs = convert_messages_openai(system_prompt, messages);
|
|
cJSON_AddItemToObject(body, "messages", openai_msgs);
|
|
|
|
if (tools_json) {
|
|
cJSON *tools = convert_tools_openai(tools_json);
|
|
if (tools) {
|
|
cJSON_AddItemToObject(body, "tools", tools);
|
|
cJSON_AddStringToObject(body, "tool_choice", "auto");
|
|
}
|
|
}
|
|
} else {
|
|
cJSON_AddStringToObject(body, "system", system_prompt);
|
|
|
|
/* Deep-copy messages so caller keeps ownership */
|
|
cJSON *msgs_copy = cJSON_Duplicate(messages, 1);
|
|
cJSON_AddItemToObject(body, "messages", msgs_copy);
|
|
|
|
/* Add tools array if provided */
|
|
if (tools_json) {
|
|
cJSON *tools = cJSON_Parse(tools_json);
|
|
if (tools) {
|
|
cJSON_AddItemToObject(body, "tools", tools);
|
|
}
|
|
}
|
|
}
|
|
|
|
char *post_data = cJSON_PrintUnformatted(body);
|
|
cJSON_Delete(body);
|
|
if (!post_data) return ESP_ERR_NO_MEM;
|
|
|
|
ESP_LOGI(TAG, "Calling LLM API with tools (provider: %s, model: %s, body: %d bytes)",
|
|
s_provider, s_model, (int)strlen(post_data));
|
|
llm_log_payload("LLM tools request", post_data);
|
|
|
|
/* HTTP call */
|
|
resp_buf_t rb;
|
|
if (resp_buf_init(&rb, MIMI_LLM_STREAM_BUF_SIZE) != ESP_OK) {
|
|
free(post_data);
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
int status = 0;
|
|
esp_err_t err = llm_http_call(post_data, &rb, &status);
|
|
free(post_data);
|
|
|
|
if (err != ESP_OK) {
|
|
ESP_LOGE(TAG, "HTTP request failed: %s", esp_err_to_name(err));
|
|
llm_log_payload("LLM tools partial response", rb.data);
|
|
resp_buf_free(&rb);
|
|
return err;
|
|
}
|
|
|
|
llm_log_payload("LLM tools raw response", rb.data);
|
|
|
|
if (status != 200) {
|
|
ESP_LOGE(TAG, "API error %d: %.500s", status, rb.data ? rb.data : "");
|
|
resp_buf_free(&rb);
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
/* Parse full JSON response */
|
|
cJSON *root = cJSON_Parse(rb.data);
|
|
resp_buf_free(&rb);
|
|
|
|
if (!root) {
|
|
ESP_LOGE(TAG, "Failed to parse API response JSON");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
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) {
|
|
cJSON *finish = cJSON_GetObjectItem(choice0, "finish_reason");
|
|
if (finish && cJSON_IsString(finish)) {
|
|
resp->tool_use = (strcmp(finish->valuestring, "tool_calls") == 0);
|
|
}
|
|
|
|
cJSON *message = cJSON_GetObjectItem(choice0, "message");
|
|
if (message) {
|
|
cJSON *content = cJSON_GetObjectItem(message, "content");
|
|
if (content && cJSON_IsString(content)) {
|
|
size_t tlen = strlen(content->valuestring);
|
|
resp->text = calloc(1, tlen + 1);
|
|
if (resp->text) {
|
|
memcpy(resp->text, content->valuestring, tlen);
|
|
resp->text_len = tlen;
|
|
}
|
|
}
|
|
|
|
cJSON *tool_calls = cJSON_GetObjectItem(message, "tool_calls");
|
|
if (tool_calls && cJSON_IsArray(tool_calls)) {
|
|
cJSON *tc;
|
|
cJSON_ArrayForEach(tc, tool_calls) {
|
|
if (resp->call_count >= MIMI_MAX_TOOL_CALLS) break;
|
|
llm_tool_call_t *call = &resp->calls[resp->call_count];
|
|
cJSON *id = cJSON_GetObjectItem(tc, "id");
|
|
cJSON *func = cJSON_GetObjectItem(tc, "function");
|
|
if (id && cJSON_IsString(id)) {
|
|
strncpy(call->id, id->valuestring, sizeof(call->id) - 1);
|
|
}
|
|
if (func) {
|
|
cJSON *name = cJSON_GetObjectItem(func, "name");
|
|
cJSON *args = cJSON_GetObjectItem(func, "arguments");
|
|
if (name && cJSON_IsString(name)) {
|
|
strncpy(call->name, name->valuestring, sizeof(call->name) - 1);
|
|
}
|
|
if (args && cJSON_IsString(args)) {
|
|
call->input = strdup(args->valuestring);
|
|
if (call->input) {
|
|
call->input_len = strlen(call->input);
|
|
}
|
|
}
|
|
}
|
|
resp->call_count++;
|
|
}
|
|
if (resp->call_count > 0) {
|
|
resp->tool_use = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
/* stop_reason */
|
|
cJSON *stop_reason = cJSON_GetObjectItem(root, "stop_reason");
|
|
if (stop_reason && cJSON_IsString(stop_reason)) {
|
|
resp->tool_use = (strcmp(stop_reason->valuestring, "tool_use") == 0);
|
|
}
|
|
|
|
/* Iterate content blocks */
|
|
cJSON *content = cJSON_GetObjectItem(root, "content");
|
|
if (content && cJSON_IsArray(content)) {
|
|
/* Accumulate total text length first */
|
|
size_t total_text = 0;
|
|
cJSON *block;
|
|
cJSON_ArrayForEach(block, content) {
|
|
cJSON *btype = cJSON_GetObjectItem(block, "type");
|
|
if (btype && strcmp(btype->valuestring, "text") == 0) {
|
|
cJSON *text = cJSON_GetObjectItem(block, "text");
|
|
if (text && cJSON_IsString(text)) {
|
|
total_text += strlen(text->valuestring);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Allocate and copy text */
|
|
if (total_text > 0) {
|
|
resp->text = calloc(1, total_text + 1);
|
|
if (resp->text) {
|
|
cJSON_ArrayForEach(block, content) {
|
|
cJSON *btype = cJSON_GetObjectItem(block, "type");
|
|
if (!btype || strcmp(btype->valuestring, "text") != 0) continue;
|
|
cJSON *text = cJSON_GetObjectItem(block, "text");
|
|
if (!text || !cJSON_IsString(text)) continue;
|
|
size_t tlen = strlen(text->valuestring);
|
|
memcpy(resp->text + resp->text_len, text->valuestring, tlen);
|
|
resp->text_len += tlen;
|
|
}
|
|
resp->text[resp->text_len] = '\0';
|
|
}
|
|
}
|
|
|
|
/* Extract tool_use blocks */
|
|
cJSON_ArrayForEach(block, content) {
|
|
cJSON *btype = cJSON_GetObjectItem(block, "type");
|
|
if (!btype || strcmp(btype->valuestring, "tool_use") != 0) continue;
|
|
if (resp->call_count >= MIMI_MAX_TOOL_CALLS) break;
|
|
|
|
llm_tool_call_t *call = &resp->calls[resp->call_count];
|
|
|
|
cJSON *id = cJSON_GetObjectItem(block, "id");
|
|
if (id && cJSON_IsString(id)) {
|
|
strncpy(call->id, id->valuestring, sizeof(call->id) - 1);
|
|
}
|
|
|
|
cJSON *name = cJSON_GetObjectItem(block, "name");
|
|
if (name && cJSON_IsString(name)) {
|
|
strncpy(call->name, name->valuestring, sizeof(call->name) - 1);
|
|
}
|
|
|
|
cJSON *input = cJSON_GetObjectItem(block, "input");
|
|
if (input) {
|
|
char *input_str = cJSON_PrintUnformatted(input);
|
|
if (input_str) {
|
|
call->input = input_str;
|
|
call->input_len = strlen(input_str);
|
|
}
|
|
}
|
|
|
|
resp->call_count++;
|
|
}
|
|
}
|
|
}
|
|
|
|
cJSON_Delete(root);
|
|
|
|
ESP_LOGI(TAG, "Response: %d bytes text, %d tool calls, stop=%s",
|
|
(int)resp->text_len, resp->call_count,
|
|
resp->tool_use ? "tool_use" : "end_turn");
|
|
|
|
return ESP_OK;
|
|
}
|
|
|
|
/* ── NVS helpers ──────────────────────────────────────────────── */
|
|
|
|
esp_err_t llm_set_api_key(const char *api_key)
|
|
{
|
|
nvs_handle_t nvs;
|
|
ESP_ERROR_CHECK(nvs_open(MIMI_NVS_LLM, NVS_READWRITE, &nvs));
|
|
ESP_ERROR_CHECK(nvs_set_str(nvs, MIMI_NVS_KEY_API_KEY, api_key));
|
|
ESP_ERROR_CHECK(nvs_commit(nvs));
|
|
nvs_close(nvs);
|
|
|
|
safe_copy(s_api_key, sizeof(s_api_key), api_key);
|
|
|
|
/* 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;
|
|
}
|
|
|
|
esp_err_t llm_set_model(const char *model)
|
|
{
|
|
nvs_handle_t nvs;
|
|
ESP_ERROR_CHECK(nvs_open(MIMI_NVS_LLM, NVS_READWRITE, &nvs));
|
|
ESP_ERROR_CHECK(nvs_set_str(nvs, MIMI_NVS_KEY_MODEL, model));
|
|
ESP_ERROR_CHECK(nvs_commit(nvs));
|
|
nvs_close(nvs);
|
|
|
|
safe_copy(s_model, sizeof(s_model), model);
|
|
ESP_LOGI(TAG, "Model set to: %s", s_model);
|
|
return ESP_OK;
|
|
}
|
|
|
|
esp_err_t llm_set_provider(const char *provider)
|
|
{
|
|
nvs_handle_t nvs;
|
|
ESP_ERROR_CHECK(nvs_open(MIMI_NVS_LLM, NVS_READWRITE, &nvs));
|
|
ESP_ERROR_CHECK(nvs_set_str(nvs, MIMI_NVS_KEY_PROVIDER, provider));
|
|
ESP_ERROR_CHECK(nvs_commit(nvs));
|
|
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);
|
|
}
|