Files
sovereign_browser/src/agent_llm.c
T

418 lines
13 KiB
C

/*
* agent_llm.c — OpenAI-compatible LLM HTTP client
*
* Uses libsoup-3.0's synchronous soup_session_send_and_read() to POST
* a chat-completions request to an OpenAI-compatible endpoint, then
* parses the JSON response with cJSON and returns the assistant's
* message (text content + any tool_calls).
*
* A fresh SoupSession is created per call so this is safe to invoke
* from a background thread (libsoup-3.0 sessions are not meant to be
* shared across threads). The GTK main thread is never touched here.
*/
#include "agent_llm.h"
#include "agent_tool_catalog.h"
#include <libsoup/soup.h>
#include <glib.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
/* ── OpenAI tools helper ────────────────────────────────────────────── *
*
* build_tools_list() returns the catalog in MCP format:
* [{"name":...,"description":...,"inputSchema":{...}}, ...]
*
* OpenAI wants each entry wrapped as:
* {"type":"function","function":{"name":...,"description":...,"parameters":{...}}}
*
* We rebuild from tool_defs[] directly so the "parameters" field is a
* fresh cJSON copy (the MCP version uses "inputSchema").
*/
cJSON *agent_llm_build_openai_tools(void) {
cJSON *tools = cJSON_CreateArray();
if (tools == NULL) {
return NULL;
}
for (int i = 0; i < tool_defs_count; i++) {
cJSON *entry = cJSON_CreateObject();
if (entry == NULL) {
cJSON_Delete(tools);
return NULL;
}
cJSON_AddStringToObject(entry, "type", "function");
cJSON *fn = cJSON_CreateObject();
if (fn == NULL) {
cJSON_Delete(entry);
cJSON_Delete(tools);
return NULL;
}
cJSON_AddStringToObject(fn, "name", tool_defs[i].name);
cJSON_AddStringToObject(fn, "description", tool_defs[i].description);
cJSON *schema = cJSON_Parse(tool_defs[i].schema_json);
if (schema) {
cJSON_AddItemToObject(fn, "parameters", schema);
} else {
/* Fall back to an empty object so the field is always present. */
cJSON_AddItemToObject(fn, "parameters", cJSON_CreateObject());
}
cJSON_AddItemToObject(entry, "function", fn);
cJSON_AddItemToArray(tools, entry);
}
return tools;
}
/* ── Response lifecycle ─────────────────────────────────────────────── */
void agent_llm_response_free(agent_llm_response_t *resp) {
if (resp == NULL) {
return;
}
g_free(resp->content);
if (resp->tool_calls) {
cJSON_Delete(resp->tool_calls);
}
g_free(resp->finish_reason);
g_free(resp);
}
/* ── Internal: build the request body JSON ──────────────────────────── *
*
* Returns a newly-allocated JSON string (caller frees with g_free).
* The caller's messages/tools arrays are referenced (not consumed) —
* cJSON_AddItemReferenceToObject increments the refcount so the caller
* retains ownership.
*
* Returns NULL on allocation failure.
*/
static char *build_request_body(const char *model,
cJSON *messages,
cJSON *tools) {
cJSON *root = cJSON_CreateObject();
if (root == NULL) {
return NULL;
}
cJSON_AddStringToObject(root, "model", model);
/* Reference the caller's array so we don't steal ownership. */
cJSON_AddItemReferenceToObject(root, "messages", messages);
if (tools != NULL) {
cJSON_AddItemReferenceToObject(root, "tools", tools);
cJSON_AddStringToObject(root, "tool_choice", "auto");
}
char *str = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return str;
}
/* ── Internal: parse the response body into agent_llm_response_t ────── *
*
* body is the raw HTTP response body (NUL-terminated by caller).
* Returns a newly-allocated agent_llm_response_t, or NULL on parse
* failure / error response.
*/
static agent_llm_response_t *parse_response(const char *body) {
cJSON *root = cJSON_Parse(body);
if (root == NULL) {
g_printerr("[agent_llm] failed to parse response JSON\n");
return NULL;
}
/* Some servers return {"error": {...}} on failure. */
cJSON *err = cJSON_GetObjectItemCaseSensitive(root, "error");
if (err) {
char *err_str = cJSON_PrintUnformatted(err);
g_printerr("[agent_llm] API error: %s\n",
err_str ? err_str : "(unknown)");
cJSON_free(err_str);
cJSON_Delete(root);
return NULL;
}
cJSON *choices = cJSON_GetObjectItemCaseSensitive(root, "choices");
cJSON *choice0 = cJSON_GetArrayItem(choices, 0);
if (choice0 == NULL) {
g_printerr("[agent_llm] response has no choices[0]\n");
cJSON_Delete(root);
return NULL;
}
agent_llm_response_t *resp = g_new0(agent_llm_response_t, 1);
if (resp == NULL) {
cJSON_Delete(root);
return NULL;
}
cJSON *message = cJSON_GetObjectItemCaseSensitive(choice0, "message");
if (message) {
cJSON *content = cJSON_GetObjectItemCaseSensitive(message, "content");
if (cJSON_IsString(content) && content->valuestring != NULL) {
resp->content = g_strdup(content->valuestring);
} else {
resp->content = NULL;
}
cJSON *tool_calls = cJSON_GetObjectItemCaseSensitive(message, "tool_calls");
if (tool_calls != NULL) {
/* Detach a deep copy so the response outlives the parsed root. */
resp->tool_calls = cJSON_Duplicate(tool_calls, 1);
} else {
resp->tool_calls = NULL;
}
}
cJSON *finish = cJSON_GetObjectItemCaseSensitive(choice0, "finish_reason");
if (cJSON_IsString(finish) && finish->valuestring != NULL) {
resp->finish_reason = g_strdup(finish->valuestring);
} else {
resp->finish_reason = NULL;
}
cJSON_Delete(root);
return resp;
}
/* ── Internal: normalize base URL ───────────────────────────────────── *
*
* Many OpenAI-compatible APIs expect the path prefix "/v1" before the
* endpoint (e.g. https://api.openai.com/v1/models). If the user supplies
* a base URL that already ends with "/v1" (or another "/vN" version
* segment) we leave it alone; otherwise we append "/v1" so that
* {base}/models and {base}/chat/completions resolve correctly.
*
* Returns a newly-allocated string (caller frees with g_free).
*/
static char *normalize_base_url(const char *base_url) {
if (base_url == NULL) {
return NULL;
}
/* Strip trailing slashes for consistent checking. */
g_autofree char *trimmed = NULL;
{
size_t len = strlen(base_url);
while (len > 0 && base_url[len - 1] == '/') {
len--;
}
trimmed = g_strndup(base_url, len);
}
size_t tlen = strlen(trimmed);
/* Already ends with "/v1"? */
if (tlen >= 3 && strcmp(trimmed + tlen - 3, "/v1") == 0) {
return g_strdup(trimmed);
}
/* Also handle "/v2", "/v3" etc. — if the last path segment is
* "v" followed by one or more digits, assume the user already
* included a version prefix. */
if (tlen > 0) {
const char *slash = strrchr(trimmed, '/');
if (slash != NULL) {
const char *seg = slash + 1;
if (seg[0] == 'v' && seg[1] >= '0' && seg[1] <= '9') {
return g_strdup(trimmed);
}
}
}
return g_strdup_printf("%s/v1", trimmed);
}
/* ── Public: agent_llm_chat ─────────────────────────────────────────── */
agent_llm_response_t *agent_llm_chat(const char *base_url,
const char *api_key,
const char *model,
cJSON *messages,
cJSON *tools) {
if (base_url == NULL || model == NULL || messages == NULL) {
return NULL;
}
/* Build the full URL: {normalized_base_url}/chat/completions.
* normalize_base_url() strips trailing slashes and appends "/v1"
* if needed, so we never produce a "//" here. */
g_autofree char *norm = normalize_base_url(base_url);
g_autofree char *url = g_strdup_printf("%s/chat/completions", norm);
/* Build request body. */
g_autofree char *body = build_request_body(model, messages, tools);
if (body == NULL) {
g_printerr("[agent_llm] failed to build request body\n");
return NULL;
}
/* Create the SoupMessage. */
SoupMessage *msg = soup_message_new("POST", url);
if (msg == NULL) {
g_printerr("[agent_llm] invalid URL: %s\n", url);
return NULL;
}
soup_message_headers_set_content_type(
soup_message_get_request_headers(msg), "application/json", NULL);
if (api_key != NULL && api_key[0] != '\0') {
g_autofree char *bearer = g_strdup_printf("Bearer %s", api_key);
soup_message_headers_append(
soup_message_get_request_headers(msg), "Authorization", bearer);
}
/* Set the request body from a GBytes. libsoup-3.0 takes ownership
* of the GBytes; the g_autofree `body` string is freed at scope exit. */
GBytes *req_bytes = g_bytes_new(body, strlen(body));
soup_message_set_request_body_from_bytes(msg, "application/json", req_bytes);
g_bytes_unref(req_bytes);
/* Send synchronously. A fresh session per call keeps things
* thread-safe without any global state. */
g_print("[agent_llm] POST %s (model=%s, %d messages, %d tools)\n",
url, model, cJSON_GetArraySize(messages),
tools ? cJSON_GetArraySize(tools) : 0);
SoupSession *session = soup_session_new();
/* Set a generous timeout (300s) to accommodate slow CPU-only models
* and cold-start loading. The default libsoup timeout is 60s, which
* is too short for large local models. */
g_object_set(session, "timeout", 300, NULL);
GError *error = NULL;
GBytes *resp_bytes = soup_session_send_and_read(session, msg, NULL, &error);
agent_llm_response_t *result = NULL;
if (error != NULL) {
g_printerr("[agent_llm] HTTP transport error: %s\n",
error->message);
g_error_free(error);
} else {
guint status = soup_message_get_status(msg);
if (status != SOUP_STATUS_OK) {
gsize size = 0;
const gchar *data = g_bytes_get_data(resp_bytes, &size);
g_printerr("[agent_llm] HTTP %u: %.*s\n",
status, (int)size, data ? data : "");
} else {
gsize size = 0;
const gchar *data = g_bytes_get_data(resp_bytes, &size);
/* Make a NUL-terminated copy for cJSON. */
g_autofree char *body_str = g_strndup(data ? data : "", size);
result = parse_response(body_str);
}
}
if (resp_bytes) {
g_bytes_unref(resp_bytes);
}
g_object_unref(msg);
g_object_unref(session);
return result;
}
/* ── Public: agent_llm_list_models ──────────────────────────────────── *
*
* Fetch the list of available models from an OpenAI-compatible API.
* Calls GET {base_url}/models with an Authorization: Bearer header
* (if api_key is non-NULL and non-empty). Parses the "data" array and
* returns a cJSON array of model ID strings. Returns NULL on error.
* Caller must cJSON_Delete() the returned array.
*/
cJSON *agent_llm_list_models(const char *base_url, const char *api_key) {
if (base_url == NULL || base_url[0] == '\0') {
return NULL;
}
/* Build the full URL: {normalized_base_url}/models.
* normalize_base_url() strips trailing slashes and appends "/v1"
* if needed, so we never produce a "//" here. */
g_autofree char *norm = normalize_base_url(base_url);
g_autofree char *url = g_strdup_printf("%s/models", norm);
SoupMessage *msg = soup_message_new("GET", url);
if (msg == NULL) {
g_printerr("[agent_llm] invalid URL: %s\n", url);
return NULL;
}
if (api_key != NULL && api_key[0] != '\0') {
g_autofree char *bearer = g_strdup_printf("Bearer %s", api_key);
soup_message_headers_append(
soup_message_get_request_headers(msg), "Authorization", bearer);
}
SoupSession *session = soup_session_new();
GError *error = NULL;
GBytes *resp_bytes = soup_session_send_and_read(session, msg, NULL, &error);
cJSON *models = NULL;
if (error != NULL) {
g_printerr("[agent_llm] list_models transport error: %s\n",
error->message);
g_error_free(error);
} else {
guint status = soup_message_get_status(msg);
if (status != SOUP_STATUS_OK) {
gsize size = 0;
const gchar *data = g_bytes_get_data(resp_bytes, &size);
g_printerr("[agent_llm] list_models HTTP %u: %.*s\n",
status, (int)size, data ? data : "");
} else {
gsize size = 0;
const gchar *data = g_bytes_get_data(resp_bytes, &size);
g_autofree char *body_str = g_strndup(data ? data : "", size);
cJSON *root = cJSON_Parse(body_str);
if (root == NULL) {
g_printerr("[agent_llm] list_models: failed to parse JSON\n");
} else {
cJSON *err = cJSON_GetObjectItemCaseSensitive(root, "error");
if (err) {
g_printerr("[agent_llm] list_models: API returned error\n");
} else {
cJSON *data_arr = cJSON_GetObjectItemCaseSensitive(root, "data");
if (cJSON_IsArray(data_arr)) {
models = cJSON_CreateArray();
if (models != NULL) {
cJSON *entry;
cJSON_ArrayForEach(entry, data_arr) {
cJSON *id = cJSON_GetObjectItemCaseSensitive(entry, "id");
if (cJSON_IsString(id) && id->valuestring != NULL) {
cJSON_AddItemToArray(models,
cJSON_CreateString(id->valuestring));
}
}
/* If no IDs were found, treat as error. */
if (cJSON_GetArraySize(models) == 0) {
cJSON_Delete(models);
models = NULL;
}
}
}
}
cJSON_Delete(root);
}
}
}
if (resp_bytes) {
g_bytes_unref(resp_bytes);
}
g_object_unref(msg);
g_object_unref(session);
return models;
}