Files
didactyl/src/llm.c
T

456 lines
14 KiB
C

#define _POSIX_C_SOURCE 200809L
#include "llm.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "cjson/cJSON.h"
#include "debug.h"
#include "../nostr_core_lib/nostr_core/nostr_http.h"
static llm_config_t g_cfg;
static int g_initialized = 0;
static int url_looks_like_websocket(const char* url) {
if (!url) return 0;
return (strncmp(url, "ws://", 5) == 0) || (strncmp(url, "wss://", 6) == 0);
}
static int json_string_is_blank(const cJSON* item) {
if (!item || !cJSON_IsString(item) || !item->valuestring) {
return 0;
}
const unsigned char* p = (const unsigned char*)item->valuestring;
while (*p) {
if (!isspace(*p)) {
return 0;
}
p++;
}
return 1;
}
static char* perform_http_request(const char* url, const char* body, int is_post) {
if (!url) {
return NULL;
}
if (url_looks_like_websocket(url)) {
DEBUG_ERROR("[didactyl] llm config error: base_url must be HTTP(S), got WebSocket URL: %s",
url);
DEBUG_WARN("[didactyl] llm hint: set llm.base_url to an OpenAI-compatible HTTPS endpoint, e.g. https://api.example.com/v1");
return NULL;
}
char auth_header[OW_MAX_KEY_LEN + 32];
snprintf(auth_header, sizeof(auth_header), "Authorization: Bearer %s", g_cfg.api_key);
const char* headers[] = {
"Content-Type: application/json",
auth_header,
NULL
};
nostr_http_request_t req;
memset(&req, 0, sizeof(req));
req.method = is_post ? "POST" : "GET";
req.url = url;
req.headers = headers;
req.timeout_seconds = 60;
req.follow_redirects = 1;
req.max_redirects = 3;
req.user_agent = "didactyl/llm";
if (is_post) {
const char* payload = body ? body : "{}";
req.body = (const unsigned char*)payload;
req.body_len = strlen(payload);
}
if (is_post) {
size_t body_len = body ? strlen(body) : 0U;
size_t body_preview_len = body_len > 4000U ? 4000U : body_len;
DEBUG_INFO("[didactyl] llm request: method=POST url=%s body_bytes=%zu body_preview=%.4000s%s",
url,
body_len,
body ? body : "",
body_len > body_preview_len ? "..." : "");
} else {
DEBUG_INFO("[didactyl] llm request: method=GET url=%s", url);
}
nostr_http_response_t resp;
int rc = nostr_http_request(&req, &resp);
if (rc != NOSTR_SUCCESS) {
DEBUG_ERROR("[didactyl] llm http request failed: transport error rc=%d", rc);
return NULL;
}
if (resp.status_code < 200 || resp.status_code >= 300) {
DEBUG_ERROR("[didactyl] llm http request failed: status=%ld", resp.status_code);
if (resp.status_code == 101) {
DEBUG_WARN("[didactyl] llm hint: received HTTP 101 (Switching Protocols), which usually means llm.base_url points to a WebSocket server instead of an HTTP LLM API");
}
if (resp.body && resp.body_len > 0) {
DEBUG_WARN("[didactyl] llm error response: %.1200s%s",
resp.body,
resp.body_len > 1200 ? "..." : "");
}
nostr_http_response_free(&resp);
return NULL;
}
if (!resp.body) {
DEBUG_ERROR("[didactyl] llm http request failed: empty response body");
nostr_http_response_free(&resp);
return NULL;
}
char* out = resp.body;
free(resp.content_type);
free(resp.headers_raw);
memset(&resp, 0, sizeof(resp));
return out;
}
static char* perform_chat_request(const char* body) {
char url[OW_MAX_URL_LEN + 64];
snprintf(url, sizeof(url), "%s/chat/completions", g_cfg.base_url);
return perform_http_request(url, body, 1);
}
static char* build_request_json(const char* system_prompt, const char* user_message) {
cJSON* root = cJSON_CreateObject();
cJSON* messages = cJSON_CreateArray();
if (!root || !messages) {
cJSON_Delete(root);
cJSON_Delete(messages);
return NULL;
}
cJSON_AddStringToObject(root, "model", g_cfg.model);
cJSON_AddNumberToObject(root, "max_tokens", g_cfg.max_tokens);
cJSON_AddNumberToObject(root, "temperature", g_cfg.temperature);
cJSON* system_msg = cJSON_CreateObject();
cJSON* user_msg = cJSON_CreateObject();
if (!system_msg || !user_msg) {
cJSON_Delete(root);
return NULL;
}
cJSON_AddStringToObject(system_msg, "role", "system");
cJSON_AddStringToObject(system_msg, "content", system_prompt ? system_prompt : "");
cJSON_AddStringToObject(user_msg, "role", "user");
cJSON_AddStringToObject(user_msg, "content", user_message ? user_message : "");
cJSON_AddItemToArray(messages, system_msg);
cJSON_AddItemToArray(messages, user_msg);
cJSON_AddItemToObject(root, "messages", messages);
char* body = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return body;
}
static int parse_tool_calls(cJSON* msg, llm_response_t* out) {
cJSON* tc = cJSON_GetObjectItemCaseSensitive(msg, "tool_calls");
if (!tc || !cJSON_IsArray(tc)) {
out->tool_calls = NULL;
out->tool_call_count = 0;
return 0;
}
int n = cJSON_GetArraySize(tc);
if (n <= 0) {
out->tool_calls = NULL;
out->tool_call_count = 0;
return 0;
}
llm_tool_call_t* calls = (llm_tool_call_t*)calloc((size_t)n, sizeof(llm_tool_call_t));
if (!calls) return -1;
int actual = 0;
for (int i = 0; i < n; i++) {
cJSON* item = cJSON_GetArrayItem(tc, i);
cJSON* id = item ? cJSON_GetObjectItemCaseSensitive(item, "id") : NULL;
cJSON* fn = item ? cJSON_GetObjectItemCaseSensitive(item, "function") : NULL;
cJSON* name = fn ? cJSON_GetObjectItemCaseSensitive(fn, "name") : NULL;
cJSON* args = fn ? cJSON_GetObjectItemCaseSensitive(fn, "arguments") : NULL;
if (!id || !cJSON_IsString(id) || !id->valuestring ||
!name || !cJSON_IsString(name) || !name->valuestring) {
continue;
}
calls[actual].id = strdup(id->valuestring);
calls[actual].name = strdup(name->valuestring);
calls[actual].arguments_json = strdup((args && cJSON_IsString(args) && args->valuestring) ? args->valuestring : "{}");
if (!calls[actual].id || !calls[actual].name || !calls[actual].arguments_json) {
free(calls[actual].id);
free(calls[actual].name);
free(calls[actual].arguments_json);
continue;
}
actual++;
}
if (actual == 0) {
free(calls);
out->tool_calls = NULL;
out->tool_call_count = 0;
return 0;
}
out->tool_calls = calls;
out->tool_call_count = actual;
return 0;
}
static int parse_llm_response(const char* json, llm_response_t* out) {
memset(out, 0, sizeof(*out));
cJSON* root = cJSON_Parse(json);
if (!root) return -1;
cJSON* choices = cJSON_GetObjectItemCaseSensitive(root, "choices");
cJSON* first = (choices && cJSON_IsArray(choices) && cJSON_GetArraySize(choices) > 0)
? cJSON_GetArrayItem(choices, 0)
: NULL;
cJSON* msg = first ? cJSON_GetObjectItemCaseSensitive(first, "message") : NULL;
if (!msg || !cJSON_IsObject(msg)) {
cJSON_Delete(root);
return -1;
}
cJSON* finish_reason = first ? cJSON_GetObjectItemCaseSensitive(first, "finish_reason") : NULL;
if (finish_reason && cJSON_IsString(finish_reason) && finish_reason->valuestring) {
out->finish_reason = strdup(finish_reason->valuestring);
}
cJSON* content = cJSON_GetObjectItemCaseSensitive(msg, "content");
if (content && cJSON_IsString(content) && content->valuestring) {
out->content = strdup(content->valuestring);
}
if (parse_tool_calls(msg, out) != 0) {
cJSON_Delete(root);
llm_response_free(out);
return -1;
}
cJSON_Delete(root);
return 0;
}
int llm_init(const llm_config_t* config) {
if (!config) {
return -1;
}
memset(&g_cfg, 0, sizeof(g_cfg));
g_cfg = *config;
g_initialized = 1;
return 0;
}
char* llm_chat(const char* system_prompt, const char* user_message) {
if (!g_initialized) {
return NULL;
}
char* body = build_request_json(system_prompt, user_message);
if (!body) return NULL;
char* raw = perform_chat_request(body);
free(body);
if (!raw) return NULL;
llm_response_t parsed;
if (parse_llm_response(raw, &parsed) != 0) {
DEBUG_ERROR("[didactyl] failed to parse llm response (non-tool path): %.1200s%s",
raw,
strlen(raw) > 1200 ? "..." : "");
free(raw);
return NULL;
}
free(raw);
char* answer = parsed.content ? strdup(parsed.content) : NULL;
llm_response_free(&parsed);
return answer;
}
int llm_chat_with_tools_messages(const char* messages_json,
const char* tools_json,
const char* tool_choice,
llm_response_t* out_response) {
if (!g_initialized || !out_response || !messages_json) {
return -1;
}
cJSON* root = cJSON_CreateObject();
if (!root) {
return -1;
}
cJSON_AddStringToObject(root, "model", g_cfg.model);
cJSON_AddNumberToObject(root, "max_tokens", g_cfg.max_tokens);
cJSON_AddNumberToObject(root, "temperature", g_cfg.temperature);
cJSON* messages = cJSON_Parse(messages_json);
if (!messages || !cJSON_IsArray(messages)) {
cJSON_Delete(messages);
cJSON_Delete(root);
return -1;
}
int filtered_count = 0;
for (int i = cJSON_GetArraySize(messages) - 1; i >= 0; i--) {
cJSON* msg = cJSON_GetArrayItem(messages, i);
if (!msg || !cJSON_IsObject(msg)) {
continue;
}
cJSON* role = cJSON_GetObjectItemCaseSensitive(msg, "role");
cJSON* content = cJSON_GetObjectItemCaseSensitive(msg, "content");
cJSON* tool_calls = cJSON_GetObjectItemCaseSensitive(msg, "tool_calls");
int role_is_textual = (role && cJSON_IsString(role) && role->valuestring &&
(strcmp(role->valuestring, "system") == 0 ||
strcmp(role->valuestring, "user") == 0 ||
strcmp(role->valuestring, "assistant") == 0));
int has_tool_calls = (tool_calls && cJSON_IsArray(tool_calls) && cJSON_GetArraySize(tool_calls) > 0);
if (role_is_textual && !has_tool_calls && json_string_is_blank(content)) {
cJSON_DeleteItemFromArray(messages, i);
filtered_count++;
}
}
if (filtered_count > 0) {
DEBUG_INFO("[didactyl] llm request sanitizer: removed %d empty text message(s) before provider request",
filtered_count);
}
cJSON_AddItemToObject(root, "messages", messages);
if (tools_json) {
cJSON* tools = cJSON_Parse(tools_json);
if (tools && cJSON_IsArray(tools)) {
cJSON_AddItemToObject(root, "tools", tools);
cJSON_AddStringToObject(root, "tool_choice", tool_choice ? tool_choice : "auto");
} else {
cJSON_Delete(tools);
}
}
char* body = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
if (!body) return -1;
char* raw = perform_chat_request(body);
free(body);
if (!raw) return -1;
int rc = parse_llm_response(raw, out_response);
if (rc != 0) {
DEBUG_ERROR("[didactyl] failed to parse llm response (tools path): %.1200s%s",
raw,
strlen(raw) > 1200 ? "..." : "");
}
free(raw);
return rc;
}
int llm_chat_with_tools(const char* system_prompt,
const char* user_message,
const char* tools_json,
llm_response_t* out_response) {
cJSON* messages = cJSON_CreateArray();
cJSON* system_msg = cJSON_CreateObject();
cJSON* user_msg = cJSON_CreateObject();
if (!messages || !system_msg || !user_msg) {
cJSON_Delete(messages);
cJSON_Delete(system_msg);
cJSON_Delete(user_msg);
return -1;
}
cJSON_AddStringToObject(system_msg, "role", "system");
cJSON_AddStringToObject(system_msg, "content", system_prompt ? system_prompt : "");
cJSON_AddStringToObject(user_msg, "role", "user");
cJSON_AddStringToObject(user_msg, "content", user_message ? user_message : "");
cJSON_AddItemToArray(messages, system_msg);
cJSON_AddItemToArray(messages, user_msg);
char* messages_json = cJSON_PrintUnformatted(messages);
cJSON_Delete(messages);
if (!messages_json) {
return -1;
}
int rc = llm_chat_with_tools_messages(messages_json, tools_json, "auto", out_response);
free(messages_json);
return rc;
}
void llm_response_free(llm_response_t* response) {
if (!response) return;
free(response->content);
free(response->finish_reason);
for (int i = 0; i < response->tool_call_count; i++) {
free(response->tool_calls[i].id);
free(response->tool_calls[i].name);
free(response->tool_calls[i].arguments_json);
}
free(response->tool_calls);
memset(response, 0, sizeof(*response));
}
int llm_get_config(llm_config_t* out_config) {
if (!g_initialized || !out_config) {
return -1;
}
*out_config = g_cfg;
return 0;
}
int llm_set_config(const llm_config_t* config) {
if (!g_initialized || !config) {
return -1;
}
g_cfg = *config;
return 0;
}
char* llm_get_json_path(const char* base_url_override, const char* path) {
if (!g_initialized || !path || path[0] == '\0') {
return NULL;
}
const char* base_url = (base_url_override && base_url_override[0] != '\0')
? base_url_override
: g_cfg.base_url;
char url[OW_MAX_URL_LEN + 128];
snprintf(url, sizeof(url), "%s%s", base_url, path);
return perform_http_request(url, NULL, 0);
}
char* llm_list_models_json(const char* base_url_override) {
return llm_get_json_path(base_url_override, "/models");
}
void llm_cleanup(void) {
if (!g_initialized) {
return;
}
memset(&g_cfg, 0, sizeof(g_cfg));
g_initialized = 0;
}