531 lines
17 KiB
C
531 lines
17 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 <time.h>
|
|
#include <errno.h>
|
|
#include <sys/stat.h>
|
|
#include <pthread.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 pthread_mutex_t g_llm_request_log_mutex = PTHREAD_MUTEX_INITIALIZER;
|
|
static unsigned long g_llm_request_log_seq = 0;
|
|
|
|
static int provider_log_next_stamp_seq_local(char* stamp, size_t stamp_size, unsigned long* seq_out) {
|
|
if (!stamp || stamp_size == 0 || !seq_out) {
|
|
return -1;
|
|
}
|
|
|
|
stamp[0] = '\0';
|
|
*seq_out = 0;
|
|
|
|
if (mkdir("context.logs", 0755) != 0 && errno != EEXIST) {
|
|
return -1;
|
|
}
|
|
|
|
time_t now = time(NULL);
|
|
struct tm tm_info;
|
|
localtime_r(&now, &tm_info);
|
|
strftime(stamp, stamp_size, "%Y%m%dT%H%M%S", &tm_info);
|
|
|
|
pthread_mutex_lock(&g_llm_request_log_mutex);
|
|
*seq_out = ++g_llm_request_log_seq;
|
|
pthread_mutex_unlock(&g_llm_request_log_mutex);
|
|
|
|
return 0;
|
|
}
|
|
|
|
static void log_provider_payload_local(const char* payload,
|
|
const char* stamp,
|
|
unsigned long seq,
|
|
const char* phase) {
|
|
if (!payload || payload[0] == '\0' || !stamp || stamp[0] == '\0' || !phase || phase[0] == '\0') {
|
|
return;
|
|
}
|
|
|
|
char path[256] = {0};
|
|
snprintf(path, sizeof(path), "context.logs/%s_%s%04lu.json", stamp, phase, seq);
|
|
|
|
FILE* out = fopen(path, "wb");
|
|
if (!out) {
|
|
return;
|
|
}
|
|
(void)fwrite(payload, 1, strlen(payload), out);
|
|
fclose(out);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
char provider_log_stamp[32] = {0};
|
|
unsigned long provider_log_seq = 0;
|
|
int provider_log_has_pair = 0;
|
|
|
|
if (is_post && body && body[0] != '\0') {
|
|
if (provider_log_next_stamp_seq_local(provider_log_stamp,
|
|
sizeof(provider_log_stamp),
|
|
&provider_log_seq) == 0) {
|
|
log_provider_payload_local(body, provider_log_stamp, provider_log_seq, "req");
|
|
provider_log_has_pair = 1;
|
|
}
|
|
}
|
|
|
|
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.body && resp.body_len > 0) {
|
|
if (!provider_log_has_pair) {
|
|
if (provider_log_next_stamp_seq_local(provider_log_stamp,
|
|
sizeof(provider_log_stamp),
|
|
&provider_log_seq) == 0) {
|
|
provider_log_has_pair = 1;
|
|
}
|
|
}
|
|
if (provider_log_has_pair) {
|
|
log_provider_payload_local(resp.body, provider_log_stamp, provider_log_seq, "res");
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|