418 lines
13 KiB
C
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;
|
|
}
|