Files
didactyl/src/llm.c
T

519 lines
16 KiB
C

#define _POSIX_C_SOURCE 200809L
#include "llm.h"
#include <curl/curl.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "cjson/cJSON.h"
#include "debug.h"
typedef struct {
char* data;
size_t len;
size_t cap;
} response_buffer_t;
static llm_config_t g_cfg;
static int g_initialized = 0;
static size_t write_cb(void* contents, size_t size, size_t nmemb, void* userp) {
response_buffer_t* rb = (response_buffer_t*)userp;
size_t total = size * nmemb;
if (rb->len + total + 1U > rb->cap) {
size_t new_cap = rb->cap == 0 ? 1024U : rb->cap * 2U;
while (new_cap < rb->len + total + 1U) {
new_cap *= 2U;
}
char* p = (char*)realloc(rb->data, new_cap);
if (!p) {
return 0;
}
rb->data = p;
rb->cap = new_cap;
}
memcpy(rb->data + rb->len, contents, total);
rb->len += total;
rb->data[rb->len] = '\0';
return total;
}
static const char* detect_ca_bundle_path(void) {
const char* env = getenv("SSL_CERT_FILE");
if (env && env[0] != '\0' && access(env, R_OK) == 0) {
return env;
}
static const char* candidates[] = {
"/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu
"/etc/ssl/cert.pem", // Alpine
"/etc/pki/tls/certs/ca-bundle.crt", // RHEL/CentOS/Fedora
"/etc/ssl/ca-bundle.pem" // openSUSE
};
for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) {
if (access(candidates[i], R_OK) == 0) {
return candidates[i];
}
}
return NULL;
}
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) {
CURL* curl = curl_easy_init();
if (!curl || !url) {
if (curl) curl_easy_cleanup(curl);
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");
curl_easy_cleanup(curl);
return NULL;
}
response_buffer_t rb = {0};
struct curl_slist* headers = NULL;
headers = curl_slist_append(headers, "Content-Type: application/json");
char auth_header[OW_MAX_KEY_LEN + 32];
snprintf(auth_header, sizeof(auth_header), "Authorization: Bearer %s", g_cfg.api_key);
headers = curl_slist_append(headers, auth_header);
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPGET, is_post ? 0L : 1L);
if (is_post) {
curl_easy_setopt(curl, CURLOPT_POST, 1L);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body ? body : "{}");
}
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
const char* ca_bundle = detect_ca_bundle_path();
if (ca_bundle) {
curl_easy_setopt(curl, CURLOPT_CAINFO, ca_bundle);
}
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);
}
CURLcode res = curl_easy_perform(curl);
long status = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
if (res != CURLE_OK) {
DEBUG_ERROR("[didactyl] llm http request failed: curl=%s", curl_easy_strerror(res));
if (rb.data && rb.len > 0) {
DEBUG_WARN("[didactyl] llm partial response: %.600s%s",
rb.data,
rb.len > 600 ? "..." : "");
}
free(rb.data);
return NULL;
}
if (status < 200 || status >= 300) {
DEBUG_ERROR("[didactyl] llm http request failed: status=%ld", status);
if (status == 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 (rb.data && rb.len > 0) {
DEBUG_WARN("[didactyl] llm error response: %.1200s%s",
rb.data,
rb.len > 1200 ? "..." : "");
}
free(rb.data);
return NULL;
}
if (!rb.data) {
DEBUG_ERROR("[didactyl] llm http request failed: empty response body");
return NULL;
}
return rb.data;
}
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;
curl_global_init(CURL_GLOBAL_DEFAULT);
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;
}
curl_global_cleanup();
memset(&g_cfg, 0, sizeof(g_cfg));
g_initialized = 0;
}