Files
profanity/src/ai/ai_client.c
Jabber Developer acae543057 feat(ai): add model caching, settings, and commands
- Introduce model caching with persistence to preferences
- Add provider default model and custom settings management
- Implement `/ai switch`, `/ai models`, and improve `/ai start`
- Add model name autocomplete for chat commands
- Update command definitions and help text
- Add unit tests for new functionality
2026-05-09 13:15:58 +00:00

1308 lines
39 KiB
C

/*
* ai_client.c - AI client for interacting with OpenAI-compatible API providers
*
* Supports multiple providers (OpenAI, Perplexity, etc.) with per-provider
* API keys, custom endpoints, and model selection.
*
* Copyright (C) 2026 CProof Developers
*
* SPDX-License-Identifier: GPL-3.0-or-later WITH OpenSSL-exception
*/
// vim: expandtab:ts=4:sts=4:sw=4
#include "ai_client.h"
#include "common.h"
#include "log.h"
#include "config/preferences.h"
#include "profanity.h"
#include "tools/autocomplete.h"
#include "ui/ui.h"
#include "ui/window_list.h"
#include <curl/curl.h>
#include <glib.h>
#include <string.h>
/* Default providers */
#define DEFAULT_OPENAI_URL "https://api.openai.com/"
#define DEFAULT_PERPLEXITY_URL "https://api.perplexity.ai/"
/* Global state */
static GHashTable* providers = NULL;
static GHashTable* provider_keys = NULL;
static Autocomplete providers_ac = NULL;
/* ========================================================================
* Forward declarations
* ======================================================================== */
static void _ai_load_models_for_provider(AIProvider* provider);
static void _ai_save_models_for_provider(AIProvider* provider);
/* ========================================================================
* Curl helpers
* ======================================================================== */
struct curl_response_t
{
gchar* data;
size_t size;
};
static size_t
_write_callback(void* ptr, size_t size, size_t nmemb, void* userdata)
{
size_t realsize = size * nmemb;
struct curl_response_t* res = (struct curl_response_t*)userdata;
/* Limit response size to 10MB to prevent OOM */
if (res->size + realsize > 10 * 1024 * 1024) {
log_error("[AI-THREAD] Response too large, aborting transfer");
return CURL_WRITEFUNC_ERROR;
}
gchar* new_data = g_realloc(res->data, res->size + realsize + 1);
if (!new_data) {
log_error("[AI-THREAD] Failed to allocate memory for response");
return realsize;
}
res->data = new_data;
memcpy(res->data + res->size, ptr, realsize);
res->size += realsize;
res->data[res->size] = '\0';
return realsize;
}
/* ========================================================================
* Thread-safe UI display helpers
* ======================================================================== */
/**
* Validate aiwin pointer by checking if it still exists in the window list.
* Detects if the original window was freed (TOCTOU protection).
* Returns the validated pointer if valid, NULL if window was closed.
*/
static ProfAiWin*
_aiwin_validate(gpointer user_data)
{
if (!user_data) {
return NULL;
}
if (!wins_ai_exists((ProfAiWin*)user_data)) {
log_warning("[AI-THREAD] aiwin=%p no longer exists — window was closed", (void*)user_data);
return NULL;
}
/* Pointer is valid */
return (ProfAiWin*)user_data;
}
/**
* Display an error message in the AI window (thread-safe).
* @param user_data The original user_data pointer (may be NULL)
* @param error_msg The error message to display
*/
static void
_aiwin_display_error(gpointer user_data, const gchar* error_msg)
{
ProfAiWin* aiwin = _aiwin_validate(user_data);
if (!aiwin) {
log_warning("[AI-THREAD] Cannot display error: aiwin is invalid or window was closed (msg: %s)", error_msg);
return;
}
pthread_mutex_lock(&lock);
aiwin_display_error(aiwin, error_msg);
pthread_mutex_unlock(&lock);
}
/**
* Display a response message in the AI window (thread-safe).
* @param user_data The original user_data pointer (may be NULL)
* @param response The response message to display
*/
static void
_aiwin_display_response(gpointer user_data, const gchar* response)
{
ProfAiWin* aiwin = _aiwin_validate(user_data);
if (!aiwin) {
log_warning("[AI-THREAD] Cannot display response: aiwin is invalid or window was closed");
return;
}
pthread_mutex_lock(&lock);
aiwin_display_response(aiwin, response);
pthread_mutex_unlock(&lock);
}
/* ========================================================================
* JSON helpers
* ======================================================================== */
gchar*
ai_json_escape(const gchar* str)
{
if (!str)
return g_strdup("");
GString* result = g_string_new("");
for (const gchar* p = str; *p; p++) {
switch (*p) {
case '"':
g_string_append(result, "\\\"");
break;
case '\\':
g_string_append(result, "\\\\");
break;
case '\b':
g_string_append(result, "\\b");
break;
case '\f':
g_string_append(result, "\\f");
break;
case '\n':
g_string_append(result, "\\n");
break;
case '\r':
g_string_append(result, "\\r");
break;
case '\t':
g_string_append(result, "\\t");
break;
default:
g_string_append_c(result, *p);
break;
}
}
return g_string_free(result, FALSE);
}
/* ========================================================================
* Provider Management
* ======================================================================== */
static AIProvider*
ai_provider_new(const gchar* name, const gchar* api_url, const gchar* org_id)
{
AIProvider* provider = g_new0(AIProvider, 1);
provider->name = g_strdup(name);
provider->api_url = g_strdup(api_url ? api_url : "");
provider->org_id = g_strdup(org_id);
provider->project_id = NULL;
provider->default_model = NULL;
provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
provider->models = NULL;
provider->models_fresh = FALSE;
provider->ref_count = 1;
return provider;
}
static AIProvider*
ai_provider_ref(AIProvider* provider)
{
if (provider) {
g_atomic_int_inc(&provider->ref_count);
}
return provider;
}
void
ai_provider_unref(AIProvider* provider)
{
if (!provider)
return;
if (!g_atomic_int_dec_and_test(&provider->ref_count)) {
return;
}
g_free(provider->name);
g_free(provider->api_url);
g_free(provider->org_id);
g_free(provider->project_id);
g_free(provider->default_model);
g_hash_table_destroy(provider->settings);
GList* curr = provider->models;
while (curr) {
g_free(curr->data);
curr = g_list_next(curr);
}
g_list_free(provider->models);
g_free(provider);
}
static void
ai_load_keys(void)
{
if (!provider_keys) {
provider_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
}
GList* tokens = prefs_ai_list_tokens();
if (!tokens) {
return;
}
GList* curr = tokens;
while (curr) {
gchar* provider = (gchar*)curr->data;
gchar* key = prefs_ai_get_token(provider);
if (key && strlen(key) > 0) {
g_hash_table_insert(provider_keys, g_strdup(provider), g_strdup(key));
}
g_free(key);
curr = g_list_next(curr);
}
prefs_free_ai_tokens(tokens);
log_info("Loaded %d saved API keys from config", g_hash_table_size(provider_keys));
}
void
ai_client_init(void)
{
if (providers)
return; /* Already initialized */
curl_global_init(CURL_GLOBAL_ALL);
/* Create hash tables */
providers = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, (GDestroyNotify)ai_provider_unref);
provider_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
/* Create autocomplete for provider names */
providers_ac = autocomplete_new();
/* Add default providers */
ai_add_provider("openai", DEFAULT_OPENAI_URL, NULL);
ai_add_provider("perplexity", DEFAULT_PERPLEXITY_URL, NULL);
/* Load saved API keys from config */
ai_load_keys();
/* Load cached models for all providers */
GList* provider_list = ai_list_providers();
GList* curr = provider_list;
while (curr) {
AIProvider* provider = (AIProvider*)curr->data;
_ai_load_models_for_provider(provider);
curr = g_list_next(curr);
}
g_list_free(provider_list);
log_info("AI client initialized with default providers: openai, perplexity");
}
void
ai_client_shutdown(void)
{
if (!providers)
return;
g_hash_table_destroy(providers);
g_hash_table_destroy(provider_keys);
providers = NULL;
provider_keys = NULL;
if (providers_ac) {
autocomplete_free(providers_ac);
providers_ac = NULL;
}
curl_global_cleanup();
log_info("AI client shutdown");
}
AIProvider*
ai_get_provider(const gchar* name)
{
if (!name || !providers)
return NULL;
return g_hash_table_lookup(providers, name);
}
AIProvider*
ai_add_provider(const gchar* name, const gchar* api_url, const gchar* org_id)
{
if (!name || !api_url)
return NULL;
if (!providers) {
ai_client_init();
}
/* Check if provider already exists */
AIProvider* existing = g_hash_table_lookup(providers, name);
if (existing) {
/* Update existing provider */
g_free(existing->api_url);
existing->api_url = g_strdup(api_url);
g_free(existing->org_id);
existing->org_id = g_strdup(org_id);
log_info("Updated provider: %s", name);
return ai_provider_ref(existing);
}
/* Create new provider (ref_count=1 owned by hash table) */
AIProvider* provider = ai_provider_new(name, api_url, org_id);
g_hash_table_insert(providers, g_strdup(name), provider);
/* Sync autocomplete */
autocomplete_add(providers_ac, name);
log_info("Added provider: %s (URL: %s)", name, api_url);
return provider; /* Caller gets non-owning pointer; hash table owns ref */
}
gboolean
ai_remove_provider(const gchar* name)
{
if (!name || !providers)
return FALSE;
/* Sync autocomplete before removing */
autocomplete_remove(providers_ac, name);
return g_hash_table_remove(providers, name);
}
GList*
ai_list_providers(void)
{
if (!providers)
return NULL;
GList* result = NULL;
GHashTableIter iter;
gpointer key, value;
g_hash_table_iter_init(&iter, providers);
while (g_hash_table_iter_next(&iter, &key, &value)) {
result = g_list_append(result, value);
}
return result;
}
/* ========================================================================
* Provider autocomplete state
* ======================================================================== */
/* Stateful provider name finder with case-sensitive matching.
* Maintains position in sorted list for deterministic tab-completion cycling.
* The autocomplete is kept in sync via ai_add_provider/ai_remove_provider. */
gchar*
ai_providers_find(const char* const search_str, gboolean previous, void* context)
{
/* Initialize autocomplete on first use */
if (!providers_ac) {
providers_ac = autocomplete_new();
}
/* NULL search_str is treated as empty string for cycling */
const char* effective_search = (search_str != NULL) ? search_str : "";
/* Use stateful autocomplete */
return autocomplete_complete(providers_ac, effective_search, FALSE, previous);
}
/* Stateful model name finder for autocomplete.
* Uses the current session's provider models for completion. */
gchar*
ai_models_find(const char* const search_str, gboolean previous, void* context)
{
ProfAiWin* aiwin = (ProfAiWin*)context;
if (!aiwin || !aiwin->session) {
return NULL;
}
AIProvider* provider = aiwin->session->provider;
if (!provider || !provider->models) {
return NULL;
}
/* Create a temporary autocomplete for models */
Autocomplete models_ac = autocomplete_new();
GList* curr = provider->models;
while (curr) {
autocomplete_add(models_ac, curr->data);
curr = g_list_next(curr);
}
/* NULL search_str is treated as empty string for cycling */
const char* effective_search = (search_str != NULL) ? search_str : "";
gchar* result = autocomplete_complete(models_ac, effective_search, FALSE, previous);
autocomplete_free(models_ac);
return result;
}
gchar*
ai_get_provider_key(const gchar* provider_name)
{
if (!provider_name || !provider_keys)
return NULL;
gchar* key = g_hash_table_lookup(provider_keys, provider_name);
return g_strdup(key);
}
void
ai_set_provider_key(const gchar* provider_name, const gchar* api_key)
{
if (!provider_name)
return;
if (!provider_keys) {
provider_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
}
if (api_key) {
g_hash_table_insert(provider_keys, g_strdup(provider_name), g_strdup(api_key));
/* Persist to config file */
prefs_ai_set_token(provider_name, api_key);
log_info("API key set for provider: %s", provider_name);
} else {
g_hash_table_remove(provider_keys, provider_name);
/* Remove from config file */
prefs_ai_remove_token(provider_name);
log_info("API key removed for provider: %s", provider_name);
}
}
/* ========================================================================
* Provider default model and settings
* ======================================================================== */
void
ai_set_provider_default_model(const gchar* provider_name, const gchar* model)
{
if (!provider_name || !model)
return;
AIProvider* provider = ai_get_provider(provider_name);
if (!provider) {
log_warning("Provider '%s' not found for default model setting", provider_name);
return;
}
g_free(provider->default_model);
provider->default_model = g_strdup(model);
log_info("Default model for provider '%s' set to: %s", provider_name, model);
}
const gchar*
ai_get_provider_default_model(const gchar* provider_name)
{
if (!provider_name)
return NULL;
AIProvider* provider = ai_get_provider(provider_name);
if (!provider)
return NULL;
return provider->default_model;
}
void
ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value)
{
if (!provider_name || !setting)
return;
AIProvider* provider = ai_get_provider(provider_name);
if (!provider) {
log_warning("Provider '%s' not found for setting '%s'", provider_name, setting);
return;
}
if (!provider->settings) {
provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
}
if (value) {
g_hash_table_insert(provider->settings, g_strdup(setting), g_strdup(value));
log_info("Setting '%s' for provider '%s' set to: %s", setting, provider_name, value);
} else {
g_hash_table_remove(provider->settings, setting);
log_info("Setting '%s' removed for provider '%s'", setting, provider_name);
}
}
gchar*
ai_get_provider_setting(const gchar* provider_name, const gchar* setting)
{
if (!provider_name || !setting)
return NULL;
AIProvider* provider = ai_get_provider(provider_name);
if (!provider || !provider->settings)
return NULL;
gchar* value = g_hash_table_lookup(provider->settings, setting);
return g_strdup(value);
}
/* ========================================================================
* Generic HTTP request handling
* ======================================================================== */
typedef void (*ai_response_handler_t)(AIProvider* provider, const gchar* response, gpointer user_data);
typedef struct
{
gchar* provider_name;
gpointer user_data;
gchar* request_url;
ai_response_handler_t handler;
} ai_request_t;
/**
* Build curl headers with auth and optional org header.
* Returns headers list (caller must free with curl_slist_free_all).
*/
static struct curl_slist*
_build_curl_headers(AIProvider* provider, const gchar* api_key)
{
struct curl_slist* headers = NULL;
auto_gchar gchar* auth_header = g_strdup_printf("Authorization: Bearer %s", api_key);
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = curl_slist_append(headers, auth_header);
if (provider && provider->org_id && strlen(provider->org_id) > 0) {
auto_gchar gchar* org_header = g_strdup_printf("OpenAI-Organization: %s", provider->org_id);
headers = curl_slist_append(headers, org_header);
}
return headers;
}
/**
* Execute a curl request and call handler on success.
* Returns CURLcode.
*/
static CURLcode
_curl_exec_and_handle(CURL* curl, const gchar* url, struct curl_slist* headers,
struct curl_response_t* response, ai_response_handler_t handler,
AIProvider* provider, gpointer user_data)
{
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, _write_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, response);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
CURLcode res = curl_easy_perform(curl);
long http_code = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
if (res != CURLE_OK) {
auto_gchar gchar* error_msg = g_strdup(curl_easy_strerror(res));
log_error("Request failed for '%s': %s", provider->name, error_msg);
_aiwin_display_error(user_data, error_msg);
} else if (http_code >= 400) {
auto_gchar gchar* error_msg = g_strdup_printf("HTTP %ld: %s", http_code,
response->data ? response->data : "Unknown error");
log_error("Request HTTP error for '%s': %s", provider->name, error_msg);
_aiwin_display_error(user_data, error_msg);
} else {
handler(provider, response->data, user_data);
}
return res;
}
static gpointer
_ai_generic_request_thread(gpointer data)
{
ai_request_t* req = (ai_request_t*)data;
AIProvider* provider = ai_get_provider(req->provider_name);
if (!provider) {
log_error("Provider '%s' not found for request", req->provider_name);
g_free(req->provider_name);
g_free(req->request_url);
g_free(req);
return NULL;
}
CURL* curl = curl_easy_init();
if (!curl) {
log_error("Failed to initialize curl for %s", req->provider_name);
g_free(req->provider_name);
g_free(req->request_url);
g_free(req);
return NULL;
}
auto_gchar gchar* api_key = ai_get_provider_key(req->provider_name);
if (!api_key || strlen(api_key) == 0) {
auto_gchar gchar* error_msg = g_strdup_printf("No API key set for provider '%s'.", req->provider_name);
_aiwin_display_error(req->user_data, error_msg);
curl_easy_cleanup(curl);
g_free(req->provider_name);
g_free(req->request_url);
g_free(req);
return NULL;
}
struct curl_slist* headers = _build_curl_headers(provider, api_key);
struct curl_response_t response;
response.data = g_new0(gchar, 1);
response.size = 0;
_curl_exec_and_handle(curl, req->request_url, headers, &response, req->handler, provider, req->user_data);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
g_free(response.data);
g_free(req->provider_name);
g_free(req->request_url);
g_free(req);
return NULL;
}
/* ========================================================================
* Model fetching
* ======================================================================== */
gboolean
ai_models_are_fresh(const gchar* provider_name)
{
if (!provider_name)
return FALSE;
AIProvider* provider = ai_get_provider(provider_name);
if (!provider)
return FALSE;
return provider->models_fresh;
}
static gboolean
_model_exists(AIProvider* provider, const gchar* model)
{
GList* curr = provider->models;
while (curr) {
if (g_strcmp0(curr->data, model) == 0) {
return TRUE;
}
curr = g_list_next(curr);
}
return FALSE;
}
static void
_add_model_if_new(AIProvider* provider, const gchar* model)
{
if (!_model_exists(provider, model)) {
provider->models = g_list_append(provider->models, g_strdup(model));
}
}
/* ========================================================================
* Model cache persistence
* ======================================================================== */
/**
* Load cached models for a provider from preferences.
*/
static void
_ai_load_models_for_provider(AIProvider* provider)
{
if (!provider)
return;
GList* models = prefs_ai_get_models(provider->name);
if (!models) {
log_info("No cached models for provider '%s'", provider->name);
return;
}
/* Add loaded models to provider */
GList* curr = models;
while (curr) {
_add_model_if_new(provider, curr->data);
curr = g_list_next(curr);
}
prefs_free_ai_models(models);
if (provider->models) {
provider->models_fresh = TRUE;
log_info("Loaded %d cached models for provider '%s'", g_list_length(provider->models), provider->name);
}
}
/**
* Save models for a provider to preferences.
*/
static void
_ai_save_models_for_provider(AIProvider* provider)
{
if (!provider || !provider->models)
return;
/* Build array of model strings */
gint count = g_list_length(provider->models);
gchar** models = g_new0(gchar*, count);
GList* curr = provider->models;
gint i = 0;
while (curr) {
models[i++] = curr->data;
curr = g_list_next(curr);
}
prefs_ai_set_models(provider->name, (const gchar* const*)models, count);
g_free(models);
log_debug("Saved %d models for provider '%s' to prefs", count, provider->name);
}
static const gchar*
_find_json_field(const gchar* json, const gchar* field)
{
auto_gchar gchar* key = g_strdup_printf("\"%s\":\"", field);
const gchar* pos = strstr(json, key);
if (!pos) {
return NULL;
}
return pos + strlen(key);
}
static void
_parse_openai_models(AIProvider* provider, const gchar* json)
{
/* Format: {"data": [{"id": "model1"}, {"id": "model2"}, ...]} */
const gchar* data_start = _find_json_field(json, "data");
if (!data_start) {
return;
}
const gchar* obj_start = strstr(data_start, "{\"id\":");
while (obj_start) {
const gchar* id_start = _find_json_field(obj_start, "id");
if (!id_start) {
break;
}
const gchar* id_end = strchr(id_start, '"');
if (!id_end) {
break;
}
auto_gchar gchar* model = g_strndup(id_start, id_end - id_start);
_add_model_if_new(provider, model);
obj_start = strstr(id_end, "{\"id\":");
if (obj_start) {
obj_start++;
}
}
}
static void
_parse_array_models(AIProvider* provider, const gchar* json)
{
/* Format: ["model1", "model2", ...] */
const gchar* arr_start = strchr(json, '[');
if (!arr_start) {
return;
}
const gchar* model_start = strchr(arr_start, '"');
while (model_start) {
const gchar* model_end = strchr(model_start + 1, '"');
if (!model_end) {
break;
}
auto_gchar gchar* model = g_strndup(model_start + 1, model_end - model_start - 1);
_add_model_if_new(provider, model);
model_start = strchr(model_end + 1, '"');
}
}
static void
_parse_and_cache_models(AIProvider* provider, const gchar* response_json)
{
if (!provider || !response_json) {
return;
}
/* Try OpenAI format first: {"data": [{"id": "model1"}, ...]} */
_parse_openai_models(provider, response_json);
/* If no models found, try simple array format: ["model1", ...] */
if (!provider->models) {
_parse_array_models(provider, response_json);
}
provider->models_fresh = TRUE;
log_info("Cached %d models for provider '%s'", g_list_length(provider->models), provider->name);
/* Persist models to preferences */
_ai_save_models_for_provider(provider);
}
static void
_models_response_handler(AIProvider* provider, const gchar* response, gpointer user_data)
{
_parse_and_cache_models(provider, response);
auto_gchar gchar* msg = g_strdup_printf("Cached %d models for provider '%s'. Use '/ai start <provider> <model>' to start a chat.",
g_list_length(provider->models), provider->name);
_aiwin_display_response(user_data, msg);
}
static gpointer
_models_fetch_thread(gpointer data)
{
ai_request_t* req = (ai_request_t*)data;
AIProvider* provider = ai_get_provider(req->provider_name);
if (!provider) {
g_free(req->provider_name);
g_free(req);
return NULL;
}
/* Build URL for listing models */
auto_gchar gchar* base_url = g_strdup_printf("%s%s", provider->api_url,
g_str_has_suffix(provider->api_url, "/") ? "" : "/");
auto_gchar gchar* request_url = g_strdup_printf("%sv1/models", base_url);
req->request_url = g_strdup(request_url);
req->handler = _models_response_handler;
return _ai_generic_request_thread(req);
}
gboolean
ai_fetch_models(const gchar* provider_name, gpointer user_data)
{
if (!provider_name)
return FALSE;
AIProvider* provider = ai_get_provider(provider_name);
if (!provider) {
log_error("Provider '%s' not found for model fetch", provider_name);
return FALSE;
}
/* If models are fresh, just display them */
if (provider->models_fresh && provider->models) {
cons_show("Cached %d models for provider '%s':", g_list_length(provider->models), provider_name);
GList* curr = provider->models;
while (curr) {
cons_show(" %s", (gchar*)curr->data);
curr = g_list_next(curr);
}
cons_show("");
cons_show("Use '/ai models %s --refresh' to fetch fresh models from the API.", provider_name);
return TRUE;
}
/* Fetch models in a thread */
ai_request_t* req = g_new0(ai_request_t, 1);
req->provider_name = g_strdup(provider_name);
req->user_data = user_data;
GThread* thread = g_thread_new("ai_models_fetch", _models_fetch_thread, req);
if (!thread) {
g_free(req->provider_name);
g_free(req);
log_error("Failed to create thread for model fetch");
return FALSE;
}
g_thread_unref(thread);
cons_show("Fetching models for provider '%s'...", provider_name);
return TRUE;
}
/* ========================================================================
* Session Management
* ======================================================================== */
AISession*
ai_session_create(const gchar* provider_name, const gchar* model)
{
if (!provider_name || !model)
return NULL;
AIProvider* provider = ai_get_provider(provider_name);
if (!provider) {
log_error("Provider not found: %s", provider_name);
return NULL;
}
AISession* session = g_new0(AISession, 1);
session->provider_name = g_strdup(provider_name);
session->provider = ai_provider_ref(provider);
session->model = g_strdup(model);
session->api_key = ai_get_provider_key(provider_name);
session->history = NULL;
session->ref_count = 1;
log_info("AI session created: %s/%s", provider_name, model);
return session;
}
AISession*
ai_session_ref(AISession* session)
{
if (session) {
g_atomic_int_inc(&session->ref_count);
}
return session;
}
void
ai_session_unref(AISession* session)
{
if (!session)
return;
if (!g_atomic_int_dec_and_test(&session->ref_count)) {
return;
}
g_free(session->provider_name);
ai_provider_unref(session->provider);
g_free(session->model);
g_free(session->api_key);
GList* curr = session->history;
while (curr) {
AIMessage* msg = curr->data;
g_free(msg->role);
g_free(msg->content);
g_free(msg);
curr = g_list_next(curr);
}
g_list_free(session->history);
g_free(session);
log_debug("AI session destroyed");
}
void
ai_session_add_message(AISession* session, const gchar* role, const gchar* content)
{
if (!session || !role || !content)
return;
AIMessage* msg = g_new0(AIMessage, 1);
msg->role = g_strdup(role);
msg->content = g_strdup(content);
session->history = g_list_append(session->history, msg);
log_debug("Added %s message to session (total: %d)", role, g_list_length(session->history));
}
void
ai_session_clear_history(AISession* session)
{
if (!session)
return;
GList* curr = session->history;
while (curr) {
AIMessage* msg = curr->data;
g_free(msg->role);
g_free(msg->content);
g_free(msg);
curr = g_list_next(curr);
}
g_list_free(session->history);
session->history = NULL;
log_info("AI session history cleared");
}
const gchar*
ai_session_get_model(AISession* session)
{
if (!session)
return NULL;
return session->model;
}
void
ai_session_set_model(AISession* session, const gchar* model)
{
if (!session || !model)
return;
g_free(session->model);
session->model = g_strdup(model);
log_info("Session model changed to: %s", model);
}
/* ========================================================================
* API Request Handling
* ======================================================================== */
static gchar*
_build_json_payload(AISession* session, const gchar* prompt)
{
/* OpenAI-compatible Responses API format:
* {"model": "...", "input": [...], "stream": false, "store": false}
* store:false prevents providers from storing/using requests for training */
GString* messages_json = g_string_new("");
GList* curr = session->history;
while (curr) {
AIMessage* msg = curr->data;
auto_gchar gchar* escaped_content = ai_json_escape(msg->content);
auto_gchar gchar* escaped_role = ai_json_escape(msg->role);
if (messages_json->len > 0) {
g_string_append_c(messages_json, ',');
}
g_string_append_printf(messages_json, "{\"role\":\"%s\",\"content\":\"%s\"}",
escaped_role, escaped_content);
curr = g_list_next(curr);
}
/* Add the new user message */
auto_gchar gchar* escaped_prompt = ai_json_escape(prompt);
if (messages_json->len > 0) {
g_string_append_c(messages_json, ',');
}
g_string_append_printf(messages_json, "{\"role\":\"user\",\"content\":\"%s\"}", escaped_prompt);
auto_gchar gchar* escaped_model = ai_json_escape(session->model);
gchar* json_payload = g_strdup_printf(
"{\"model\":\"%s\",\"input\":[%s],\"stream\":false,\"store\":false}",
escaped_model, messages_json->str);
g_string_free(messages_json, TRUE);
return json_payload;
}
static gchar*
_parse_ai_response(const gchar* response_json)
{
if (!response_json || strlen(response_json) == 0)
return NULL;
/* Try Perplexity /v1/agent format first: look for "text":"..." inside output array
* Response: {"output":[{"content":[{"text":"...","type":"output_text"}]}]} */
const gchar* text_start = strstr(response_json, "\"text\":\"");
if (text_start) {
text_start += strlen("\"text\":\"");
const gchar* p = text_start;
while (*p) {
if (*p == '\\' && *(p + 1) == '"') {
p += 2;
continue;
}
if (*p == '"') {
gsize len = p - text_start;
gchar* result = g_new0(gchar, len + 1);
gchar* out = result;
const gchar* in = text_start;
while (in < p) {
if (*in == '\\' && *(in + 1) == '"') {
*out++ = '"';
in += 2;
} else if (*in == '\\' && *(in + 1) == 'n') {
*out++ = '\n';
in += 2;
} else {
*out++ = *in++;
}
}
*out = '\0';
return result;
}
p++;
}
}
/* Try legacy OpenAI format: "content":"..."
* Response: {"choices":[{"message":{"content":"..."}}]} */
const gchar* content_start = strstr(response_json, "\"content\":\"");
if (!content_start)
return NULL;
content_start += strlen("\"content\":\"");
/* Find the closing quote, accounting for escaped quotes */
const gchar* p = content_start;
while (*p) {
if (*p == '\\' && *(p + 1) == '"') {
/* Escaped quote, skip both characters */
p += 2;
continue;
}
if (*p == '"') {
/* Found unescaped closing quote */
gsize len = p - content_start;
/* Unescape the content: convert \" back to " */
gchar* result = g_new0(gchar, len + 1);
gchar* out = result;
const gchar* in = content_start;
while (in < p) {
if (*in == '\\' && *(in + 1) == '"') {
*out++ = '"';
in += 2;
} else if (*in == '\\' && *(in + 1) == 'n') {
*out++ = '\n';
in += 2;
} else {
*out++ = *in++;
}
}
*out = '\0';
return result;
}
p++;
}
return NULL;
}
static gpointer
_ai_request_thread(gpointer data)
{
log_debug("[AI-THREAD] Starting AI request thread");
/* Data is an array: [0]=session, [1]=prompt, [2]=user_data */
gpointer* args = (gpointer*)data;
AISession* session = (AISession*)args[0];
auto_gchar gchar* prompt = args[1];
gpointer user_data = args[2];
log_debug("[AI-THREAD] Session: %s/%s", session->provider_name, session->model);
log_debug("[AI-THREAD] API key length: %zu", session->api_key ? strlen(session->api_key) : 0);
/* Check for API key first */
if (!session->api_key || strlen(session->api_key) == 0) {
auto_gchar gchar* error_msg = g_strdup_printf("No API key set for provider '%s'. Use '/ai set token %s <key>' to configure.",
session->provider_name, session->provider_name);
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
_aiwin_display_error(user_data, error_msg);
g_free(args);
return NULL;
}
CURL* curl = curl_easy_init();
log_debug("[AI-THREAD] Curl initialized: %s", curl ? "OK" : "FAILED");
if (!curl) {
log_error("AI request failed for %s/%s: Failed to initialize curl",
session->provider_name, session->model);
_aiwin_display_error(user_data, "Failed to initialize curl.");
g_free(args);
return NULL;
}
/* Add user message to history FIRST */
ai_session_add_message(session, "user", prompt);
log_debug("[AI-THREAD] Added user message to history");
/* Build JSON payload (includes the message we just added) */
log_debug("[AI-THREAD] Building JSON payload...");
auto_gchar gchar* json_payload = _build_json_payload(session, prompt);
log_debug("[AI-THREAD] JSON payload: %s", json_payload);
/* Build headers using shared helper */
struct curl_slist* headers = _build_curl_headers(session->provider, session->api_key);
/* Response buffer */
struct curl_response_t response;
response.data = g_new0(gchar, 1);
response.size = 0;
/* Build request URL */
const gchar* api_url = session->provider ? session->provider->api_url : DEFAULT_OPENAI_URL;
auto_gchar gchar* request_url = g_strdup_printf("%s%sv1/responses", api_url, g_str_has_suffix(api_url, "/") ? "" : "/");
log_debug("[AI-THREAD] API URL: %s", api_url);
log_debug("[AI-THREAD] API Request URL: %s", request_url);
log_debug("[AI-THREAD] Model: %s", session->model);
/* Execute request with POST fields */
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_payload);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L);
CURLcode res = curl_easy_perform(curl);
log_debug("[AI-THREAD] curl_easy_perform completed, res: %d", res);
/* Get HTTP response code */
long http_code = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
log_debug("[AI-THREAD] HTTP response code: %ld", http_code);
if (res != CURLE_OK) {
auto_gchar gchar* error_msg = g_strdup(curl_easy_strerror(res));
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
_aiwin_display_error(user_data, error_msg);
} else if (http_code >= 400) {
/* Handle HTTP errors */
log_debug("[AI-THREAD] HTTP error response body (%zu bytes): %s",
response.size, response.data ? response.data : "NULL");
auto_gchar gchar* error_msg = g_strdup_printf("HTTP %ld: %s", http_code,
response.data ? response.data : "Unknown error");
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
_aiwin_display_error(user_data, error_msg);
} else {
/* Parse response - transfer ownership to auto_gchar for cleanup */
log_debug("[AI-THREAD] Raw API response (%zu bytes): %s", response.size, response.data ? response.data : "NULL");
auto_gchar gchar* response_data = response.data;
response.data = NULL;
auto_gchar gchar* content = _parse_ai_response(response_data);
if (content) {
/* Add assistant response to history */
ai_session_add_message(session, "assistant", content);
_aiwin_display_response(user_data, content);
} else {
log_error("AI response parse failed for %s/%s: %.200s...",
session->provider_name, session->model, response_data);
_aiwin_display_error(user_data, "Failed to parse AI response.");
}
}
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
g_free(args);
return NULL;
}
gboolean
ai_send_prompt(AISession* session, const gchar* prompt, gpointer user_data)
{
log_debug("[AI-PROMPT] ENTER: session=%p, prompt='%s', user_data=%p",
(void*)session, prompt, user_data);
if (!session || !prompt) {
log_error("[AI-PROMPT] FAIL: invalid session or prompt");
return FALSE;
}
/* Prepare thread arguments: [0]=session, [1]=prompt, [2]=user_data */
gpointer* args = g_new0(gpointer, 3);
args[0] = ai_session_ref(session);
args[1] = g_strdup(prompt);
args[2] = user_data;
log_debug("[AI-PROMPT] Prepared args, creating thread...");
GThread* thread = g_thread_new("ai_request", _ai_request_thread, args);
if (!thread) {
g_free(args[1]);
ai_session_unref(session);
g_free(args);
log_error("[AI-PROMPT] FAIL: g_thread_new returned NULL");
return FALSE;
}
log_debug("[AI-PROMPT] Thread created successfully: %p", (void*)thread);
g_thread_unref(thread);
return TRUE;
}