Some checks failed
CI Code / Check spelling (pull_request) Successful in 23s
CI Code / Check coding style (pull_request) Successful in 35s
CI Code / Code Coverage (pull_request) Successful in 2m28s
CI Code / Linux (debian) (pull_request) Failing after 6m18s
CI Code / Linux (arch) (pull_request) Failing after 8m46s
CI Code / Linux (ubuntu) (pull_request) Failing after 11m4s
- 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
1308 lines
39 KiB
C
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;
|
|
}
|