455 lines
14 KiB
C
455 lines
14 KiB
C
#define _POSIX_C_SOURCE 200809L
|
|
|
|
#include "llm.h"
|
|
|
|
#include <curl/curl.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <unistd.h>
|
|
|
|
#include "cjson/cJSON.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 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)) {
|
|
fprintf(stderr,
|
|
"[didactyl] llm config error: base_url must be HTTP(S), got WebSocket URL: %s\n",
|
|
url);
|
|
fprintf(stderr,
|
|
"[didactyl] llm hint: set llm.base_url to an OpenAI-compatible HTTPS endpoint, e.g. https://api.example.com/v1\n");
|
|
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);
|
|
}
|
|
|
|
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) {
|
|
fprintf(stderr, "[didactyl] llm http request failed: curl=%s\n", curl_easy_strerror(res));
|
|
if (rb.data && rb.len > 0) {
|
|
fprintf(stderr, "[didactyl] llm partial response: %.600s%s\n",
|
|
rb.data,
|
|
rb.len > 600 ? "..." : "");
|
|
}
|
|
free(rb.data);
|
|
return NULL;
|
|
}
|
|
|
|
if (status < 200 || status >= 300) {
|
|
fprintf(stderr, "[didactyl] llm http request failed: status=%ld\n", status);
|
|
if (status == 101) {
|
|
fprintf(stderr,
|
|
"[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\n");
|
|
}
|
|
if (rb.data && rb.len > 0) {
|
|
fprintf(stderr, "[didactyl] llm error response: %.1200s%s\n",
|
|
rb.data,
|
|
rb.len > 1200 ? "..." : "");
|
|
}
|
|
free(rb.data);
|
|
return NULL;
|
|
}
|
|
|
|
if (!rb.data) {
|
|
fprintf(stderr, "[didactyl] llm http request failed: empty response body\n");
|
|
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* 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) {
|
|
fprintf(stderr, "[didactyl] failed to parse llm response (non-tool path): %.1200s%s\n",
|
|
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;
|
|
}
|
|
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) {
|
|
fprintf(stderr, "[didactyl] failed to parse llm response (tools path): %.1200s%s\n",
|
|
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);
|
|
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_list_models_json(const char* base_url_override) {
|
|
if (!g_initialized) {
|
|
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 + 32];
|
|
snprintf(url, sizeof(url), "%s/models", base_url);
|
|
return perform_http_request(url, NULL, 0);
|
|
}
|
|
|
|
void llm_cleanup(void) {
|
|
if (!g_initialized) {
|
|
return;
|
|
}
|
|
curl_global_cleanup();
|
|
memset(&g_cfg, 0, sizeof(g_cfg));
|
|
g_initialized = 0;
|
|
}
|