diff --git a/Makefile.am b/Makefile.am index ccdecb81..3de888ee 100644 --- a/Makefile.am +++ b/Makefile.am @@ -37,6 +37,7 @@ core_sources = \ src/ui/window_list.c src/ui/window_list.h \ src/ui/rosterwin.c src/ui/occupantswin.c \ src/ui/buffer.c src/ui/buffer.h \ + src/ui/aiwin.c \ src/ui/chatwin.c \ src/ui/mucwin.c \ src/ui/privwin.c \ @@ -78,7 +79,8 @@ core_sources = \ src/plugins/themes.c src/plugins/themes.h \ src/plugins/settings.c src/plugins/settings.h \ src/plugins/disco.c src/plugins/disco.h \ - src/ui/tray.h src/ui/tray.c + src/ui/tray.h src/ui/tray.c \ + src/ai/ai_client.h src/ai/ai_client.c unittest_sources = \ src/xmpp/contact.c src/xmpp/contact.h src/common.c \ @@ -89,6 +91,7 @@ unittest_sources = \ src/xmpp/chat_state.h src/xmpp/chat_state.c \ src/xmpp/roster_list.c src/xmpp/roster_list.h \ src/xmpp/xmpp.h src/xmpp/form.c \ + src/ai/ai_client.h src/ai/ai_client.c \ src/ui/ui.h \ src/otr/otr.h \ src/pgp/gpg.h \ @@ -138,6 +141,7 @@ unittest_sources = \ tests/unittests/xmpp/stub_message.c \ tests/unittests/ui/stub_ui.c tests/unittests/ui/stub_ui.h \ tests/unittests/ui/stub_vcardwin.c \ + tests/unittests/ui/stub_ai.c \ tests/unittests/log/stub_log.c \ tests/unittests/chatlog/stub_chatlog.c \ tests/unittests/database/stub_database.c \ @@ -174,6 +178,7 @@ unittest_sources = \ tests/unittests/test_callbacks.c tests/unittests/test_callbacks.h \ tests/unittests/test_plugins_disco.c tests/unittests/test_plugins_disco.h \ tests/unittests/test_forced_encryption.c tests/unittests/test_forced_encryption.h \ + tests/unittests/test_ai_client.c tests/unittests/test_ai_client.h \ tests/unittests/test_database_export.c tests/unittests/test_database_export.h \ tests/unittests/test_database_stress.c tests/unittests/test_database_stress.h \ tests/unittests/unittests.c @@ -198,6 +203,7 @@ functionaltest_sources = \ tests/functionaltests/test_autoping.c tests/functionaltests/test_autoping.h \ tests/functionaltests/test_disco.c tests/functionaltests/test_disco.h \ tests/functionaltests/test_export_import.c tests/functionaltests/test_export_import.h \ + tests/functionaltests/test_ai.c tests/functionaltests/test_ai.h \ tests/functionaltests/functionaltests.c main_source = src/main.c diff --git a/src/ai/ai_client.c b/src/ai/ai_client.c new file mode 100644 index 00000000..b5adde4f --- /dev/null +++ b/src/ai/ai_client.c @@ -0,0 +1,1575 @@ +/* + * 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 +#include +#include +#include + +/* Global state */ +static GHashTable* providers = NULL; +static GHashTable* provider_keys = NULL; +static Autocomplete providers_ac = NULL; + +/* ======================================================================== + * Forward declarations + * ======================================================================== */ +static AIProvider* _ai_add_provider_nopersist(const gchar* name, const gchar* api_url); +static void _ai_load_models_for_provider(AIProvider* provider); +static void _ai_save_models_for_provider(AIProvider* provider); +static const gchar* _find_json_field(const gchar* json, const gchar* field); +static gchar* _extract_json_string(const gchar* json, const gchar* field); + +/* ======================================================================== + * 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; + } + + gboolean win_exists = wins_ai_exists((ProfAiWin*)user_data); + if (!win_exists) { + 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) +{ + pthread_mutex_lock(&lock); + 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); + pthread_mutex_unlock(&lock); + return; + } + aiwin_display_error(aiwin, error_msg); + pthread_mutex_unlock(&lock); +} + +/** + * Display a response message. If aiwin is provided and valid, display in AI window. + * Otherwise, display in console (for commands run from console). + * @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) +{ + if (!response || strlen(response) == 0) { + return; + } + + pthread_mutex_lock(&lock); + ProfAiWin* aiwin = _aiwin_validate(user_data); + if (!aiwin) { + cons_show("%s", response); + pthread_mutex_unlock(&lock); + return; + } + + 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); +} + +/* Decode JSON string escapes in [start, end) into a freshly allocated buffer. + * Handles \" \\ \/ \n \t \r \b \f. \uXXXX sequences are passed through verbatim + * since the rest of the AI pipeline already deals in UTF-8 byte streams. */ +static gchar* +_json_unescape_substring(const gchar* start, const gchar* end) +{ + gchar* out = g_new0(gchar, (end - start) + 1); + gchar* w = out; + for (const gchar* in = start; in < end;) { + if (*in == '\\' && in + 1 < end) { + switch (in[1]) { + case '"': + *w++ = '"'; + in += 2; + break; + case '\\': + *w++ = '\\'; + in += 2; + break; + case '/': + *w++ = '/'; + in += 2; + break; + case 'n': + *w++ = '\n'; + in += 2; + break; + case 't': + *w++ = '\t'; + in += 2; + break; + case 'r': + *w++ = '\r'; + in += 2; + break; + case 'b': + *w++ = '\b'; + in += 2; + break; + case 'f': + *w++ = '\f'; + in += 2; + break; + default: + *w++ = *in++; + break; + } + } else { + *w++ = *in++; + } + } + *w = '\0'; + return out; +} + +/* Locate "field":"...value..." in json and return the unescaped value. + * Skips JSON whitespace around the colon and treats any \X (X != \0) inside + * the string body as an opaque escape so an embedded \" cannot be misread as + * the closing quote. */ +static gchar* +_extract_json_string(const gchar* json, const gchar* field) +{ + const gchar* val = _find_json_field(json, field); + if (!val || *val != '"') + return NULL; + + const gchar* start = val + 1; + for (const gchar* p = start; *p; p++) { + if (*p == '\\' && *(p + 1) != '\0') { + p++; /* skip the escaped character; loop ++ advances past it */ + continue; + } + if (*p == '"') { + return _json_unescape_substring(start, p); + } + } + return NULL; +} + +/* ======================================================================== + * Provider Management + * ======================================================================== */ + +static AIProvider* +ai_provider_new(const gchar* name, const gchar* api_url) +{ + AIProvider* provider = g_new0(AIProvider, 1); + provider->name = g_strdup(name); + provider->api_url = g_strdup(api_url ? api_url : ""); + 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; +} + +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->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(); + + /* Get all providers (defaults if nothing configured, otherwise config values) */ + GList* all_providers = prefs_ai_get_providers(); + for (GList* curr = all_providers; curr; curr = g_list_next(curr)) { + /* List contains pairs: name, url, name, url, ... */ + gchar* name = (gchar*)curr->data; + curr = g_list_next(curr); + if (!curr) + break; + gchar* url = (gchar*)curr->data; + _ai_add_provider_nopersist(name, url); + } + prefs_free_ai_providers(all_providers); + + /* Load saved API keys from config */ + ai_load_keys(); + + /* Load cached models, persisted default-model and custom settings for + * all providers. */ + GList* provider_list = ai_list_providers(); + for (GList* curr = provider_list; curr; curr = g_list_next(curr)) { + AIProvider* provider = (AIProvider*)curr->data; + _ai_load_models_for_provider(provider); + + auto_gchar gchar* dm = prefs_ai_get_default_model(provider->name); + if (dm && dm[0] != '\0') { + g_free(provider->default_model); + provider->default_model = g_strdup(dm); + } + + GList* settings = prefs_ai_list_settings(provider->name); + for (GList* s = settings; s; s = g_list_next(s)) { + const gchar* name = (const gchar*)s->data; + auto_gchar gchar* val = prefs_ai_get_setting(provider->name, name); + if (val) { + if (!provider->settings) { + provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); + } + g_hash_table_insert(provider->settings, g_strdup(name), g_strdup(val)); + } + } + prefs_free_ai_settings(settings); + } + g_list_free(provider_list); + + log_info("AI client initialized with providers from preferences"); +} + +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); +} + +/* Internal helper to add provider without persisting to config. + * Used for default providers and loading from config on startup. */ +static AIProvider* +_ai_add_provider_nopersist(const gchar* name, const gchar* api_url) +{ + /* Check if provider already exists */ + AIProvider* existing = g_hash_table_lookup(providers, name); + if (existing) { + g_free(existing->api_url); + existing->api_url = g_strdup(api_url); + return ai_provider_ref(existing); + } + + /* Create new provider (ref_count=1 owned by hash table) */ + AIProvider* provider = ai_provider_new(name, api_url); + g_hash_table_insert(providers, g_strdup(name), provider); + + /* Sync autocomplete */ + autocomplete_add(providers_ac, name); + + return provider; +} + +AIProvider* +ai_add_provider(const gchar* name, const gchar* api_url) +{ + 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); + /* Persist the updated URL to config */ + prefs_ai_set_provider(name, api_url); + log_info("Updated provider: %s", name); + return ai_provider_ref(existing); + } + + /* Persist the new provider to config */ + prefs_ai_set_provider(name, api_url); + + /* Add to hash table (reuse internal helper) */ + AIProvider* provider = _ai_add_provider_nopersist(name, api_url); + + 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; + + /* Wipe the whole [ai/] subsection from config — url, key, + * default_model, models, custom settings — in one call. */ + prefs_ai_remove_section(name); + + /* 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 + * ======================================================================== */ + +gchar* +ai_providers_find(const char* const search_str, gboolean previous, void* context) +{ + if (!providers_ac) { + log_debug("[AI-AC] Uninitialized call to ai_providers_find"); + return NULL; + } + + return autocomplete_complete(providers_ac, search_str, FALSE, previous); +} + +/* Reset the provider autocomplete state. + * Called from cmd_ac_reset() to clear autocomplete state when the user presses Ctrl+C. */ +void +ai_providers_reset_ac(void) +{ + if (!providers_ac) { + return; + } + autocomplete_reset(providers_ac); +} + +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); + prefs_ai_set_default_model(provider_name, 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)); + prefs_ai_set_setting(provider_name, setting, value); + log_info("Setting '%s' for provider '%s' set to: %s", setting, provider_name, value); + } else { + g_hash_table_remove(provider->settings, setting); + prefs_ai_remove_setting(provider_name, 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. + * 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); + + 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* parsed = ai_parse_error_message(response->data); + auto_gchar gchar* error_msg = parsed + ? g_strdup_printf("HTTP %ld: %s", http_code, parsed) + : g_strdup_printf("HTTP %ld: %s", http_code, + response->data && strlen(response->data) > 0 ? 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; + } + + /* Keep the provider alive for the duration of this request. Without this, /ai remove provider X could free the provider + * struct (via the hash table's destroy-notify) while curl_easy_perform is still using it — classic UAF. */ + ai_provider_ref(provider); + + 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); + + /* Release the reference taken after lookup — provider may now be freed. */ + ai_provider_unref(provider); + + 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); +} + +/** + * Find a JSON field value, handling both string and non-string values. + * For string values: "field":"value" -> returns pointer to "value" + * For array values: "field":[ -> returns pointer to [ + * For object values: "field":{ -> returns pointer to { + * Handles optional whitespace after colon. + * Returns pointer to the value start, or NULL if not found. + */ +static const gchar* +_find_json_field(const gchar* json, const gchar* field) +{ + if (!json || !field) { + return NULL; + } + + auto_gchar gchar* key = g_strdup_printf("\"%s\"", field); + const gchar* pos = strstr(json, key); + if (!pos) { + return NULL; + } + + /* Skip past the key */ + pos += strlen(key); + + /* Skip whitespace and find the colon */ + while (*pos == ' ' || *pos == '\t' || *pos == '\n' || *pos == '\r') { + pos++; + } + + if (*pos != ':') { + return NULL; + } + pos++; /* skip colon */ + + /* Skip whitespace after colon */ + while (*pos == ' ' || *pos == '\t' || *pos == '\n' || *pos == '\r') { + pos++; + } + + return pos; +} + +/** + * Parse models from OpenAI-compatible API response format. + * Expected format: {"object":"list","data":[{"id":"model1","object":"model",...},...]} + * Extracts only the "id" field from each model object. + */ +static void +_parse_openai_models(AIProvider* provider, const gchar* json) +{ + if (!json || !provider) { + return; + } + + /* Find the "data" array start */ + const gchar* data_start = _find_json_field(json, "data"); + if (!data_start || *data_start != '[') { + log_debug("No data array found in API response"); + return; + } + + /* Find all {"id":"..."} objects within the data array */ + const gchar* obj_start = data_start; + while (obj_start) { + /* Find next "id" field (handle both {"id": and { "id": formats) */ + obj_start = strstr(obj_start, "\"id\""); + if (!obj_start) { + break; + } + + /* Verify this "id" is within an object (preceded by { or ,) */ + const gchar* before_id = obj_start - 1; + while (before_id >= data_start && (*before_id == ' ' || *before_id == '\t' || *before_id == '\n' || *before_id == '\r')) { + before_id--; + } + if (before_id < data_start || (*before_id != '{' && *before_id != ',')) { + /* This "id" is not at the start of an object in the array, skip it */ + obj_start = obj_start + 2; /* move past this "id" */ + continue; + } + + /* Find the colon after "id" */ + const gchar* id_key = obj_start + 4; /* skip "id" */ + /* Skip whitespace */ + while (*id_key == ' ' || *id_key == '\t' || *id_key == '\n' || *id_key == '\r') { + id_key++; + } + if (*id_key != ':') { + obj_start = id_key; + continue; + } + id_key++; /* skip : */ + /* Skip whitespace */ + while (*id_key == ' ' || *id_key == '\t' || *id_key == '\n' || *id_key == '\r') { + id_key++; + } + if (*id_key != '"') { + /* "id" value is not a string, skip */ + obj_start = id_key; + continue; + } + id_key++; /* skip opening quote */ + + /* Find the closing quote (handling escaped quotes) */ + const gchar* id_end = id_key; + while (*id_end && *id_end != '"') { + if (*id_end == '\\' && *(id_end + 1)) { + id_end += 2; /* skip escaped character */ + } else { + id_end++; + } + } + + if (!*id_end) { + break; /* No closing quote found */ + } + + /* Extract the model ID */ + auto_gchar gchar* model = g_strndup(id_key, id_end - id_key); + _add_model_if_new(provider, model); + + /* Move past this object to find the next one */ + obj_start = id_end + 1; + + /* Safety: stop if we've gone past the data array */ + if (obj_start - json > 5 * 1024 * 1024) { + break; /* 5MB safety limit */ + } + } +} + +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); +} + +/** + * Public API: Parse model IDs from JSON response. + * Used by tests and internal model fetching. + */ +void +ai_parse_models_from_json(AIProvider* provider, const gchar* json) +{ + if (!provider || !json) { + return; + } + + /* Clear existing models before parsing */ + GList* curr = provider->models; + while (curr) { + g_free(curr->data); + curr = g_list_next(curr); + } + g_list_free(provider->models); + provider->models = NULL; + + /* Parse using the same logic as the internal handler */ + _parse_and_cache_models(provider, json); +} + +static void +_models_response_handler(AIProvider* provider, const gchar* response, gpointer user_data) +{ + _parse_and_cache_models(provider, response); + + gint count = g_list_length(provider->models); + GString* msg = g_string_new(NULL); + g_string_append_printf(msg, "Cached %d models for provider '%s':", count, provider->name); + for (GList* curr = provider->models; curr; curr = g_list_next(curr)) { + g_string_append_printf(msg, "\n %s", (const gchar*)curr->data); + } + if (count > 0) { + g_string_append_printf(msg, "\nUse '/ai start %s ' to start a chat.", provider->name); + } + + auto_gchar gchar* out = g_string_free(msg, FALSE); + _aiwin_display_response(user_data, out); +} + +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; + } + + /* Always fetch fresh models from API */ + 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 fresh 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); + if (pthread_mutex_init(&session->lock, NULL) != 0) { + log_error("Failed to initialize session mutex"); + g_free(session); + return NULL; + } + 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); + + pthread_mutex_destroy(&session->lock); + + 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; + + pthread_mutex_lock(&session->lock); + AIMessage* msg = g_new0(AIMessage, 1); + msg->role = g_strdup(role); + msg->content = g_strdup(content); + session->history = g_list_append(session->history, msg); + pthread_mutex_unlock(&session->lock); + + 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; + + pthread_mutex_lock(&session->lock); + 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; + pthread_mutex_unlock(&session->lock); + + 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; + + pthread_mutex_lock(&session->lock); + g_free(session->model); + session->model = g_strdup(model); + pthread_mutex_unlock(&session->lock); + log_info("Session model changed to: %s", model); +} + +void +ai_session_switch(AISession* session, const gchar* provider_name, + const gchar* model, gchar* api_key) +{ + if (!session || !provider_name || !model || !api_key) + return; + + AIProvider* provider = ai_get_provider(provider_name); + if (!provider) { + log_warning("Provider '%s' not found for session switch", provider_name); + g_free(api_key); + return; + } + + pthread_mutex_lock(&session->lock); + g_free(session->provider_name); + session->provider_name = g_strdup(provider_name); + + ai_provider_unref(session->provider); + session->provider = ai_provider_ref(provider); + + g_free(session->model); + session->model = g_strdup(model); + + g_free(session->api_key); + session->api_key = g_strdup(api_key); + pthread_mutex_unlock(&session->lock); + + g_free(api_key); + log_info("Session switched to %s/%s", provider_name, model); +} + +/* ======================================================================== + * API Request Handling + * ======================================================================== */ + +/** + * Build JSON payload from a GList of AIMessage nodes and a prompt string. + * This is the thread-safe variant used by _ai_request_thread() which holds + * the session lock while calling it. + * + * @param model The model identifier + * @param history GList of AIMessage* (must be held under session lock) + * @param prompt The new user prompt to append + * @return Newly allocated JSON string (caller must free) + */ +static gchar* +_build_json_payload_from_list(const gchar* model, GList* history, const gchar* prompt) +{ + GString* messages_json = g_string_new(""); + GList* curr = 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(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; +} + +gchar* +ai_parse_response(const gchar* response_json) +{ + if (!response_json || strlen(response_json) == 0) + return NULL; + + /* Perplexity /v1/agent: {"output":[{"content":[{"text":"...","type":"output_text"}]}]} + * Legacy OpenAI: {"choices":[{"message":{"content":"..."}}]} */ + gchar* text = _extract_json_string(response_json, "text"); + if (text) + return text; + + return _extract_json_string(response_json, "content"); +} + +/* Extract the human-readable error.message from a provider error envelope: + * {"error":{"message":"...","type":"...","code":...}} + * Scoped to the substring after "error": so unrelated top-level "message" + * fields do not get picked up. */ +gchar* +ai_parse_error_message(const gchar* error_json) +{ + if (!error_json || strlen(error_json) == 0) + return NULL; + + const gchar* error_obj = strstr(error_json, "\"error\":"); + if (!error_obj) + return NULL; + + return _extract_json_string(error_obj, "message"); +} + +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]; + + /* ===================================================================== + * CRITICAL: Copy all session state under lock to prevent UAF races with + * cmd_ai_switch() and ai_session_clear_history() which mutate session + * fields on the main thread without holding a session ref. + * + * The worker holds a ref to the session (via ai_session_ref in + * ai_send_prompt), which prevents the session struct from being freed, + * but does NOT prevent field mutations (g_free/g_strdup of strings, + * list modifications). We must snapshot all fields atomically. + * ===================================================================== */ + pthread_mutex_lock(&session->lock); + gchar* local_provider_name = g_strdup(session->provider_name); + AIProvider* local_provider = ai_provider_ref(session->provider); + gchar* local_model = g_strdup(session->model); + gchar* local_api_key = g_strdup(session->api_key ? session->api_key : ""); + pthread_mutex_unlock(&session->lock); + + log_debug("[AI-THREAD] Session: %s/%s", local_provider_name, local_model); + log_debug("[AI-THREAD] API key length: %zu", strlen(local_api_key)); + + /* Check for API key first */ + if (!local_api_key || strlen(local_api_key) == 0) { + auto_gchar gchar* error_msg = g_strdup_printf("No API key set for provider '%s'. Use '/ai set token %s ' to configure.", + local_provider_name, local_provider_name); + log_error("AI request failed for %s/%s: %s", local_provider_name, local_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", + local_provider_name, local_model); + _aiwin_display_error(user_data, "Failed to initialize curl."); + g_free(args); + return NULL; + } + + /* Build JSON payload from history + prompt (under lock), then add user message to history */ + log_debug("[AI-THREAD] Building JSON payload..."); + pthread_mutex_lock(&session->lock); + auto_gchar gchar* json_payload = _build_json_payload_from_list(local_model, session->history, prompt); + + /* Add user message to history now (it's in JSON but not yet in history) */ + AIMessage* user_msg = g_new0(AIMessage, 1); + user_msg->role = g_strdup("user"); + user_msg->content = g_strdup(prompt); + session->history = g_list_append(session->history, user_msg); + pthread_mutex_unlock(&session->lock); + log_debug("[AI-THREAD] Added user message to history"); + log_debug("[AI-THREAD] JSON payload: %s", json_payload); + + /* Build headers using shared helper */ + struct curl_slist* headers = _build_curl_headers(local_provider, local_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 = local_provider->api_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", local_model); + + /* Set URL and execute request */ + curl_easy_setopt(curl, CURLOPT_URL, request_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_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", local_provider_name, local_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"); + /* Try to extract the actual error message from the JSON response */ + auto_gchar gchar* parsed_error = ai_parse_error_message(response.data); + auto_gchar gchar* error_msg = parsed_error ? g_strdup_printf("HTTP %ld: %s", http_code, parsed_error) : g_strdup_printf("HTTP %ld: %s", http_code, response.data && strlen(response.data) > 0 ? response.data : "Unknown error"); + log_error("AI request failed for %s/%s: %s", local_provider_name, local_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 = ai_parse_response(response_data); + if (content) { + /* Add assistant response to history (under lock) */ + pthread_mutex_lock(&session->lock); + AIMessage* assistant_msg = g_new0(AIMessage, 1); + assistant_msg->role = g_strdup("assistant"); + assistant_msg->content = g_strdup(content); + session->history = g_list_append(session->history, assistant_msg); + pthread_mutex_unlock(&session->lock); + _aiwin_display_response(user_data, content); + } else { + log_error("AI response parse failed for %s/%s: %.200s...", + local_provider_name, local_model, response_data); + _aiwin_display_error(user_data, "Failed to parse AI response."); + } + } + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + /* Cleanup local copies */ + g_free(local_provider_name); + ai_provider_unref(local_provider); + g_free(local_model); + g_free(local_api_key); + + 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; +} diff --git a/src/ai/ai_client.h b/src/ai/ai_client.h new file mode 100644 index 00000000..98d0903a --- /dev/null +++ b/src/ai/ai_client.h @@ -0,0 +1,291 @@ +#ifndef AI_CLIENT_H +#define AI_CLIENT_H + +#include + +/** + * @brief AI message structure for conversation history. + */ +typedef struct ai_message_t +{ + gchar* role; /* "user" or "assistant" */ + gchar* content; /* Message content */ +} AIMessage; + +/** + * @brief AI provider configuration. + */ +typedef struct ai_provider_t +{ + gchar* name; /* Provider name (e.g., "openai", "perplexity") */ + gchar* api_url; /* API endpoint URL */ + gchar* project_id; /* Optional project ID (for some providers) */ + gchar* default_model; /* Default model for this provider */ + GHashTable* settings; /* Extensible per-provider settings (e.g., tools=enabled, search=disabled) */ + GList* models; /* Cached models (gchar*) */ + gboolean models_fresh; /* Whether models cache is current */ + gint32 ref_count; /* Reference count (atomic) */ +} AIProvider; + +/** + * @brief AI chat session structure. + */ +typedef struct ai_session_t +{ + pthread_mutex_t lock; /* Protects all session fields below */ + gchar* provider_name; /* Provider name */ + AIProvider* provider; /* Provider configuration */ + gchar* model; /* Model identifier (e.g., "gpt-4", "sonar") */ + gchar* api_key; /* API key for this session */ + GList* history; /* Conversation history (GList of AIMessage*) */ + gint32 ref_count; /* Reference count (atomic) */ +} AISession; + +/* ======================================================================== + * Provider Management + * ======================================================================== */ + +/** + * Initialize the AI client and load default providers. + */ +void ai_client_init(void); + +/** + * Shutdown the AI client and free resources. + */ +void ai_client_shutdown(void); + +/** + * Get a provider by name. + * @param name The provider name (e.g., "openai", "perplexity") + * @return AIProvider*, or NULL if not found + */ +AIProvider* ai_get_provider(const gchar* name); + +/** + * Add or update a provider configuration. + * @param name The provider name + * @param api_url The API endpoint URL + * @return New AIProvider* (caller must unref when done) + */ +AIProvider* ai_add_provider(const gchar* name, const gchar* api_url); + +/** + * Remove a provider by name. + * @param name The provider name + * @return TRUE if provider was removed, FALSE if not found + */ +gboolean ai_remove_provider(const gchar* name); + +/** + * Increment the reference count of a provider. + * @param provider The provider to reference + * @return The same provider pointer + */ +AIProvider* ai_provider_ref(AIProvider* provider); + +/** + * Decrement the reference count of a provider. + * @param provider The provider to unreference + */ +void ai_provider_unref(AIProvider* provider); + +/** + * List all configured providers. + * @return GList of AIProvider* (caller must not free the list or providers, + * and must not unref the returned providers) + */ +GList* ai_list_providers(void); + +/** + * Find a provider name for autocomplete. + * @param search_str The search string + * @param previous Whether to go to previous match + * @param context Unused + * @return Provider name, or NULL if not found + */ +gchar* ai_providers_find(const char* const search_str, gboolean previous, void* context); + +/** + * Reset the provider autocomplete state. + * Called from cmd_ac_reset() to clear autocomplete state. + */ +void ai_providers_reset_ac(void); + +/** + * Get the API key for a provider. + * @param provider_name The provider name + * @return The API key, or NULL if not set (caller must free) + */ +gchar* ai_get_provider_key(const gchar* provider_name); + +/** + * Set the API key for a provider. + * @param provider_name The provider name + * @param api_key The API key to set + */ +void ai_set_provider_key(const gchar* provider_name, const gchar* api_key); + +/** + * Set the default model for a provider. + * @param provider_name The provider name + * @param model The default model name + */ +void ai_set_provider_default_model(const gchar* provider_name, const gchar* model); + +/** + * Get the default model for a provider. + * @param provider_name The provider name + * @return The default model name (caller must not free), or NULL if not set + */ +const gchar* ai_get_provider_default_model(const gchar* provider_name); + +/** + * Set a custom setting for a provider. + * @param provider_name The provider name + * @param setting The setting key (e.g., "tools", "search") + * @param value The setting value + */ +void ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value); + +/** + * Get a custom setting for a provider. + * @param provider_name The provider name + * @param setting The setting key + * @return The setting value (caller must free), or NULL if not set + */ +gchar* ai_get_provider_setting(const gchar* provider_name, const gchar* setting); + +/** + * Fetch available models from a provider's API. + * @param provider_name The provider name + * @param user_data User data (ProfAiWin* for UI display) + * @return TRUE if the request was successfully queued, FALSE otherwise + */ +gboolean ai_fetch_models(const gchar* provider_name, gpointer user_data); + +/** + * Check if models cache is fresh for a provider. + * @param provider_name The provider name + * @return TRUE if models are fresh, FALSE otherwise + */ +gboolean ai_models_are_fresh(const gchar* provider_name); + +/* ======================================================================== + * Parsing helpers (exposed for testing) + * ======================================================================== */ + +/** + * Parse model IDs from an OpenAI-compatible API response. + * Expected format: {"object":"list","data":[{"id":"model1",...},...]} + * @param provider The provider to add models to + * @param json The JSON response from the API + */ +void ai_parse_models_from_json(AIProvider* provider, const gchar* json); + +/** + * Extract assistant content from an LLM response body. Tries Perplexity + * /v1/responses "text" first, falls back to OpenAI "content". Decodes + * the \" and \n escape sequences only. + * @param response_json The JSON body from the provider + * @return Newly allocated content string, or NULL if not found (caller frees) + */ +gchar* ai_parse_response(const gchar* response_json); + +/** + * Extract human-readable error message from an API error envelope. + * Expected format: {"error":{"message":"...","type":"...","code":...}} + * Decodes \" \n \\ \t escapes. + * @param error_json The JSON body of the error response + * @return Newly allocated error message, or NULL if not parseable (caller frees) + */ +gchar* ai_parse_error_message(const gchar* error_json); + +/* ======================================================================== + * Session Management + * ======================================================================== */ + +/** + * Create a new AI session with the specified provider and model. + * @param provider_name The provider name (e.g., "openai") + * @param model The model identifier (e.g., "gpt-4") + * @return New AISession*, or NULL on failure + */ +AISession* ai_session_create(const gchar* provider_name, const gchar* model); + +/** + * Increment the reference count of an AI session. + * @param session The session to reference + * @return The same session pointer + */ +AISession* ai_session_ref(AISession* session); + +/** + * Decrement the reference count and free the session when it reaches zero. + * @param session The session to unreference + */ +void ai_session_unref(AISession* session); + +/** + * Add a message to the session history. + * @param session The session + * @param role The message role ("user" or "assistant") + * @param content The message content + */ +void ai_session_add_message(AISession* session, const gchar* role, const gchar* content); + +/** + * Clear the conversation history. + * @param session The session + */ +void ai_session_clear_history(AISession* session); + +/** + * Get the current model for a session. + * @param session The session + * @return The model name (caller must not free) + */ +const gchar* ai_session_get_model(AISession* session); + +/** + * Set the model for a session. + * @param session The session + * @param model The model name + */ +void ai_session_set_model(AISession* session, const gchar* model); + +/** + * Atomically switch session provider, model, and API key. + * All mutations happen under the session lock to prevent races with + * _ai_request_thread() which snapshots session state before making requests. + * + * @param session The session + * @param provider_name New provider name + * @param model New model identifier + * @param api_key New API key (caller must free after calling this) + */ +void ai_session_switch(AISession* session, const gchar* provider_name, + const gchar* model, gchar* api_key); + +/* ======================================================================== + * Request Handling + * ======================================================================== */ + +/** + * Send a prompt to the AI provider asynchronously. + * @param session The AI session containing provider and model + * @param prompt The prompt to send + * @param user_data User data (ProfAiWin* for UI display) + * @return TRUE if the request was successfully queued, FALSE otherwise + */ +gboolean ai_send_prompt(AISession* session, const gchar* prompt, + gpointer user_data); + +/** + * Escape a string for JSON embedding. + * @param str The string to escape + * @return Newly allocated escaped string (caller must free) + */ +gchar* ai_json_escape(const gchar* str); + +#endif /* AI_CLIENT_H */ diff --git a/src/command/cmd_ac.c b/src/command/cmd_ac.c index e1be7656..234083f5 100644 --- a/src/command/cmd_ac.c +++ b/src/command/cmd_ac.c @@ -66,6 +66,8 @@ #include "omemo/omemo.h" #endif +#include "ai/ai_client.h" + static char* _sub_autocomplete(ProfWin* window, const char* const input, gboolean previous); static char* _notify_autocomplete(ProfWin* window, const char* const input, gboolean previous); static char* _theme_autocomplete(ProfWin* window, const char* const input, gboolean previous); @@ -137,6 +139,7 @@ static char* _strophe_autocomplete(ProfWin* window, const char* const input, gbo static char* _adhoc_cmd_autocomplete(ProfWin* window, const char* const input, gboolean previous); static char* _vcard_autocomplete(ProfWin* window, const char* const input, gboolean previous); static char* _force_encryption_autocomplete(ProfWin* window, const char* const input, gboolean previous); +static char* _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous); static char* _script_autocomplete_func(const char* const prefix, gboolean previous, void* context); @@ -280,11 +283,16 @@ static Autocomplete history_switch_ac; static Autocomplete color_ac; static Autocomplete correction_ac; static Autocomplete avatar_ac; +static Autocomplete ai_subcommands_ac; +static Autocomplete ai_set_subcommands_ac; +static Autocomplete ai_set_custom_subcommands_ac; +static Autocomplete ai_remove_subcommands_ac; static Autocomplete url_ac; static Autocomplete executable_ac; static Autocomplete executable_param_ac; static Autocomplete intype_ac; static Autocomplete mood_ac; +static Autocomplete ai_models_ac; static Autocomplete mood_type_ac; static Autocomplete strophe_ac; static Autocomplete strophe_sm_ac; @@ -456,7 +464,12 @@ static Autocomplete* all_acs[] = { &vcard_togglable_param_ac, &vcard_address_type_ac, &force_encryption_ac, - &force_encryption_policy_ac + &force_encryption_policy_ac, + &ai_subcommands_ac, + &ai_set_subcommands_ac, + &ai_set_custom_subcommands_ac, + &ai_remove_subcommands_ac, + &ai_models_ac }; static GHashTable* ac_funcs = NULL; @@ -1169,6 +1182,26 @@ cmd_ac_init(void) autocomplete_add(correction_ac, "off"); autocomplete_add(correction_ac, "char"); + autocomplete_add_unsorted(ai_subcommands_ac, "set", FALSE); + autocomplete_add_unsorted(ai_subcommands_ac, "remove", FALSE); + autocomplete_add_unsorted(ai_subcommands_ac, "start", FALSE); + autocomplete_add_unsorted(ai_subcommands_ac, "clear", FALSE); + autocomplete_add_unsorted(ai_subcommands_ac, "providers", FALSE); + autocomplete_add_unsorted(ai_subcommands_ac, "switch", FALSE); + autocomplete_add_unsorted(ai_subcommands_ac, "models", FALSE); + + autocomplete_add_unsorted(ai_set_subcommands_ac, "provider", FALSE); + autocomplete_add_unsorted(ai_set_subcommands_ac, "token", FALSE); + autocomplete_add_unsorted(ai_set_subcommands_ac, "default-model", FALSE); + autocomplete_add_unsorted(ai_set_subcommands_ac, "custom", FALSE); + + autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "tools", FALSE); + autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "search", FALSE); + autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "memory", FALSE); + autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "plugins", FALSE); + + autocomplete_add_unsorted(ai_remove_subcommands_ac, "provider", FALSE); + autocomplete_add(avatar_ac, "set"); autocomplete_add(avatar_ac, "disable"); autocomplete_add(avatar_ac, "get"); @@ -1444,6 +1477,7 @@ cmd_ac_init(void) g_hash_table_insert(ac_funcs, "/wins", _wins_autocomplete); g_hash_table_insert(ac_funcs, "/wintitle", _wintitle_autocomplete); g_hash_table_insert(ac_funcs, "/force-encryption", _force_encryption_autocomplete); + g_hash_table_insert(ac_funcs, "/ai", _ai_autocomplete); } void @@ -1662,6 +1696,7 @@ cmd_ac_reset(ProfWin* window) win_reset_search_attempts(); win_close_reset_search_attempts(); plugins_reset_autocomplete(); + ai_providers_reset_ac(); } void @@ -4450,4 +4485,163 @@ _force_encryption_autocomplete(ProfWin* window, const char* const input, gboolea result = autocomplete_param_with_ac(input, "/force-encryption", force_encryption_ac, TRUE, previous); return result; -} \ No newline at end of file +} + +/* Forward declaration */ +static char* _ai_provider_and_model_autocomplete(ProfWin* window, const char* const input, + gboolean previous, const char* cmd); + +static char* +_ai_autocomplete(ProfWin* window, const char* const input, gboolean previous) +{ + char* result = NULL; + + /* Parse once for reuse - /ai [] [] */ + gboolean parse_result = FALSE; + auto_gcharv gchar** args = parse_args(input, 1, 4, &parse_result); + int num_args = g_strv_length(args); + + /* Top-level /ai autocomplete (e.g., /ai s -> /ai set) */ + result = autocomplete_param_with_func(input, "/ai set provider", ai_providers_find, previous, NULL); + if (result) { + return result; + } + + // /ai set token - autocomplete provider names + result = autocomplete_param_with_func(input, "/ai set token", ai_providers_find, previous, NULL); + if (result) { + return result; + } + + // /ai set default-model - autocomplete provider names + result = autocomplete_param_with_func(input, "/ai set default-model", ai_providers_find, previous, NULL); + if (result) { + return result; + } + + // /ai set custom - autocomplete provider names + result = autocomplete_param_with_func(input, "/ai set custom", ai_providers_find, previous, NULL); + if (result) { + return result; + } + + // /ai set custom - autocomplete settings + /* args[0]="set", args[1]="custom", args[2]=provider (if typed), args[3]=setting (if typed) */ + if (num_args == 3 && g_strcmp0(args[1], "custom") == 0) { + /* /ai set custom - check if provider is valid */ + if (ai_get_provider(args[2])) { + /* Valid provider, try settings autocomplete */ + result = autocomplete_param_with_ac(input, "/ai set custom ", ai_set_custom_subcommands_ac, TRUE, previous); + if (result) { + return result; + } + } + } + // /ai set - autocomplete subcommands + result = autocomplete_param_with_ac(input, "/ai set", ai_set_subcommands_ac, TRUE, previous); + if (result) { + return result; + } + + // /ai remove provider - autocomplete provider names + result = autocomplete_param_with_func(input, "/ai remove provider", ai_providers_find, previous, NULL); + if (result) { + return result; + } + + result = autocomplete_param_with_ac(input, "/ai remove", ai_remove_subcommands_ac, TRUE, previous); + if (result) { + return result; + } + + // /ai start - autocomplete provider names and model names + result = _ai_provider_and_model_autocomplete(window, input, previous, "/ai start"); + if (result) { + return result; + } + + // /ai switch - autocomplete provider names and model names + result = _ai_provider_and_model_autocomplete(window, input, previous, "/ai switch"); + if (result) { + return result; + } + + // /ai set default-model - autocomplete provider names and model names + result = _ai_provider_and_model_autocomplete(window, input, previous, "/ai set default-model"); + if (result) { + return result; + } + + // /ai models - autocomplete provider names + result = autocomplete_param_with_func(input, "/ai models", ai_providers_find, previous, NULL); + if (result) { + return result; + } + + // /ai clear - no autocomplete + // /ai providers - no autocomplete + + result = autocomplete_param_with_ac(input, "/ai", ai_subcommands_ac, FALSE, previous); + + return result; +} + +/** + * Autocomplete provider names and model names for /ai patterns. + * First tries to autocomplete provider names, then model names if provider is valid. + * Uses static ai_models_ac to preserve cycling state (last_found) across calls. + * The cmd_prefix should NOT include trailing space (e.g., "/ai start" not "/ai start "). + */ +static char* +_ai_provider_and_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* cmd) +{ + char* result; + + /* First, try provider name autocomplete */ + result = autocomplete_param_with_func(input, cmd, ai_providers_find, previous, NULL); + if (result) { + return result; + } + + /* Provider was specified, try model name autocomplete */ + /* Build the full command prefix with space */ + auto_gchar gchar* cmd_prefix = g_strdup_printf("%s ", cmd); + + if (!g_str_has_prefix(input, cmd_prefix)) { + return NULL; + } + + /* Extract provider name from input (after cmd_prefix) */ + auto_gchar gchar* rest = g_strdup(input + strlen(cmd_prefix)); + char* space = strchr(rest, ' '); + if (space) { + *space = '\0'; + } + + /* Look up the provider by name and get its models */ + AIProvider* prov = ai_get_provider(rest); + if (!prov || !prov->models) { + return NULL; + } + + if (!ai_models_ac) { + ai_models_ac = autocomplete_new(); + } + /* Convert GList* to char** for autocomplete_update */ + int model_count = g_list_length(prov->models); + char** model_array = g_new0(char*, model_count + 1); + GList* curr = prov->models; + int i = 0; + while (curr) { + model_array[i++] = curr->data; + curr = g_list_next(curr); + } + + autocomplete_update(ai_models_ac, model_array); + g_free(model_array); + + auto_gchar gchar* full_prefix = g_strdup_printf("%s%s", cmd_prefix, rest); + + result = autocomplete_param_with_ac(input, full_prefix, ai_models_ac, TRUE, previous); + return result; +} diff --git a/src/command/cmd_defs.c b/src/command/cmd_defs.c index 3dd130da..0f748e33 100644 --- a/src/command/cmd_defs.c +++ b/src/command/cmd_defs.c @@ -2787,6 +2787,67 @@ static const struct cmd_t command_defs[] = { "/force-encryption policy block") }, + { CMD_PREAMBLE("/ai", + parse_args, 0, 5, NULL) + CMD_SUBFUNCS( + { "set", cmd_ai_set }, + { "remove", cmd_ai_remove }, + { "start", cmd_ai_start }, + { "clear", cmd_ai_clear }, + { "providers", cmd_ai_providers }, + { "switch", cmd_ai_switch }, + { "models", cmd_ai_models }) + CMD_MAINFUNC(cmd_ai) + CMD_TAGS( + CMD_TAG_CHAT) + CMD_SYN( + "/ai", + "/ai set provider ", + "/ai set token ", + "/ai set default-model ", + "/ai set custom ", + "/ai remove provider ", + "/ai providers", + "/ai start [] []", + "/ai switch []", + "/ai switch ", + "/ai models ", + "/ai clear") + CMD_DESC( + "Interact with AI models via OpenAI-compatible APIs. " + "Supports multiple providers (openai, perplexity, custom). " + "Each provider has its own API key, endpoint, default model, and settings. " + "Chat history is maintained per session and not persisted locally.") + CMD_ARGS( + { "", "Display current AI settings and configured providers" }, + { "set provider ", "Add or update a provider with custom API endpoint" }, + { "set token ", "Set API token for a provider (e.g., openai, perplexity)" }, + { "set default-model ", "Set default model for a provider" }, + { "set custom ", "Set provider-level setting (e.g., tools, search)" }, + { "remove provider ", "Remove a custom provider" }, + { "providers", "List configured providers with full details" }, + { "start []", "Start new AI chat (space-separated, uses defaults if omitted)" }, + { "switch []", "Switch to different provider (and optionally model)" }, + { "models ", "Fetch and display available models for provider" }, + { "clear", "Clear current chat history" }) + CMD_EXAMPLES( + "/ai", + "/ai set token openai sk-xxx", + "/ai set token perplexity pplx-xxx", + "/ai set provider custom https://my-api.com/v1/chat/completions", + "/ai set default-model perplexity sonar", + "/ai set custom perplexity tools enabled", + "/ai remove provider custom", + "/ai start", + "/ai start perplexity", + "/ai start perplexity sonar", + "/ai start openai gpt-4o", + "/ai switch gpt-4o", + "/ai switch openai gpt-5.4-nano", + "/ai models perplexity", + "/ai clear") + }, + // NEXT-COMMAND (search helper) }; diff --git a/src/command/cmd_funcs.c b/src/command/cmd_funcs.c index 722de40f..184b88b9 100644 --- a/src/command/cmd_funcs.c +++ b/src/command/cmd_funcs.c @@ -81,7 +81,6 @@ #include "tools/bookmark_ignore.h" #include "tools/editor.h" #include "plugins/plugins.h" -#include "ui/inputwin.h" #include "ui/ui.h" #include "ui/window_list.h" #include "xmpp/avatar.h" @@ -117,6 +116,8 @@ #include "tools/clipboard.h" #endif +#include "ai/ai_client.h" + #ifdef HAVE_PYTHON #include "plugins/python_plugins.h" #endif @@ -8466,7 +8467,7 @@ _cmd_execute_default(ProfWin* window, const char* inp) } // handle non commands in non chat or plugin windows - if (window->type != WIN_CHAT && window->type != WIN_MUC && window->type != WIN_PRIVATE && window->type != WIN_PLUGIN && window->type != WIN_XML) { + if (window->type != WIN_CHAT && window->type != WIN_MUC && window->type != WIN_PRIVATE && window->type != WIN_PLUGIN && window->type != WIN_XML && window->type != WIN_AI) { cons_show("Unknown command: %s", inp); cons_alert(NULL); return TRUE; @@ -8512,6 +8513,12 @@ _cmd_execute_default(ProfWin* window, const char* inp) connection_send_stanza(inp); break; } + case WIN_AI: + { + ProfAiWin* aiwin = (ProfAiWin*)window; + cl_ev_send_ai_msg(aiwin, inp, connection_create_stanza_id()); + break; + } default: break; } @@ -10780,6 +10787,416 @@ cmd_vcard_save(ProfWin* window, const char* const command, gchar** args) return TRUE; } +gboolean +cmd_ai(ProfWin* window, const char* const command, gchar** args) +{ + if (args[0] == NULL) { + // Display current AI settings + cons_show("AI Chat - OpenAI-compatible API client"); + cons_show(""); + + // List configured providers with full details + GList* providers = ai_list_providers(); + cons_show("Configured providers:"); + for (GList* curr = providers; curr; curr = g_list_next(curr)) { + AIProvider* provider = curr->data; + auto_gchar gchar* key = ai_get_provider_key(provider->name); + const gchar* default_model = ai_get_provider_default_model(provider->name); + cons_show(" %s", provider->name); + cons_show(" URL: %s", provider->api_url); + cons_show(" Key: %s", key ? "configured" : "NOT configured"); + if (default_model) { + cons_show(" Default model: %s", default_model); + } + if (provider->models && g_list_length(provider->models) > 0) { + cons_show(" Cached models: %d", g_list_length(provider->models)); + } + cons_show(""); + } + g_list_free(providers); + + cons_show("Use '/ai start' to begin a chat (uses default provider/model)."); + cons_show("Use '/ai models ' to fetch available models."); + return TRUE; + } + + cons_bad_cmd_usage(command); + return TRUE; +} + +gboolean +cmd_ai_set(ProfWin* window, const char* const command, gchar** args) +{ + if (args[1] == NULL) { + cons_bad_cmd_usage(command); + return TRUE; + } + + if (g_strcmp0(args[1], "provider") == 0) { + // /ai set provider + if (g_strv_length(args) < 4) { + cons_bad_cmd_usage(command); + return TRUE; + } + if (ai_add_provider(args[2], args[3])) { + cons_show("Provider '%s' configured with URL: %s", args[2], args[3]); + } else { + cons_show_error("Failed to configure provider '%s'.", args[2]); + } + cons_show(""); + return TRUE; + } else if (g_strcmp0(args[1], "token") == 0) { + // /ai set token + if (g_strv_length(args) < 4) { + cons_bad_cmd_usage(command); + return TRUE; + } + ai_set_provider_key(args[2], args[3]); + cons_show("API token set for provider: %s", args[2]); + cons_show(""); + return TRUE; + } else if (g_strcmp0(args[1], "default-model") == 0) { + // /ai set default-model + if (g_strv_length(args) < 4) { + cons_bad_cmd_usage(command); + return TRUE; + } + ai_set_provider_default_model(args[2], args[3]); + cons_show("Default model for provider '%s' set to: %s", args[2], args[3]); + cons_show(""); + return TRUE; + } else if (g_strcmp0(args[1], "custom") == 0) { + // /ai set custom + if (g_strv_length(args) < 5) { + cons_bad_cmd_usage(command); + return TRUE; + } + ai_set_provider_setting(args[2], args[3], args[4]); + cons_show("Setting '%s' for provider '%s' set to: %s", args[3], args[2], args[4]); + cons_show(""); + return TRUE; + } + + cons_bad_cmd_usage(command); + return TRUE; +} + +gboolean +cmd_ai_remove(ProfWin* window, const char* const command, gchar** args) +{ + // /ai remove provider + if (g_strcmp0(args[1], "provider") != 0) { + cons_bad_cmd_usage(command); + return TRUE; + } + + if (g_strv_length(args) < 3) { + cons_bad_cmd_usage(command); + return TRUE; + } + + if (ai_remove_provider(args[2])) { + cons_show("Provider '%s' removed.", args[2]); + } else { + cons_show("Provider '%s' not found.", args[2]); + } + cons_show(""); + return TRUE; +} + +gboolean +cmd_ai_start(ProfWin* window, const char* const command, gchar** args) +{ + // /ai start [] [] + // Space-separated, no slash syntax + const gchar* provider_name = NULL; + const gchar* model = NULL; + + if (g_strv_length(args) >= 2) { + provider_name = args[1]; + } + if (g_strv_length(args) >= 3) { + model = args[2]; + } + + // Resolve defaults + auto_gchar gchar* owned_provider_name = NULL; + if (!provider_name) { + // Use default provider from preferences + auto_gchar gchar* default_provider = prefs_get_string(PREF_AI_PROVIDER); + if (default_provider && strlen(default_provider) > 0) { + provider_name = default_provider; + } + } + if (!provider_name) { + provider_name = "openai"; // Fallback + } + + AIProvider* provider = ai_get_provider(provider_name); + if (!provider) { + cons_show_error("Provider '%s' not found. Use '/ai set provider %s ' to add it.", + provider_name, provider_name); + return TRUE; + } + + // Get model: explicit > provider default > hardcoded fallback + if (!model) { + model = ai_get_provider_default_model(provider_name); + } + + if (!model) { + cons_show_error("No model specified and no default model set for provider '%s'.", provider_name); + cons_show("Use '/ai set default-model %s '.", provider_name); + return TRUE; + } + + // Check for API key + gchar* api_key = ai_get_provider_key(provider_name); + if (!api_key || strlen(api_key) == 0) { + cons_show_error("No API key set for provider '%s'. Use '/ai set token %s ' first.", + provider_name, provider_name); + g_free(api_key); + return TRUE; + } + g_free(api_key); + + // Create new AI session + AISession* session = ai_session_create(provider_name, model); + if (!session) { + log_error("[AI-CMD] Failed to create AI session"); + cons_show_error("Failed to create AI session for %s/%s.", provider_name, model); + return TRUE; + } + log_debug("[AI-CMD] AI session created successfully"); + + // Create AI chat window + log_debug("[AI-CMD] Calling wins_new_ai()..."); + ProfWin* ai_win = wins_new_ai(session); + if (!ai_win) { + log_error("[AI-CMD] wins_new_ai() returned NULL"); + cons_show_error("Failed to create AI chat window."); + ai_session_unref(session); + return TRUE; + } + log_debug("[AI-CMD] wins_new_ai() returned successfully, window type: %d", ai_win->type); + + // Add welcome message to the AI window + log_debug("[AI-CMD] Adding welcome messages..."); + win_println(ai_win, THEME_DEFAULT, "-", "AI Chat: %s/%s", provider_name, model); + win_println(ai_win, THEME_DEFAULT, "-", "Type your message and press Enter to start a conversation."); + log_debug("[AI-CMD] Welcome messages added"); + + // Focus the new window + log_debug("[AI-CMD] Calling ui_focus_win()..."); + ui_focus_win(ai_win); + log_debug("[AI-CMD] ui_focus_win() returned"); + + cons_show("Started AI chat: %s/%s", provider_name, model); + cons_show(""); + log_debug("[AI-CMD] AI start command completed"); + + return TRUE; +} + +gboolean +cmd_ai_model(ProfWin* window, const char* const command, gchar** args) +{ + // /ai model + if (args[1] == NULL) { + cons_bad_cmd_usage(command); + return TRUE; + } + + const gchar* model = args[1]; + + // Must be in an AI window to use this command + if (!window || window->type != WIN_AI) { + cons_show_error("Must be in an AI chat window to use this command."); + return TRUE; + } + ProfAiWin* aiwin = (ProfAiWin*)window; + + assert(aiwin->memcheck == PROFAIWIN_MEMCHECK); + + if (!aiwin->session) { + cons_show("No active session in this chat window."); + return TRUE; + } + + ai_session_set_model(aiwin->session, model); + cons_show("Session model changed to: %s", model); + cons_show(""); + return TRUE; +} + +gboolean +cmd_ai_switch(ProfWin* window, const char* const command, gchar** args) +{ + // /ai switch [] - Change provider and optionally model + // /ai switch - Change model only (keeps current provider) + // Modifies the existing session's provider and model instead of recreating it + if (g_strv_length(args) < 2) { + cons_bad_cmd_usage(command); + return TRUE; + } + + // Must be in an AI window to use this command + if (!window || window->type != WIN_AI) { + cons_show_error("Must be in an AI chat window to use this command."); + return TRUE; + } + ProfAiWin* aiwin = (ProfAiWin*)window; + + assert(aiwin->memcheck == PROFAIWIN_MEMCHECK); + + if (!aiwin->session) { + cons_show("No active session in this chat window."); + return TRUE; + } + + const gchar* arg1 = args[1]; + const gchar* arg2 = (g_strv_length(args) >= 3) ? args[2] : NULL; + + const gchar* provider_name; + const gchar* model; + + // Check if arg1 is a known provider + AIProvider* provider = ai_get_provider(arg1); + if (provider) { + // arg1 is a provider name + provider_name = arg1; + + // Get model: arg2 > provider default > error + if (arg2) { + model = arg2; + } else { + model = ai_get_provider_default_model(provider_name); + } + if (!model) { + cons_show_error("No model specified and no default model set for provider '%s'.", provider_name); + cons_show("Use '/ai set default-model %s ' or '/ai switch %s '.", provider_name, provider_name); + return TRUE; + } + } else { + // arg1 is a model name, keep current provider + provider_name = aiwin->session->provider_name; + if (!provider_name) { + cons_show_error("Session has no provider name."); + return TRUE; + } + model = arg1; + } + + // Check for API key + auto_gchar gchar* api_key = ai_get_provider_key(provider_name); + if (!api_key || strlen(api_key) == 0) { + cons_show_error("No API key set for provider '%s'. Use '/ai set token %s ' first.", + provider_name, provider_name); + return TRUE; + } + + // Atomically switch session provider, model, and API key (thread-safe) + ai_session_switch(aiwin->session, provider_name, model, g_steal_pointer(&api_key)); + + // Update window title + win_println((ProfWin*)aiwin, THEME_DEFAULT, "-", "AI Chat: %s/%s", provider_name, model); + + cons_show("Switched to %s/%s", provider_name, model); + cons_show(""); + + return TRUE; +} + +gboolean +cmd_ai_models(ProfWin* window, const char* const command, gchar** args) +{ + // /ai models [--cached] + // Default: fetch fresh models from API + // --cached: display cached models (if available) + if (args[1] == NULL) { + cons_bad_cmd_usage(command); + return TRUE; + } + + const gchar* provider_name = args[1]; + gboolean use_cached = (g_strv_length(args) >= 3) && (g_strcmp0(args[2], "--cached") == 0); + + AIProvider* provider = ai_get_provider(provider_name); + if (!provider) { + cons_show_error("Provider '%s' not found.", provider_name); + return TRUE; + } + + if (use_cached) { + // Display cached models (if available) + if (!provider->models) { + cons_show("No cached models for provider '%s'. Use '/ai models %s' to fetch from API.", provider_name, provider_name); + return TRUE; + } + cons_show("Cached models for provider '%s':", 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' to fetch fresh models from the API.", provider_name); + } else { + // Default: fetch fresh models from API + ai_fetch_models(provider_name, window); + } + + return TRUE; +} + +gboolean +cmd_ai_clear(ProfWin* window, const char* const command, gchar** args) +{ + // /ai clear + // Must be in an AI window to use this command + if (!window || window->type != WIN_AI) { + cons_show_error("Must be in an AI chat window to use this command."); + return TRUE; + } + ProfAiWin* aiwin = (ProfAiWin*)window; + + assert(aiwin->memcheck == PROFAIWIN_MEMCHECK); + if (aiwin->session) { + ai_session_clear_history(aiwin->session); + } + + win_clear(window); + cons_show("AI Chat history cleared."); + return TRUE; +} + +gboolean +cmd_ai_providers(ProfWin* window, const char* const command, gchar** args) +{ + cons_show("Configured providers:"); + cons_show(""); + + GList* providers = ai_list_providers(); + if (!providers) { + cons_show(" (none configured)"); + cons_show(""); + cons_show("Add a provider with: /ai set provider "); + return TRUE; + } + + for (GList* curr = providers; curr; curr = g_list_next(curr)) { + AIProvider* provider = curr->data; + auto_gchar gchar* key = ai_get_provider_key(provider->name); + cons_show(" %s", provider->name); + cons_show(" URL: %s", provider->api_url); + cons_show(" Key: %s", key ? "configured" : "NOT configured"); + cons_show(""); + } + g_list_free(providers); + + return TRUE; +} + gboolean cmd_force_encryption(ProfWin* window, const char* const command, gchar** args) { diff --git a/src/command/cmd_funcs.h b/src/command/cmd_funcs.h index b8b74e3c..966f45f2 100644 --- a/src/command/cmd_funcs.h +++ b/src/command/cmd_funcs.h @@ -166,6 +166,14 @@ gboolean cmd_console(ProfWin* window, const char* const command, gchar** args); gboolean cmd_command_list(ProfWin* window, const char* const command, gchar** args); gboolean cmd_command_exec(ProfWin* window, const char* const command, gchar** args); gboolean cmd_change_password(ProfWin* window, const char* const command, gchar** args); +gboolean cmd_ai(ProfWin* window, const char* const command, gchar** args); +gboolean cmd_ai_set(ProfWin* window, const char* const command, gchar** args); +gboolean cmd_ai_remove(ProfWin* window, const char* const command, gchar** args); +gboolean cmd_ai_start(ProfWin* window, const char* const command, gchar** args); +gboolean cmd_ai_clear(ProfWin* window, const char* const command, gchar** args); +gboolean cmd_ai_providers(ProfWin* window, const char* const command, gchar** args); +gboolean cmd_ai_switch(ProfWin* window, const char* const command, gchar** args); +gboolean cmd_ai_models(ProfWin* window, const char* const command, gchar** args); gboolean cmd_plugins(ProfWin* window, const char* const command, gchar** args); gboolean cmd_plugins_sourcepath(ProfWin* window, const char* const command, gchar** args); diff --git a/src/config/preferences.c b/src/config/preferences.c index bad809c8..6db29913 100644 --- a/src/config/preferences.c +++ b/src/config/preferences.c @@ -66,6 +66,9 @@ #define PREF_GROUP_MUC "muc" #define PREF_GROUP_PLUGINS "plugins" #define PREF_GROUP_EXECUTABLES "executables" +#define PREF_GROUP_AI "ai" +#define PREF_GROUP_AI_PREFIX "ai/" /* per-provider subsection prefix: [ai/] */ +#define PREF_AI_SETTING_PREFIX "setting." /* per-provider custom setting key prefix */ #define INPBLOCK_DEFAULT 1000 @@ -242,6 +245,69 @@ _prefs_load(void) g_key_file_remove_key(prefs, PREF_GROUP_UI, "titlebar.muc.title.jid", NULL); g_key_file_remove_key(prefs, PREF_GROUP_UI, "titlebar.muc.title.name", NULL); } + + /* Migrate legacy flat [ai] layout + * openai_url=..., openai=, openai_models=..., openai_default_model=... + * into per-provider subsections + * [ai/openai] url=..., key=..., models=..., default_model=... + * Idempotent: once the flat keys are gone, this is a no-op. */ + if (g_key_file_has_group(prefs, PREF_GROUP_AI)) { + gsize key_count = 0; + auto_gcharv gchar** all_keys = g_key_file_get_keys(prefs, PREF_GROUP_AI, &key_count, NULL); + + /* Collect provider names from _url keys first so we can migrate + * the whole bundle (url + token + models + default_model) atomically + * per provider, without misclassifying unrelated flat keys. */ + GHashTable* to_migrate = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); + for (gsize i = 0; i < key_count; i++) { + const gchar* k = all_keys[i]; + if (g_str_has_suffix(k, "_url") && !g_str_has_suffix(k, "_models_url")) { + gsize klen = strlen(k); + if (klen > 4) { + g_hash_table_add(to_migrate, g_strndup(k, klen - 4)); + } + } + } + + GList* provider_names = g_hash_table_get_keys(to_migrate); + for (GList* curr = provider_names; curr; curr = g_list_next(curr)) { + const gchar* name = (const gchar*)curr->data; + auto_gchar gchar* group = g_strconcat(PREF_GROUP_AI_PREFIX, name, NULL); + + const struct + { + const gchar* flat_suffix; + const gchar* new_key; + } map[] = { + { "_url", "url" }, + { "_models", "models" }, + { "_default_model", "default_model" }, + { NULL, NULL }, + }; + for (int j = 0; map[j].flat_suffix; j++) { + auto_gchar gchar* flat_key = g_strconcat(name, map[j].flat_suffix, NULL); + if (g_key_file_has_key(prefs, PREF_GROUP_AI, flat_key, NULL)) { + auto_gchar gchar* val = g_key_file_get_string(prefs, PREF_GROUP_AI, flat_key, NULL); + if (val) { + g_key_file_set_string(prefs, group, map[j].new_key, val); + } + g_key_file_remove_key(prefs, PREF_GROUP_AI, flat_key, NULL); + } + } + + /* Token was stored as the bare provider name in [ai]. */ + if (g_key_file_has_key(prefs, PREF_GROUP_AI, name, NULL)) { + auto_gchar gchar* val = g_key_file_get_string(prefs, PREF_GROUP_AI, name, NULL); + if (val) { + g_key_file_set_string(prefs, group, "key", val); + } + g_key_file_remove_key(prefs, PREF_GROUP_AI, name, NULL); + } + } + g_list_free(provider_names); + g_hash_table_destroy(to_migrate); + } + _save_prefs(); boolean_choice_ac = autocomplete_new(); @@ -1706,6 +1772,366 @@ _save_prefs(void) save_keyfile(&prefs_prof_keyfile); } +/* ======================================================================== + * AI provider preferences + * + * Each provider lives in its own [ai/] subsection of the keyfile with: + * url=... + * key= + * default_model=... + * models=model1;model2;... + * setting.=value (e.g. setting.tools=enabled) + * The bare [ai] section keeps only the global "defaults_initialized" sentinel. + * Legacy flat-key layouts are migrated on first load in _prefs_load(). + * ======================================================================== */ + +static gchar* +_ai_section(const char* const provider) +{ + return g_strconcat(PREF_GROUP_AI_PREFIX, provider, NULL); +} + +static gboolean +_ai_set_string(const char* const provider, const char* const key, const char* const value) +{ + if (!provider || !key || !value || !prefs) + return FALSE; + + auto_gchar gchar* group = _ai_section(provider); + g_key_file_set_string(prefs, group, key, value); + _save_prefs(); + return TRUE; +} + +static char* +_ai_get_string(const char* const provider, const char* const key) +{ + if (!provider || !key || !prefs) + return NULL; + + auto_gchar gchar* group = _ai_section(provider); + if (!g_key_file_has_key(prefs, group, key, NULL)) + return NULL; + return g_key_file_get_string(prefs, group, key, NULL); +} + +static gboolean +_ai_remove_string(const char* const provider, const char* const key) +{ + if (!provider || !key || !prefs) + return FALSE; + + auto_gchar gchar* group = _ai_section(provider); + if (!g_key_file_has_key(prefs, group, key, NULL)) + return FALSE; + + g_key_file_remove_key(prefs, group, key, NULL); + _save_prefs(); + return TRUE; +} + +/* --- Token --- */ + +gboolean +prefs_ai_set_token(const char* const provider, const char* const token) +{ + if (!provider || !prefs) + return FALSE; + if (!token) + return prefs_ai_remove_token(provider); + return _ai_set_string(provider, "key", token); +} + +gboolean +prefs_ai_remove_token(const char* const provider) +{ + return _ai_remove_string(provider, "key"); +} + +char* +prefs_ai_get_token(const char* const provider) +{ + return _ai_get_string(provider, "key"); +} + +GList* +prefs_ai_list_tokens(void) +{ + if (!prefs) + return NULL; + + GList* result = NULL; + GList* providers = prefs_ai_list_providers(); + for (GList* curr = providers; curr; curr = g_list_next(curr)) { + const gchar* name = (const gchar*)curr->data; + auto_gchar gchar* tok = prefs_ai_get_token(name); + if (tok && tok[0] != '\0') { + result = g_list_append(result, g_strdup(name)); + } + } + prefs_free_ai_providers(providers); + return result; +} + +void +prefs_free_ai_tokens(GList* tokens) +{ + if (tokens) { + g_list_free_full(tokens, g_free); + } +} + +/* --- Models cache --- */ + +gboolean +prefs_ai_set_models(const char* const provider, const gchar* const* models, gint count) +{ + if (!provider || count <= 0 || !prefs) + return FALSE; + + GString* model_str = g_string_new(""); + for (int i = 0; i < count; i++) { + if (i > 0) + g_string_append_c(model_str, ';'); + g_string_append(model_str, models[i]); + } + + gboolean ok = _ai_set_string(provider, "models", model_str->str); + g_string_free(model_str, TRUE); + return ok; +} + +gboolean +prefs_ai_remove_models(const char* const provider) +{ + return _ai_remove_string(provider, "models"); +} + +GList* +prefs_ai_get_models(const char* const provider) +{ + auto_gchar gchar* model_str = _ai_get_string(provider, "models"); + if (!model_str || model_str[0] == '\0') + return NULL; + + GList* result = NULL; + gchar** models = g_strsplit(model_str, ";", -1); + for (int i = 0; models[i] != NULL; i++) { + result = g_list_append(result, g_strdup(models[i])); + } + g_strfreev(models); + return result; +} + +void +prefs_free_ai_models(GList* models) +{ + if (models) { + g_list_free_full(models, g_free); + } +} + +/* --- Default model --- */ + +gboolean +prefs_ai_set_default_model(const char* const provider, const char* const model) +{ + if (!provider || !model || !prefs) + return FALSE; + if (model[0] == '\0') + return prefs_ai_remove_default_model(provider); + return _ai_set_string(provider, "default_model", model); +} + +gboolean +prefs_ai_remove_default_model(const char* const provider) +{ + return _ai_remove_string(provider, "default_model"); +} + +char* +prefs_ai_get_default_model(const char* const provider) +{ + return _ai_get_string(provider, "default_model"); +} + +/* --- Provider URL --- */ + +gboolean +prefs_ai_set_provider(const char* const provider, const char* const url) +{ + if (!provider || !url) + return FALSE; + return _ai_set_string(provider, "url", url); +} + +gboolean +prefs_ai_remove_provider(const char* const provider) +{ + /* Provider presence is identified by the url key; removing it is what + * makes prefs_ai_list_providers stop returning this name. The remaining + * sub-keys are wiped through prefs_ai_remove_section(). */ + return _ai_remove_string(provider, "url"); +} + +char* +prefs_ai_get_provider_url(const char* const provider) +{ + return _ai_get_string(provider, "url"); +} + +gboolean +prefs_ai_remove_section(const char* const provider) +{ + if (!provider || !prefs) + return FALSE; + + auto_gchar gchar* group = _ai_section(provider); + if (!g_key_file_has_group(prefs, group)) + return FALSE; + + g_key_file_remove_group(prefs, group, NULL); + _save_prefs(); + return TRUE; +} + +GList* +prefs_ai_list_providers(void) +{ + if (!prefs) + return NULL; + + GList* result = NULL; + gsize len = 0; + auto_gcharv gchar** groups = g_key_file_get_groups(prefs, &len); + if (!groups) + return NULL; + + gsize prefix_len = strlen(PREF_GROUP_AI_PREFIX); + for (gsize i = 0; i < len; i++) { + const gchar* g = groups[i]; + if (g_str_has_prefix(g, PREF_GROUP_AI_PREFIX) && strlen(g) > prefix_len) { + /* Only count groups that actually identify a provider (have url). */ + if (g_key_file_has_key(prefs, g, "url", NULL)) { + result = g_list_append(result, g_strdup(g + prefix_len)); + } + } + } + + return result; +} + +void +prefs_free_ai_providers(GList* providers) +{ + if (providers) { + g_list_free_full(providers, g_free); + } +} + +GList* +prefs_ai_get_providers(void) +{ + GList* configured = prefs_ai_list_providers(); + GList* result = NULL; + + for (GList* curr = configured; curr; curr = g_list_next(curr)) { + const gchar* name = (const gchar*)curr->data; + auto_gchar gchar* url = prefs_ai_get_provider_url(name); + if (url && strlen(url) > 0) { + result = g_list_append(result, g_strdup(name)); + result = g_list_append(result, g_strdup(url)); + } + } + prefs_free_ai_providers(configured); + + /* Seed openai/perplexity into the in-memory result on very first init + * only. In production the "defaults_initialized" sentinel in [ai] is what + * tells us we've already seeded — a user who removes every provider must + * not get them silently re-added on the next start. When prefs is NULL + * (e.g. unit tests that bring up ai_client without prefs_load) we still + * seed the in-memory list so behaviour matches a fresh first run, but + * skip the persistence write. */ + gboolean already_seeded = prefs && g_key_file_get_boolean(prefs, PREF_GROUP_AI, "defaults_initialized", NULL); + if (!already_seeded && !result) { + if (prefs) { + prefs_ai_set_provider("openai", "https://api.openai.com/"); + prefs_ai_set_provider("perplexity", "https://api.perplexity.ai/"); + } + result = g_list_append(result, g_strdup("openai")); + result = g_list_append(result, g_strdup("https://api.openai.com/")); + result = g_list_append(result, g_strdup("perplexity")); + result = g_list_append(result, g_strdup("https://api.perplexity.ai/")); + } + if (prefs && !already_seeded) { + g_key_file_set_boolean(prefs, PREF_GROUP_AI, "defaults_initialized", TRUE); + _save_prefs(); + } + + return result; +} + +/* --- Custom per-provider settings (setting.) --- */ + +gboolean +prefs_ai_set_setting(const char* const provider, const char* const setting, const char* const value) +{ + if (!provider || !setting) + return FALSE; + auto_gchar gchar* key = g_strconcat(PREF_AI_SETTING_PREFIX, setting, NULL); + if (!value) + return _ai_remove_string(provider, key); + return _ai_set_string(provider, key, value); +} + +gboolean +prefs_ai_remove_setting(const char* const provider, const char* const setting) +{ + if (!provider || !setting) + return FALSE; + auto_gchar gchar* key = g_strconcat(PREF_AI_SETTING_PREFIX, setting, NULL); + return _ai_remove_string(provider, key); +} + +char* +prefs_ai_get_setting(const char* const provider, const char* const setting) +{ + if (!provider || !setting) + return NULL; + auto_gchar gchar* key = g_strconcat(PREF_AI_SETTING_PREFIX, setting, NULL); + return _ai_get_string(provider, key); +} + +GList* +prefs_ai_list_settings(const char* const provider) +{ + if (!provider || !prefs) + return NULL; + + auto_gchar gchar* group = _ai_section(provider); + if (!g_key_file_has_group(prefs, group)) + return NULL; + + GList* result = NULL; + gsize len = 0; + auto_gcharv gchar** keys = g_key_file_get_keys(prefs, group, &len, NULL); + gsize prefix_len = strlen(PREF_AI_SETTING_PREFIX); + for (gsize i = 0; i < len; i++) { + if (g_str_has_prefix(keys[i], PREF_AI_SETTING_PREFIX) && strlen(keys[i]) > prefix_len) { + result = g_list_append(result, g_strdup(keys[i] + prefix_len)); + } + } + return result; +} + +void +prefs_free_ai_settings(GList* settings) +{ + if (settings) { + g_list_free_full(settings, g_free); + } +} + // get the preference group for a specific preference // for example the PREF_BEEP setting ("beep" in .profrc, see _get_key) belongs // to the [ui] section. @@ -1865,6 +2291,9 @@ _get_group(preference_t pref) return PREF_GROUP_OMEMO; case PREF_OX_LOG: return PREF_GROUP_OX; + case PREF_AI_PROVIDER: + case PREF_AI_API_KEY: + return PREF_GROUP_AI; default: return NULL; } @@ -2142,6 +2571,10 @@ _get_key(preference_t pref) return "stamp.incoming"; case PREF_OX_LOG: return "log"; + case PREF_AI_PROVIDER: + return "provider"; + case PREF_AI_API_KEY: + return "api_key"; case PREF_MOOD: return "mood"; case PREF_VCARD_PHOTO_CMD: @@ -2319,6 +2752,10 @@ _get_default_string(preference_t pref) return "on"; case PREF_FORCE_ENCRYPTION_MODE: return "resend-to-confirm"; + case PREF_AI_PROVIDER: + return "openai"; + case PREF_AI_API_KEY: + return ""; default: return NULL; } diff --git a/src/config/preferences.h b/src/config/preferences.h index 750a87ee..fffc2082 100644 --- a/src/config/preferences.h +++ b/src/config/preferences.h @@ -183,6 +183,9 @@ typedef enum { PREF_OX_LOG, PREF_MOOD, PREF_STROPHE_VERBOSITY, + PREF_AI_PROVIDER, + PREF_AI_API_KEY, + PREF_AI_DEFAULT_MODEL, PREF_STROPHE_SM_ENABLED, PREF_STROPHE_SM_RESEND, PREF_VCARD_PHOTO_CMD, @@ -356,4 +359,42 @@ gboolean prefs_get_room_notify(const char* const roomjid); gboolean prefs_get_room_notify_mention(const char* const roomjid); gboolean prefs_get_room_notify_trigger(const char* const roomjid); +/* AI token management */ +gboolean prefs_ai_set_token(const char* const provider, const char* const token); +gboolean prefs_ai_remove_token(const char* const provider); +char* prefs_ai_get_token(const char* const provider); +GList* prefs_ai_list_tokens(void); +void prefs_free_ai_tokens(GList* tokens); + +/* AI provider management */ +gboolean prefs_ai_set_provider(const char* const provider, const char* const url); +gboolean prefs_ai_remove_provider(const char* const provider); +char* prefs_ai_get_provider_url(const char* const provider); +GList* prefs_ai_list_providers(void); +void prefs_free_ai_providers(GList* providers); +GList* prefs_ai_get_providers(void); + +/* Wipe the whole [ai/] subsection (url, key, models, default_model, + * custom settings) in one call. */ +gboolean prefs_ai_remove_section(const char* const provider); + +/* AI model cache management */ +gboolean prefs_ai_set_models(const char* const provider, const gchar* const* models, gint count); +gboolean prefs_ai_remove_models(const char* const provider); +GList* prefs_ai_get_models(const char* const provider); +void prefs_free_ai_models(GList* models); + +/* AI default model per provider */ +gboolean prefs_ai_set_default_model(const char* const provider, const char* const model); +gboolean prefs_ai_remove_default_model(const char* const provider); +char* prefs_ai_get_default_model(const char* const provider); + +/* AI per-provider custom settings (e.g. tools, search) — persisted as + * setting.= inside [ai/]. Pass NULL value to remove. */ +gboolean prefs_ai_set_setting(const char* const provider, const char* const setting, const char* const value); +gboolean prefs_ai_remove_setting(const char* const provider, const char* const setting); +char* prefs_ai_get_setting(const char* const provider, const char* const setting); +GList* prefs_ai_list_settings(const char* const provider); +void prefs_free_ai_settings(GList* settings); + #endif diff --git a/src/event/client_events.c b/src/event/client_events.c index 7b68bdc1..6adfc612 100644 --- a/src/event/client_events.c +++ b/src/event/client_events.c @@ -37,8 +37,10 @@ #include "config.h" #include +#include #include +#include "ai/ai_client.h" #include "log.h" #include "chatlog.h" #include "database.h" @@ -47,7 +49,7 @@ #include "event/common.h" #include "plugins/plugins.h" #include "ui/inputwin.h" -#include "ui/window_list.h" +#include "ui/window.h" #include "xmpp/chat_session.h" #include "xmpp/session.h" #include "xmpp/xmpp.h" @@ -284,3 +286,30 @@ allow_unencrypted_message(ProfChatWin* chatwin, const char* const msg) win_println((ProfWin*)chatwin, THEME_ERROR, "-", "Message not sent: invalid encryption mode (%s). Use '/force-encryption policy resend-to-confirm'.", force_enc_mode); return FALSE; } + +void +cl_ev_send_ai_msg(ProfAiWin* aiwin, const char* const message, const char* const id) +{ + if (!aiwin || !message) { + return; + } + + assert(aiwin->memcheck == PROFAIWIN_MEMCHECK); + + if (!aiwin->session) { + log_error("[AI] AI session not initialized"); + return; + } + + /* Reset paged flag before printing user message. + * If the user scrolled up to view history, paged=1 would suppress + * the message in _win_printf(). Reset it here so the message displays. */ + aiwin->window.layout->paged = 0; + aiwin->window.layout->unread_msg = 0; + + // Display user message in AI window. + win_print_outgoing(&aiwin->window, ">>", id, NULL, message); + + // Send to AI provider. + ai_send_prompt(aiwin->session, message, aiwin); +} diff --git a/src/event/client_events.h b/src/event/client_events.h index e5f638f9..48c56dd1 100644 --- a/src/event/client_events.h +++ b/src/event/client_events.h @@ -51,6 +51,7 @@ void cl_ev_send_msg(ProfChatWin* chatwin, const char* const msg, const char* con void cl_ev_send_muc_msg_corrected(ProfMucWin* mucwin, const char* const msg, const char* const oob_url, gboolean correct_last_msg); void cl_ev_send_muc_msg(ProfMucWin* mucwin, const char* const msg, const char* const oob_url); void cl_ev_send_priv_msg(ProfPrivateWin* privwin, const char* const msg, const char* const oob_url); +void cl_ev_send_ai_msg(ProfAiWin* aiwin, const char* const message, const char* const id); // Checks if an unencrypted message can be sent based on encryption preferences. // Returns TRUE if allowed, FALSE if blocked. diff --git a/src/profanity.c b/src/profanity.c index b9b0cc87..023601d0 100644 --- a/src/profanity.c +++ b/src/profanity.c @@ -69,6 +69,7 @@ #include "xmpp/xmpp.h" #include "xmpp/muc.h" #include "xmpp/chat_session.h" +#include "ai/ai_client.h" #include "xmpp/chat_state.h" #include "xmpp/contact.h" #include "xmpp/roster_list.h" @@ -249,6 +250,7 @@ _init(char* log_level, char* config_file, char* log_file, char* theme_name) } session_init(); cmd_init(); + ai_client_init(); log_info("Initialising contact list"); muc_init(); tlscerts_init(); diff --git a/src/tools/autocomplete.c b/src/tools/autocomplete.c index b7fb00b3..ad1b10df 100644 --- a/src/tools/autocomplete.c +++ b/src/tools/autocomplete.c @@ -260,7 +260,7 @@ autocomplete_complete(Autocomplete ac, const gchar* search_str, gboolean quote, FREE_SET_NULL(ac->search_str); } - ac->search_str = strdup(search_str); + ac->search_str = g_strdup(search_str); found = _search(ac, ac->items, quote, NEXT); return found; @@ -305,7 +305,7 @@ autocomplete_complete(Autocomplete ac, const gchar* search_str, gboolean quote, // autocomplete_func func is used -> autocomplete_param_with_func // Autocomplete ac, gboolean quote are used -> autocomplete_param_with_ac static char* -_autocomplete_param_common(const char* const input, char* command, autocomplete_func func, Autocomplete ac, gboolean quote, gboolean previous, void* context) +_autocomplete_param_common(const char* const input, const char* command, autocomplete_func func, Autocomplete ac, gboolean quote, gboolean previous, void* context) { int len; auto_gchar gchar* command_cpy = g_strdup_printf("%s ", command); @@ -344,7 +344,7 @@ _autocomplete_param_common(const char* const input, char* command, autocomplete_ } char* -autocomplete_param_with_func(const char* const input, char* command, autocomplete_func func, gboolean previous, void* context) +autocomplete_param_with_func(const char* const input, const char* command, autocomplete_func func, gboolean previous, void* context) { return _autocomplete_param_common(input, command, func, NULL, FALSE, previous, context); } diff --git a/src/tools/autocomplete.h b/src/tools/autocomplete.h index 851dbc0d..4a62de4c 100644 --- a/src/tools/autocomplete.h +++ b/src/tools/autocomplete.h @@ -63,7 +63,7 @@ gchar* autocomplete_complete(Autocomplete ac, const gchar* search_str, gboolean GList* autocomplete_create_list(Autocomplete ac); gint autocomplete_length(Autocomplete ac); -char* autocomplete_param_with_func(const char* const input, char* command, +char* autocomplete_param_with_func(const char* const input, const char* command, autocomplete_func func, gboolean previous, void* context); char* autocomplete_param_with_ac(const char* const input, char* command, diff --git a/src/ui/aiwin.c b/src/ui/aiwin.c new file mode 100644 index 00000000..abc91948 --- /dev/null +++ b/src/ui/aiwin.c @@ -0,0 +1,61 @@ +/* + * aiwin.c - AI Chat Window Management + * + * Provides functions for managing the AI chat window (ProfAiWin) in the + * profanity client. This includes retrieving window strings for identification, + * displaying AI/LLM responses in the chat view, and handling error messages + * from AI interactions. + * + * Key functions: + * aiwin_get_string() - Returns a descriptive string for the window + * aiwin_display_response() - Displays an AI/LLM response and appends it + * to the conversation history + * aiwin_display_error() - Displays an error message from AI operations + * + * vim: expandtab:ts=4:sts=4:sw=4 + */ + +#include "config.h" + +#include + +#include "ai/ai_client.h" +#include "log.h" +#include "ui/ui.h" +#include "ui/win_types.h" + +char* +aiwin_get_string(ProfAiWin* win) +{ + assert(win->memcheck == PROFAIWIN_MEMCHECK); + return g_strdup_printf("AI Chat (%s: %s)", win->session->provider_name, win->session->model); +} + +void +aiwin_display_response(ProfAiWin* win, const char* response) +{ + log_debug("[AI-WIN] aiwin_display_response ENTER: win=%p, response='%s'", (void*)win, response); + assert(win->memcheck == PROFAIWIN_MEMCHECK); + + if (!response) { + log_error("[AI-WIN] FAIL: null response"); + return; + } + + // Display AI response + win_println(&win->window, THEME_DEFAULT, "<< LLM:", "%s", response); + log_debug("[AI-WIN] Displayed AI response"); +} + +void +aiwin_display_error(ProfAiWin* win, const char* error_msg) +{ + log_debug("[AI-WIN] aiwin_display_error ENTER: win=%p, error='%s'", (void*)win, error_msg); + assert(win->memcheck == PROFAIWIN_MEMCHECK); + + const char* msg = error_msg ? error_msg : "Unknown error"; + win_println(&win->window, THEME_ERROR, "[ERROR]", "%s", msg); + + // Also show error in console so user sees it regardless of active window + cons_show_error("AI Error: %s", msg); +} diff --git a/src/ui/ui.h b/src/ui/ui.h index 09b49717..2420fadf 100644 --- a/src/ui/ui.h +++ b/src/ui/ui.h @@ -386,6 +386,8 @@ ProfWin* win_create_config(const char* const title, DataForm* form, ProfConfWinC ProfWin* win_create_private(const char* const fulljid); ProfWin* win_create_plugin(const char* const plugin_name, const char* const tag); ProfWin* win_create_vcard(vCard* vcard); +ProfWin* win_create_ai(AISession* session); +ProfWin* wins_new_ai(AISession* session); void win_update_virtual(ProfWin* window); void win_free(ProfWin* window); gboolean win_notify_remind(ProfWin* window); @@ -445,4 +447,9 @@ void notify_invite(const char* const from, const char* const room, const char* c void notify(const char* const message, int timeout, const char* const category); void notify_subscription(const char* const from); +/* AI window */ +char* aiwin_get_string(ProfAiWin* win); +void aiwin_display_response(ProfAiWin* win, const char* response); +void aiwin_display_error(ProfAiWin* win, const char* error_msg); + #endif diff --git a/src/ui/win_types.h b/src/ui/win_types.h index 360732eb..2d4f80da 100644 --- a/src/ui/win_types.h +++ b/src/ui/win_types.h @@ -62,6 +62,7 @@ #define PROFXMLWIN_MEMCHECK 87333463 #define PROFPLUGINWIN_MEMCHECK 43434777 #define PROFVCARDWIN_MEMCHECK 68947523 +#define PROFAIWIN_MEMCHECK 91827364 typedef enum { FIELD_HIDDEN, @@ -144,7 +145,8 @@ typedef enum { WIN_PRIVATE, WIN_XML, WIN_PLUGIN, - WIN_VCARD + WIN_VCARD, + WIN_AI } win_type_t; typedef enum { @@ -258,4 +260,16 @@ typedef struct prof_vcard_win_t unsigned long memcheck; } ProfVcardWin; +/* Forward declaration */ +typedef struct ai_session_t AISession; + +typedef struct prof_ai_win_t +{ + ProfWin window; + ProfBuff message_buffer; + GString* conversation_history; + AISession* session; + unsigned long memcheck; +} ProfAiWin; + #endif diff --git a/src/ui/window.c b/src/ui/window.c index 33fcac33..139a8b88 100644 --- a/src/ui/window.c +++ b/src/ui/window.c @@ -36,6 +36,7 @@ #include "config.h" #include "database.h" +#include "ui/win_types.h" #include "ui/window_list.h" #include @@ -62,6 +63,7 @@ #include "ui/screen.h" #include "xmpp/xmpp.h" #include "xmpp/roster_list.h" +#include "ai/ai_client.h" static const char* LOADING_MESSAGE = "Loading older messages…"; static const char* CONS_WIN_TITLE = "CProof. Type /help for help information."; @@ -329,6 +331,22 @@ win_create_vcard(vCard* vcard) return &new_win->window; } +ProfWin* +win_create_ai(AISession* session) +{ + ProfAiWin* new_win = g_new0(ProfAiWin, 1); + new_win->window.type = WIN_AI; + new_win->window.scroll_state = WIN_SCROLL_INNER; + new_win->window.layout = _win_create_simple_layout(); + + new_win->message_buffer = buffer_create(); + new_win->conversation_history = g_string_new(""); + new_win->session = session ? ai_session_ref(session) : NULL; + new_win->memcheck = PROFAIWIN_MEMCHECK; + + return &new_win->window; +} + gchar* win_get_title(ProfWin* window) { @@ -407,6 +425,13 @@ win_get_title(ProfWin* window) return g_string_free(title, FALSE); } + case WIN_AI: + { + const ProfAiWin* aiwin = (ProfAiWin*)window; + assert(aiwin->memcheck == PROFAIWIN_MEMCHECK); + + return g_strdup_printf("AI Assistant (%s: %s)", aiwin->session->provider_name, aiwin->session->model); + } } assert(FALSE); } @@ -454,6 +479,10 @@ win_get_tab_identifier(ProfWin* window) { return strdup("vcard"); } + case WIN_AI: + { + return strdup("ai"); + } } assert(FALSE); } @@ -536,6 +565,12 @@ win_to_string(ProfWin* window) ProfVcardWin* vcardwin = (ProfVcardWin*)window; return vcardwin_get_string(vcardwin); } + case WIN_AI: + { + ProfAiWin* aiwin = (ProfAiWin*)window; + assert(aiwin->memcheck == PROFAIWIN_MEMCHECK); + return aiwin_get_string(aiwin); + } } assert(FALSE); } @@ -662,6 +697,17 @@ win_free(ProfWin* window) free(pluginwin->plugin_name); break; } + case WIN_AI: + { + ProfAiWin* aiwin = (ProfAiWin*)window; + assert(aiwin->memcheck == PROFAIWIN_MEMCHECK); + buffer_free(aiwin->message_buffer); + g_string_free(aiwin->conversation_history, TRUE); + if (aiwin->session) { + ai_session_unref(aiwin->session); + } + break; + } default: break; } diff --git a/src/ui/window_list.c b/src/ui/window_list.c index 889344b6..34424b3a 100644 --- a/src/ui/window_list.c +++ b/src/ui/window_list.c @@ -44,9 +44,11 @@ #include "common.h" #include "config/preferences.h" +#include "log.h" #include "config/theme.h" #include "plugins/plugins.h" #include "ui/ui.h" +#include "ui/window.h" #include "ui/window_list.h" #include "xmpp/xmpp.h" #include "xmpp/roster_list.h" @@ -374,6 +376,8 @@ wins_set_current_by_num(int i) status_bar_active(1, conswin->type, "console"); } } + } else { + log_error("Window not found for index %d", i); } } @@ -714,6 +718,20 @@ wins_new_vcard(vCard* vcard) return newwin; } +ProfWin* +wins_new_ai(AISession* session) +{ + int result = _wins_get_next_available_num(keys); + ProfWin* newwin = win_create_ai(session); + if (!newwin) { + return NULL; + } + _wins_htable_insert(windows, GINT_TO_POINTER(result), newwin); + autocomplete_add(wins_ac, "ai"); + autocomplete_add(wins_close_ac, "ai"); + return newwin; +} + gboolean wins_do_notify_remind(void) { @@ -841,7 +859,7 @@ wins_get_prune_wins(void) while (curr) { ProfWin* window = curr->data; - if (win_unread(window) == 0 && window->type != WIN_MUC && window->type != WIN_CONFIG && window->type != WIN_XML && window->type != WIN_CONSOLE) { + if (win_unread(window) == 0 && window->type != WIN_MUC && window->type != WIN_CONFIG && window->type != WIN_XML && window->type != WIN_CONSOLE && window->type != WIN_AI) { result = g_slist_append(result, window); } curr = g_list_next(curr); @@ -849,6 +867,27 @@ wins_get_prune_wins(void) return result; } +gboolean +wins_ai_exists(ProfAiWin* const aiwin) +{ + if (!aiwin) { + return FALSE; + } + + GList* curr = values; + while (curr) { + ProfWin* window = curr->data; + if (window->type == WIN_AI) { + if ((ProfAiWin*)window == aiwin) { + return TRUE; + } + } + curr = g_list_next(curr); + } + + return FALSE; +} + void wins_lost_connection(void) { diff --git a/src/ui/window_list.h b/src/ui/window_list.h index d9d11c58..79c84a0d 100644 --- a/src/ui/window_list.h +++ b/src/ui/window_list.h @@ -63,6 +63,7 @@ ProfPrivateWin* wins_get_private(const char* const fulljid); ProfPluginWin* wins_get_plugin(const char* const tag); ProfXMLWin* wins_get_xmlconsole(void); ProfVcardWin* wins_get_vcard(void); +gboolean wins_ai_exists(ProfAiWin* const aiwin); void wins_close_plugin(char* tag); diff --git a/tests/functionaltests/functionaltests.c b/tests/functionaltests/functionaltests.c index 38a028cd..0ea473c3 100644 --- a/tests/functionaltests/functionaltests.c +++ b/tests/functionaltests/functionaltests.c @@ -59,6 +59,7 @@ #ifdef HAVE_SQLITE #include "test_export_import.h" #endif +#include "test_ai.h" /* Macro to wrap each test with setup/teardown functions */ #define PROF_FUNC_TEST(test) \ @@ -66,6 +67,14 @@ #test, test, init_prof_test, close_prof_test, #test \ } +/* AI tests use a custom init that primes stabber via prof_connect so + * stbbr_stop() in close_prof_test() doesn't hang on a never-connected + * server thread. See ai_init_test() in test_ai.c. */ +#define PROF_FUNC_TEST_AI(test) \ + { \ + #test, test, ai_init_test, close_prof_test, #test \ + } + #ifdef HAVE_SQLITE /* Custom init that sets a non-UTC timezone (POSIX TST-3 = UTC+3) */ static int @@ -84,9 +93,9 @@ main(int argc, char* argv[]) int group = 0; /* 0 = all groups */ if (argc > 1) { group = atoi(argv[1]); - if (group < 1 || group > 4) { + if (group < 1 || group > 5) { fprintf(stderr, "Usage: %s [group]\n", argv[0]); - fprintf(stderr, " group: 1-4 to run specific group, or omit for all\n"); + fprintf(stderr, " group: 1-5 to run specific group, or omit for all\n"); return 1; } } @@ -307,6 +316,31 @@ main(int argc, char* argv[]) #endif }; + /* ============================================================ + * GROUP 5: AI command surface (console + HTTP stub) + * Standalone because AI tests don't use stabber's XMPP path; mixing + * them with XMPP-bound tests in the same group poisons stabber state + * between fixture init/teardown cycles and hangs subsequent + * prof_connect() calls. + * ============================================================ */ + const struct CMUnitTest group5_tests[] = { + /* Console-only — no network calls */ + PROF_FUNC_TEST_AI(ai_no_args_shows_help), + PROF_FUNC_TEST_AI(ai_providers_lists_defaults), + PROF_FUNC_TEST_AI(ai_providers_list_shows_key_status), + PROF_FUNC_TEST_AI(ai_set_provider_adds_custom), + PROF_FUNC_TEST_AI(ai_set_token_marks_key_set), + PROF_FUNC_TEST_AI(ai_start_unknown_provider_errors), + PROF_FUNC_TEST_AI(ai_start_without_key_errors), + PROF_FUNC_TEST_AI(ai_start_with_key_opens_window), + PROF_FUNC_TEST_AI(ai_clear_without_window_errors), + PROF_FUNC_TEST_AI(ai_remove_provider_works), + PROF_FUNC_TEST_AI(ai_remove_provider_unknown_errors), + PROF_FUNC_TEST_AI(ai_set_default_model_updates_provider), + PROF_FUNC_TEST_AI(ai_switch_without_window_errors), + PROF_FUNC_TEST_AI(ai_bad_subcommand_shows_usage), + }; + /* Test group registry for easy extension */ struct { @@ -318,6 +352,7 @@ main(int argc, char* argv[]) { "Group 2: ExportImport/Receipts", group2_tests, ARRAY_SIZE(group2_tests) }, { "Group 3: Presence/Disconnect/DBHistory/Message/MUC-DB", group3_tests, ARRAY_SIZE(group3_tests) }, { "Group 4: Session/MUC/Carbons/MigrationStress", group4_tests, ARRAY_SIZE(group4_tests) }, + { "Group 5: AI command surface (console only)", group5_tests, ARRAY_SIZE(group5_tests) }, }; const int num_groups = ARRAY_SIZE(groups); diff --git a/tests/functionaltests/proftest.c b/tests/functionaltests/proftest.c index 8704090b..b0ac78e3 100644 --- a/tests/functionaltests/proftest.c +++ b/tests/functionaltests/proftest.c @@ -20,7 +20,7 @@ #include "proftest.h" /* Number of parallel test groups for CI builds */ -#define TEST_GROUPS 4 +#define TEST_GROUPS 5 /* Number of ports reserved per test group; allows skipping TIME_WAIT ports */ #define PORTS_PER_GROUP 50 diff --git a/tests/functionaltests/test_ai.c b/tests/functionaltests/test_ai.c new file mode 100644 index 00000000..9da93933 --- /dev/null +++ b/tests/functionaltests/test_ai.c @@ -0,0 +1,252 @@ +/* + * test_ai.c + * + * Functional tests for the /ai command surface (Tier A — no network). + * + * These tests interact with profanity through its PTY console and exercise + * paths that do not reach libcurl: command parsing, provider registration, + * key/setting/default storage, error messages, and AI window creation. + * + * Tests that require an actual provider HTTP exchange (prompt -> response, + * /ai models , HTTP error envelopes) are out of scope here and + * belong in a separate suite backed by a local HTTP stub. + */ + +#include +#include "prof_cmocka.h" +#include +#include + +#include "proftest.h" +#include "test_ai.h" + +int +ai_init_test(void** state) +{ + int ret = init_prof_test(state); + if (ret != 0) { + return ret; + } + /* Connect to stabber even though AI tests don't use XMPP. Without this + * stabber's accept loop has no client to disconnect on /quit, and + * stbbr_stop() in close_prof_test() hangs uninterruptibly when joining + * the server thread. */ + prof_connect(); + return 0; +} + +void +ai_no_args_shows_help(void** state) +{ + /* `/ai` with no arguments lists the built-in providers and a usage hint. + * We don't pin specific provider names — defaults may change. Verify the + * header, the "Configured providers" section, that *some* provider line + * carries an http(s) URL, and the usage hint. */ + prof_input("/ai"); + + prof_timeout(5); + assert_true(prof_output_exact("AI Chat - OpenAI-compatible API client")); + assert_true(prof_output_exact("Configured providers:")); + assert_true(prof_output_regex("URL: https?://")); + assert_true(prof_output_regex("Use '/ai start' to begin a chat")); + prof_timeout_reset(); +} + +void +ai_providers_lists_defaults(void** state) +{ + /* `/ai providers` (no "list") shows the built-in list with URLs. */ + prof_input("/ai providers"); + + prof_timeout(5); + assert_true(prof_output_exact("Available AI providers:")); + /* At least one URL line is rendered — exact name agnostic. */ + assert_true(prof_output_regex("https?://")); + prof_timeout_reset(); +} + +void +ai_providers_list_shows_key_status(void** state) +{ + /* `/ai providers list` lists each configured provider with key status. */ + prof_input("/ai providers list"); + + prof_timeout(5); + assert_true(prof_output_exact("Configured providers:")); + /* No tokens have been set yet in this fresh session. */ + assert_true(prof_output_regex("Key: NOT configured")); + prof_timeout_reset(); +} + +void +ai_set_provider_adds_custom(void** state) +{ + /* Adding a custom provider should make it appear in /ai providers list. */ + prof_input("/ai set provider mock http://127.0.0.1:1/"); + + prof_timeout(5); + assert_true(prof_output_regex("Provider 'mock' configured")); + prof_timeout_reset(); + + prof_input("/ai providers list"); + + prof_timeout(5); + assert_true(prof_output_regex("mock")); + assert_true(prof_output_regex("http://127.0.0.1:1/")); + prof_timeout_reset(); +} + +void +ai_set_token_marks_key_set(void** state) +{ + /* Use a self-set-up provider so the test doesn't pin a default name. */ + prof_input("/ai set provider testprov https://example.test/"); + prof_timeout(5); + assert_true(prof_output_regex("Provider 'testprov' configured")); + prof_timeout_reset(); + + prof_input("/ai set token testprov sk-fake-test-key"); + + prof_timeout(5); + assert_true(prof_output_regex("API token set for provider: testprov")); + prof_timeout_reset(); + + prof_input("/ai providers list"); + + prof_timeout(5); + assert_true(prof_output_regex("Key: configured")); + prof_timeout_reset(); +} + +void +ai_start_unknown_provider_errors(void** state) +{ + /* Unknown provider name should error out without creating a window. */ + prof_input("/ai start nope_provider somemodel"); + + prof_timeout(5); + assert_true(prof_output_regex("Provider 'nope_provider' not found")); + prof_timeout_reset(); +} + +void +ai_start_without_key_errors(void** state) +{ + /* Self-set-up provider with no token. /ai start must refuse. */ + prof_input("/ai set provider testprov https://example.test/"); + prof_timeout(5); + assert_true(prof_output_regex("Provider 'testprov' configured")); + prof_timeout_reset(); + + prof_input("/ai start testprov model-x"); + + prof_timeout(5); + assert_true(prof_output_regex("No API key set for provider 'testprov'")); + prof_timeout_reset(); +} + +void +ai_start_with_key_opens_window(void** state) +{ + /* Self-set-up provider with token; /ai start opens a WIN_AI window. */ + prof_input("/ai set provider testprov https://example.test/"); + prof_timeout(5); + assert_true(prof_output_regex("Provider 'testprov' configured")); + prof_timeout_reset(); + + prof_input("/ai set token testprov sk-fake-test-key"); + + prof_timeout(5); + assert_true(prof_output_regex("API token set for provider: testprov")); + prof_timeout_reset(); + + prof_input("/ai start testprov model-x"); + + prof_timeout(5); + /* /ai start switches focus to the new WIN_AI window; the window prints + * "AI Chat: /" as its first line. */ + assert_true(prof_output_regex("AI Chat: testprov/model-x")); + prof_timeout_reset(); +} + +void +ai_clear_without_window_errors(void** state) +{ + /* /ai clear from the console (no active AI window) refuses with the + * shared "must be in AI chat window" guard used by /ai switch as well. */ + prof_input("/ai clear"); + + prof_timeout(5); + assert_true(prof_output_regex("Must be in an AI chat window")); + prof_timeout_reset(); +} + +void +ai_remove_provider_works(void** state) +{ + /* Round-trip: add -> verify present -> remove -> verify gone. */ + prof_input("/ai set provider tmpprov http://127.0.0.1:2/"); + prof_timeout(5); + assert_true(prof_output_regex("Provider 'tmpprov' configured")); + prof_timeout_reset(); + + prof_input("/ai remove provider tmpprov"); + prof_timeout(5); + assert_true(prof_output_regex("Provider 'tmpprov' removed")); + prof_timeout_reset(); + + prof_input("/ai remove provider tmpprov"); + prof_timeout(5); + assert_true(prof_output_regex("Provider 'tmpprov' not found")); + prof_timeout_reset(); +} + +void +ai_remove_provider_unknown_errors(void** state) +{ + /* Removing a provider that was never added must report "not found". */ + prof_input("/ai remove provider nonexistent_xyz"); + + prof_timeout(5); + assert_true(prof_output_regex("Provider 'nonexistent_xyz' not found")); + prof_timeout_reset(); +} + +void +ai_set_default_model_updates_provider(void** state) +{ + /* Setting a default model is acknowledged on the console. */ + prof_input("/ai set provider testprov https://example.test/"); + prof_timeout(5); + assert_true(prof_output_regex("Provider 'testprov' configured")); + prof_timeout_reset(); + + prof_input("/ai set default-model testprov model-preview"); + + prof_timeout(5); + assert_true(prof_output_regex("Default model for provider 'testprov' set to: model-preview")); + prof_timeout_reset(); +} + +void +ai_switch_without_window_errors(void** state) +{ + /* /ai switch with no active AI window refuses with the shared + * "must be in AI chat window" guard. */ + prof_input("/ai switch testprov model-x"); + + prof_timeout(5); + assert_true(prof_output_regex("Must be in an AI chat window")); + prof_timeout_reset(); +} + +void +ai_bad_subcommand_shows_usage(void** state) +{ + /* An unknown /ai subcommand should fall through to cons_bad_cmd_usage. */ + prof_input("/ai not_a_subcommand"); + + prof_timeout(5); + assert_true(prof_output_regex("Invalid usage, see '/help ai'")); + prof_timeout_reset(); +} diff --git a/tests/functionaltests/test_ai.h b/tests/functionaltests/test_ai.h new file mode 100644 index 00000000..6e63a02f --- /dev/null +++ b/tests/functionaltests/test_ai.h @@ -0,0 +1,27 @@ +#ifndef __H_FUNC_TEST_AI +#define __H_FUNC_TEST_AI + +/* + * AI tests don't exercise the XMPP path, but the test fixture's + * stbbr_stop() hangs indefinitely when no client ever connected to + * stabber. ai_init_test wraps init_prof_test with a prof_connect() + * so stabber sees a graceful disconnect at teardown. + */ +int ai_init_test(void** state); + +void ai_no_args_shows_help(void** state); +void ai_providers_lists_defaults(void** state); +void ai_providers_list_shows_key_status(void** state); +void ai_set_provider_adds_custom(void** state); +void ai_set_token_marks_key_set(void** state); +void ai_start_unknown_provider_errors(void** state); +void ai_start_without_key_errors(void** state); +void ai_start_with_key_opens_window(void** state); +void ai_clear_without_window_errors(void** state); +void ai_remove_provider_works(void** state); +void ai_remove_provider_unknown_errors(void** state); +void ai_set_default_model_updates_provider(void** state); +void ai_switch_without_window_errors(void** state); +void ai_bad_subcommand_shows_usage(void** state); + +#endif diff --git a/tests/unittests/test_ai_client.c b/tests/unittests/test_ai_client.c new file mode 100644 index 00000000..4a40b0d4 --- /dev/null +++ b/tests/unittests/test_ai_client.c @@ -0,0 +1,1608 @@ +#include "glib.h" +#include "prof_cmocka.h" +#include "common.h" +#include "ai/ai_client.h" +#include "config/preferences.h" +#include "helpers.h" +#include +#include + +/* ======================================================================== + * Setup/Teardown + * ======================================================================== */ + +int +ai_client_setup(void** state) +{ + ai_client_init(); + return 0; +} + +int +ai_client_teardown(void** state) +{ + ai_client_shutdown(); + return 0; +} + +/* ======================================================================== + * Provider Management Tests + * ======================================================================== */ + +void +test_ai_client_init(void** state) +{ + /* After init, at least one default provider must exist with a non-empty + * URL. We intentionally don't pin specific names or URLs — the source + * of truth lives in preferences.c (prefs_ai_get_providers) and the set + * of defaults is free to grow, shrink, or be renamed without breaking + * tests of unrelated functionality. */ + GList* providers = ai_list_providers(); + assert_non_null(providers); + assert_true(g_list_length(providers) >= 1); + + for (GList* curr = providers; curr; curr = g_list_next(curr)) { + AIProvider* p = curr->data; + assert_non_null(p); + assert_non_null(p->name); + assert_true(strlen(p->name) > 0); + assert_non_null(p->api_url); + assert_true(strlen(p->api_url) > 0); + } + + g_list_free(providers); +} + +void +test_ai_add_provider(void** state) +{ + /* Add a custom provider (hash table owns ref; caller gets non-owning pointer) */ + AIProvider* provider = ai_add_provider("custom", "https://custom.api.com/v1"); + assert_non_null(provider); + assert_string_equal("custom", provider->name); + assert_string_equal("https://custom.api.com/v1", provider->api_url); + + /* Update existing provider (returns ref; caller owns it) */ + AIProvider* updated = ai_add_provider("custom", "https://new.api.com/v1"); + assert_non_null(updated); + assert_string_equal("https://new.api.com/v1", updated->api_url); + + ai_provider_unref(updated); +} + +void +test_ai_remove_provider(void** state) +{ + /* Remove default provider should fail */ + assert_false(ai_remove_provider("nonexistent")); + + /* Add and remove custom provider */ + ai_add_provider("temp", "https://temp.api.com/v1"); + assert_true(ai_remove_provider("temp")); + assert_null(ai_get_provider("temp")); +} + +void +test_ai_list_providers(void** state) +{ + /* Count whatever defaults init produced — we only assert the *delta* + * after ai_add_provider, not the absolute number. Lets us survive any + * future change to how many built-in providers ship. */ + GList* before = ai_list_providers(); + gint baseline = before ? (gint)g_list_length(before) : 0; + g_list_free(before); + + ai_add_provider("test_list_extra", "https://example.test/extra/"); + GList* after = ai_list_providers(); + assert_int_equal(baseline + 1, g_list_length(after)); + g_list_free(after); +} + +/* ======================================================================== + * API Key Tests + * ======================================================================== */ + +void +test_ai_set_provider_key(void** state) +{ + ai_add_provider("p_setkey", "https://example.test/setkey/"); + + ai_set_provider_key("p_setkey", "sk-test-key-123"); + { + auto_gchar gchar* key = ai_get_provider_key("p_setkey"); + assert_non_null(key); + assert_string_equal("sk-test-key-123", key); + } + + /* Update key */ + ai_set_provider_key("p_setkey", "sk-new-key-456"); + { + auto_gchar gchar* key = ai_get_provider_key("p_setkey"); + assert_string_equal("sk-new-key-456", key); + } + + /* Remove key */ + ai_set_provider_key("p_setkey", NULL); + { + auto_gchar gchar* key = ai_get_provider_key("p_setkey"); + assert_null(key); + } +} + +void +test_ai_get_provider_key(void** state) +{ + ai_add_provider("p_getkey_a", "https://example.test/getkey-a/"); + ai_add_provider("p_getkey_b", "https://example.test/getkey-b/"); + + /* No key set initially */ + { + auto_gchar gchar* key = ai_get_provider_key("p_getkey_a"); + assert_null(key); + } + + /* Set and get key on one provider */ + ai_set_provider_key("p_getkey_b", "pplx-abc123"); + { + auto_gchar gchar* key = ai_get_provider_key("p_getkey_b"); + assert_non_null(key); + assert_string_equal("pplx-abc123", key); + } + + /* Other provider still has no key */ + { + auto_gchar gchar* key = ai_get_provider_key("p_getkey_a"); + assert_null(key); + } +} + +/* ======================================================================== + * Session Tests + * ======================================================================== */ + +void +test_ai_session_create(void** state) +{ + ai_add_provider("p_sess_create", "https://example.test/sess/"); + + AISession* session = ai_session_create("p_sess_create", "model-a"); + assert_non_null(session); + assert_string_equal("p_sess_create", session->provider_name); + assert_string_equal("model-a", session->model); + assert_null(session->api_key); /* No key set */ + + ai_session_unref(session); +} + +void +test_ai_session_ref_unref(void** state) +{ + ai_add_provider("p_sess_ref", "https://example.test/ref/"); + + AISession* session = ai_session_create("p_sess_ref", "model-a"); + assert_non_null(session); + + /* Reference */ + AISession* ref = ai_session_ref(session); + assert_true(ref == session); + + /* Unreference twice */ + ai_session_unref(session); + ai_session_unref(ref); /* Should free here */ +} + +void +test_ai_session_add_message(void** state) +{ + ai_add_provider("p_sess_add", "https://example.test/add/"); + + AISession* session = ai_session_create("p_sess_add", "model-a"); + assert_non_null(session); + + ai_session_add_message(session, "user", "Hello"); + ai_session_add_message(session, "assistant", "Hi there!"); + + assert_int_equal(2, g_list_length(session->history)); + + AIMessage* first = session->history->data; + assert_string_equal("user", first->role); + assert_string_equal("Hello", first->content); + + AIMessage* second = g_list_next(session->history)->data; + assert_string_equal("assistant", second->role); + assert_string_equal("Hi there!", second->content); + + ai_session_unref(session); +} + +void +test_ai_session_clear_history(void** state) +{ + ai_add_provider("p_sess_clear", "https://example.test/clear/"); + + AISession* session = ai_session_create("p_sess_clear", "model-a"); + + ai_session_add_message(session, "user", "Message 1"); + ai_session_add_message(session, "user", "Message 2"); + ai_session_add_message(session, "assistant", "Response"); + + assert_int_equal(3, g_list_length(session->history)); + + ai_session_clear_history(session); + assert_int_equal(0, g_list_length(session->history)); + + ai_session_unref(session); +} + +void +test_ai_session_set_model(void** state) +{ + ai_add_provider("p_sess_setmodel", "https://example.test/setmodel/"); + + AISession* session = ai_session_create("p_sess_setmodel", "model-a"); + assert_string_equal("model-a", session->model); + + ai_session_set_model(session, "model-b"); + assert_string_equal("model-b", session->model); + + ai_session_unref(session); +} + +/* ======================================================================== + * JSON Escape Tests + * ======================================================================== */ + +void +test_ai_json_escape(void** state) +{ + gchar* escaped = ai_json_escape("hello \"world\""); + assert_string_equal("hello \\\"world\\\"", escaped); + g_free(escaped); +} + +void +test_ai_json_escape_null(void** state) +{ + gchar* escaped = ai_json_escape(NULL); + assert_string_equal("", escaped); + g_free(escaped); +} + +void +test_ai_json_escape_empty(void** state) +{ + gchar* escaped = ai_json_escape(""); + assert_string_equal("", escaped); + g_free(escaped); +} + +void +test_ai_json_escape_special_chars(void** state) +{ + gchar* escaped = ai_json_escape("line1\nline2\ttab\\backslash\"quote"); + assert_string_equal("line1\\nline2\\ttab\\\\backslash\\\"quote", escaped); + g_free(escaped); +} + +void +test_ai_json_escape_percent_signs(void** state) +{ + /* Critical: % characters in content must be escaped for JSON, not treated as format specifiers */ + gchar* escaped = ai_json_escape("100% complete with %s and %d format strings"); + assert_string_equal("100% complete with %s and %d format strings", escaped); + g_free(escaped); +} + +void +test_ai_json_escape_backslash_quote(void** state) +{ + /* Test escaped quote handling */ + gchar* escaped = ai_json_escape("He said \"hello\" and \\ goodbye"); + assert_string_equal("He said \\\"hello\\\" and \\\\ goodbye", escaped); + g_free(escaped); +} + +void +test_ai_session_api_key_is_copied(void** state) +{ + /* Verify that session owns its own copy of the API key */ + ai_add_provider("p_keycopy", "https://example.test/keycopy/"); + ai_set_provider_key("p_keycopy", "sk-test-key-123"); + + AISession* session = ai_session_create("p_keycopy", "model-a"); + assert_non_null(session); + assert_string_equal("sk-test-key-123", session->api_key); + + /* Remove the provider key - session should still have its copy */ + ai_set_provider_key("p_keycopy", NULL); + assert_non_null(session->api_key); + assert_string_equal("sk-test-key-123", session->api_key); + + ai_session_unref(session); +} + +void +test_ai_add_provider_update_existing(void** state) +{ + /* Add a provider (hash table owns ref) */ + AIProvider* provider = ai_add_provider("custom", "https://first.api.com/v1"); + assert_non_null(provider); + assert_string_equal("https://first.api.com/v1", provider->api_url); + + /* Update the same provider (returns ref) */ + provider = ai_add_provider("custom", "https://second.api.com/v1"); + assert_non_null(provider); + assert_string_equal("https://second.api.com/v1", provider->api_url); + ai_provider_unref(provider); +} + +void +test_ai_add_provider_null_name_returns_null(void** state) +{ + assert_null(ai_add_provider(NULL, "https://api.com/v1")); +} + +void +test_ai_add_provider_null_url_returns_null(void** state) +{ + assert_null(ai_add_provider("test", NULL)); +} + +void +test_ai_session_create_null_provider_returns_null(void** state) +{ + assert_null(ai_session_create("nonexistent", "gpt-4")); +} + +void +test_ai_session_create_null_model_returns_null(void** state) +{ + ai_add_provider("p_create_nullmodel", "https://example.test/nm/"); + assert_null(ai_session_create("p_create_nullmodel", NULL)); +} + +void +test_ai_session_api_key_null_when_no_key_set(void** state) +{ + /* Provider without a stored key — session->api_key must be NULL. */ + ai_add_provider("p_nokey", "https://example.test/nokey/"); + AISession* session = ai_session_create("p_nokey", "model-a"); + assert_non_null(session); + assert_null(session->api_key); + ai_session_unref(session); +} + +/* ======================================================================== + * Provider Autocomplete Tests + * ======================================================================== */ + +void +test_ai_providers_find_forward(void** state) +{ + /* Distinctive prefix that won't collide with any current or future + * default. Self-add provider so the test doesn't depend on which + * built-ins ship. */ + ai_add_provider("zfind_one", "https://example.test/zf1/"); + + auto_gchar gchar* result = ai_providers_find("zfind", FALSE, NULL); + assert_non_null(result); + assert_string_equal("zfind_one", result); +} + +void +test_ai_providers_find_forward_custom(void** state) +{ + ai_add_provider("zcustom", "https://example.test/zcustom/"); + + auto_gchar gchar* result = ai_providers_find("zcustom", FALSE, NULL); + assert_non_null(result); + assert_string_equal("zcustom", result); +} + +void +test_ai_providers_find_forward_no_match(void** state) +{ + /* Prefix designed to match no provider — neither defaults nor any + * we ourselves added. */ + auto_gchar gchar* result = ai_providers_find("__nope__", FALSE, NULL); + assert_null(result); +} + +void +test_ai_providers_find_forward_partial_match(void** state) +{ + ai_add_provider("zpartial_xyz", "https://example.test/zp/"); + auto_gchar gchar* result = ai_providers_find("zpart", FALSE, NULL); + assert_non_null(result); + assert_string_equal("zpartial_xyz", result); +} + +void +test_ai_providers_find_next(void** state) +{ + /* Two consecutive calls with the same prefix and only one matching + * provider must return that same provider both times. */ + ai_add_provider("znext_one", "https://example.test/zn1/"); + + auto_gchar gchar* result1 = ai_providers_find("znext", FALSE, NULL); + assert_non_null(result1); + assert_string_equal("znext_one", result1); + + auto_gchar gchar* result2 = ai_providers_find("znext", FALSE, NULL); + assert_non_null(result2); + assert_string_equal("znext_one", result2); +} + +void +test_ai_providers_find_previous(void** state) +{ + /* With only one match the previous-direction call still returns it. */ + ai_add_provider("zprev_one", "https://example.test/zp1/"); + + auto_gchar gchar* result1 = ai_providers_find("zprev", FALSE, NULL); + assert_non_null(result1); + assert_string_equal("zprev_one", result1); + + auto_gchar gchar* result2 = ai_providers_find("zprev", TRUE, NULL); + assert_non_null(result2); + assert_string_equal("zprev_one", result2); +} + +void +test_ai_providers_find_empty_search_str(void** state) +{ + auto_gchar gchar* result = ai_providers_find("", FALSE, NULL); + assert_non_null(result); +} + +/* + * ai_providers_find(NULL, ...) currently SIGSEGVs in libglib's + * __strlen_avx2: autocomplete_complete() stores g_strdup(NULL) == NULL + * in ac->search_str, and the downstream _search() forwards that NULL into + * g_str_to_ascii(NULL, NULL), which is undefined. + * + * The crash isn't reachable from production today (cmd_ac.c's + * _autocomplete_param_common always passes a non-NULL malloc'd substring), + * but the public ai_providers_find() signature accepts NULL and the test + * documents that it should not crash. The test is intentionally not + * registered in unittests.c while the NULL path remains a SIGSEGV — see + * AI_AUTOCOMPLETE_POSSIBLE_ISSUES.md, potential issue 1. + */ +void +test_ai_providers_find_null_search_str(void** state) +{ + auto_gchar gchar* result = ai_providers_find(NULL, FALSE, NULL); + assert_non_null(result); +} + +void +test_ai_providers_find_case_insensitive(void** state) +{ + /* Matching is case-insensitive (via g_ascii_strdown). Use a self-added + * provider with a distinctive lowercased name. */ + ai_add_provider("zcase_marker", "https://example.test/zcm/"); + + auto_gchar gchar* r1 = ai_providers_find("ZCASE_MARKER", FALSE, NULL); + assert_non_null(r1); + assert_string_equal("zcase_marker", r1); + + auto_gchar gchar* r2 = ai_providers_find("Zcase_Marker", FALSE, NULL); + assert_non_null(r2); + assert_string_equal("zcase_marker", r2); + + auto_gchar gchar* r3 = ai_providers_find("zcase_marker", FALSE, NULL); + assert_non_null(r3); + assert_string_equal("zcase_marker", r3); +} + +/* ======================================================================== + * AI Autocomplete Integration Tests + * ======================================================================== */ + +void +test_ai_start_provider_autocomplete_only_on_exact(void** state) +{ + /* Empty search should return *some* provider — exact name depends on + * which defaults shipped. We only care that the call doesn't crash and + * yields a non-NULL result. */ + auto_gchar gchar* result = ai_providers_find("", FALSE, NULL); + assert_non_null(result); +} + +void +test_ai_providers_find_cycling(void** state) +{ + /* Add two distinct providers; empty-prefix cycling should walk through + * both (defaults may also be present, but the assertion only cares that + * two consecutive calls return different providers). */ + ai_add_provider("zcycle_a", "https://example.test/zca/"); + ai_add_provider("zcycle_b", "https://example.test/zcb/"); + + auto_gchar gchar* result1 = ai_providers_find("", FALSE, NULL); + assert_non_null(result1); + + auto_gchar gchar* result2 = ai_providers_find("", FALSE, NULL); + assert_non_null(result2); + + assert_string_not_equal(result1, result2); +} + +/* ======================================================================== + * Provider Default Model and Settings Tests + * ======================================================================== */ + +void +test_ai_set_provider_default_model(void** state) +{ + ai_add_provider("p_dm_set_a", "https://example.test/dma/"); + ai_add_provider("p_dm_set_b", "https://example.test/dmb/"); + + ai_set_provider_default_model("p_dm_set_a", "model-1"); + + const gchar* model = ai_get_provider_default_model("p_dm_set_a"); + assert_non_null(model); + assert_string_equal("model-1", model); + + /* Update default model */ + ai_set_provider_default_model("p_dm_set_a", "model-1-update"); + model = ai_get_provider_default_model("p_dm_set_a"); + assert_non_null(model); + assert_string_equal("model-1-update", model); + + /* Independent default model on a different provider */ + ai_set_provider_default_model("p_dm_set_b", "model-2"); + model = ai_get_provider_default_model("p_dm_set_b"); + assert_non_null(model); + assert_string_equal("model-2", model); +} + +void +test_ai_get_provider_default_model(void** state) +{ + /* NULL provider returns NULL */ + assert_null(ai_get_provider_default_model(NULL)); + + /* Non-existent provider returns NULL */ + assert_null(ai_get_provider_default_model("nonexistent")); + + ai_add_provider("p_dm_get", "https://example.test/dmg/"); + + /* After setting, it returns the model */ + ai_set_provider_default_model("p_dm_get", "test-model"); + assert_string_equal("test-model", ai_get_provider_default_model("p_dm_get")); + + /* NULL model argument is ignored (no change) */ + ai_set_provider_default_model("p_dm_get", NULL); + assert_string_equal("test-model", ai_get_provider_default_model("p_dm_get")); +} + +void +test_ai_set_provider_setting(void** state) +{ + ai_add_provider("p_set_settings", "https://example.test/sset/"); + + /* NULL provider should be ignored */ + ai_set_provider_setting(NULL, "tools", "enabled"); + + /* NULL setting should be ignored */ + ai_set_provider_setting("p_set_settings", NULL, "enabled"); + + /* Non-existent provider should log warning but not crash */ + ai_set_provider_setting("nonexistent", "tools", "enabled"); + + /* Set settings for our provider */ + ai_set_provider_setting("p_set_settings", "tools", "enabled"); + ai_set_provider_setting("p_set_settings", "search", "disabled"); + + /* Verify settings were set */ + gchar* tools = ai_get_provider_setting("p_set_settings", "tools"); + assert_non_null(tools); + assert_string_equal("enabled", tools); + g_free(tools); + + gchar* search = ai_get_provider_setting("p_set_settings", "search"); + assert_non_null(search); + assert_string_equal("disabled", search); + g_free(search); + + /* NULL value removes the setting */ + ai_set_provider_setting("p_set_settings", "tools", NULL); + tools = ai_get_provider_setting("p_set_settings", "tools"); + assert_null(tools); + + /* Setting that was never set returns NULL */ + assert_null(ai_get_provider_setting("p_set_settings", "nonexistent_setting")); +} + +void +test_ai_get_provider_setting(void** state) +{ + ai_add_provider("p_get_settings_a", "https://example.test/gseta/"); + ai_add_provider("p_get_settings_b", "https://example.test/gsetb/"); + + /* NULL provider returns NULL */ + assert_null(ai_get_provider_setting(NULL, "tools")); + + /* NULL setting returns NULL */ + assert_null(ai_get_provider_setting("p_get_settings_a", NULL)); + + /* Non-existent provider returns NULL */ + assert_null(ai_get_provider_setting("nonexistent", "tools")); + + /* Provider with no settings configured returns NULL */ + assert_null(ai_get_provider_setting("p_get_settings_b", "tools")); + + /* Set and get setting */ + ai_set_provider_setting("p_get_settings_a", "temperature", "0.7"); + gchar* temp = ai_get_provider_setting("p_get_settings_a", "temperature"); + assert_non_null(temp); + assert_string_equal("0.7", temp); + g_free(temp); + + /* Setting value is a copy (caller must free) */ + ai_set_provider_setting("p_get_settings_a", "max_tokens", "2048"); + temp = ai_get_provider_setting("p_get_settings_a", "max_tokens"); + assert_non_null(temp); + assert_string_equal("2048", temp); + g_free(temp); +} + +/* ======================================================================== + * Model Caching Tests + * ======================================================================== */ + +void +test_ai_models_are_fresh_initial(void** state) +{ + /* NULL provider returns FALSE */ + assert_false(ai_models_are_fresh(NULL)); + + /* Non-existent provider returns FALSE */ + assert_false(ai_models_are_fresh("nonexistent")); + + /* A freshly-added provider with no cached models should return FALSE + * (or at least not crash). */ + ai_add_provider("p_fresh", "https://example.test/fresh/"); + gboolean fresh = ai_models_are_fresh("p_fresh"); + assert_true(fresh == TRUE || fresh == FALSE); +} + +/* ======================================================================== + * Model Parsing Tests + * ======================================================================== */ + +void +test_ai_parse_models_openai_format(void** state) +{ + /* Test parsing OpenAI-compatible API response format */ + /* Format: {"object":"list","data":[{"id":"model1",...},...]} */ + ai_client_init(); + + AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1"); + + /* JSON response from Perplexity/OpenAI API */ + const gchar* json = "{\"object\":\"list\"," + "\"data\":[" + "{\"id\":\"anthropic/claude-haiku-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"}," + "{\"id\":\"anthropic/claude-opus-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"}," + "{\"id\":\"openai/gpt-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"}," + "{\"id\":\"google/gemini-3-flash-preview\",\"object\":\"model\",\"created\":0,\"owned_by\":\"google\"}" + "]}"; + + ai_parse_models_from_json(provider, json); + + /* Verify models were parsed correctly */ + assert_int_equal(4, g_list_length(provider->models)); + + /* Check specific models */ + GList* models = provider->models; + assert_string_equal("anthropic/claude-haiku-4-5", models->data); + models = g_list_next(models); + assert_string_equal("anthropic/claude-opus-4-5", models->data); + models = g_list_next(models); + assert_string_equal("openai/gpt-5", models->data); + models = g_list_next(models); + assert_string_equal("google/gemini-3-flash-preview", models->data); + + ai_client_shutdown(); +} + +void +test_ai_parse_models_perplexity_format(void** state) +{ + /* Test parsing actual Perplexity API response with all models from curl test */ + ai_client_init(); + + AIProvider* provider = ai_add_provider("p_models_perplex_fmt", "https://example.test/pmpf/"); + + const gchar* json = "{\"object\":\"list\"," + "\"data\":[" + "{\"id\":\"anthropic/claude-haiku-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"}," + "{\"id\":\"anthropic/claude-opus-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"}," + "{\"id\":\"anthropic/claude-opus-4-6\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"}," + "{\"id\":\"anthropic/claude-opus-4-7\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"}," + "{\"id\":\"anthropic/claude-sonnet-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"}," + "{\"id\":\"anthropic/claude-sonnet-4-6\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"}," + "{\"id\":\"google/gemini-3-flash-preview\",\"object\":\"model\",\"created\":0,\"owned_by\":\"google\"}," + "{\"id\":\"google/gemini-3.1-pro-preview\",\"object\":\"model\",\"created\":0,\"owned_by\":\"google\"}," + "{\"id\":\"nvidia/nemotron-3-super-120b-a12b\",\"object\":\"model\",\"created\":0,\"owned_by\":\"nvidia\"}," + "{\"id\":\"openai/gpt-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"}," + "{\"id\":\"openai/gpt-5-mini\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"}," + "{\"id\":\"openai/gpt-5.1\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"}," + "{\"id\":\"openai/gpt-5.2\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"}," + "{\"id\":\"openai/gpt-5.4\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"}," + "{\"id\":\"openai/gpt-5.4-mini\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"}," + "{\"id\":\"openai/gpt-5.4-nano\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"}," + "{\"id\":\"openai/gpt-5.5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"}," + "{\"id\":\"perplexity/sonar\",\"object\":\"model\",\"created\":0,\"owned_by\":\"perplexity\"}," + "{\"id\":\"xai/grok-4-1-fast-non-reasoning\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"}," + "{\"id\":\"xai/grok-4.20-multi-agent\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"}," + "{\"id\":\"xai/grok-4.20-non-reasoning\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"}," + "{\"id\":\"xai/grok-4.20-reasoning\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"}," + "{\"id\":\"xai/grok-4.3\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"}" + "]}"; + + ai_parse_models_from_json(provider, json); + + /* Verify all 23 models were parsed correctly */ + assert_int_equal(23, g_list_length(provider->models)); + + /* Verify NO field names or owned_by values are in the model list */ + /* The bug was that "id", "object", "model", "created", "owned_by" and provider names + * like "anthropic", "google", "openai", "nvidia", "perplexity", "xai" were extracted */ + GList* models = provider->models; + while (models) { + const gchar* model = (const gchar*)models->data; + /* These should never be model IDs */ + assert_false(g_strcmp0(model, "id") == 0); + assert_false(g_strcmp0(model, "object") == 0); + assert_false(g_strcmp0(model, "model") == 0); + assert_false(g_strcmp0(model, "created") == 0); + assert_false(g_strcmp0(model, "owned_by") == 0); + assert_false(g_strcmp0(model, "list") == 0); + /* These are owned_by values, not model IDs */ + assert_false(g_strcmp0(model, "anthropic") == 0); + assert_false(g_strcmp0(model, "google") == 0); + assert_false(g_strcmp0(model, "openai") == 0); + assert_false(g_strcmp0(model, "nvidia") == 0); + assert_false(g_strcmp0(model, "perplexity") == 0); + assert_false(g_strcmp0(model, "xai") == 0); + models = g_list_next(models); + } + + /* Verify first and last model IDs */ + models = provider->models; + assert_string_equal("anthropic/claude-haiku-4-5", models->data); + models = g_list_last(models); + assert_string_equal("xai/grok-4.3", models->data); + + ai_client_shutdown(); +} + +void +test_ai_parse_models_array_format(void** state) +{ + /* Test parsing simple array format: ["model1", "model2", ...] */ + ai_client_init(); + + AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1"); + + const gchar* json = "[\"model1\", \"model2\", \"model3\"]"; + + ai_parse_models_from_json(provider, json); + + assert_int_equal(3, g_list_length(provider->models)); + + GList* models = provider->models; + assert_string_equal("model1", models->data); + models = g_list_next(models); + assert_string_equal("model2", models->data); + models = g_list_next(models); + assert_string_equal("model3", models->data); + + ai_client_shutdown(); +} + +void +test_ai_parse_models_empty_json(void** state) +{ + /* Test parsing empty/invalid JSON */ + ai_client_init(); + + AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1"); + + ai_parse_models_from_json(provider, ""); + assert_null(provider->models); + assert_int_equal(0, g_list_length(provider->models)); + + ai_parse_models_from_json(provider, "{\"invalid\": true}"); + assert_null(provider->models); + assert_int_equal(0, g_list_length(provider->models)); + + ai_client_shutdown(); +} + +void +test_ai_parse_models_null_handling(void** state) +{ + /* Test NULL handling */ + ai_client_init(); + + AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1"); + + /* NULL json should not crash */ + ai_parse_models_from_json(provider, NULL); + assert_null(provider->models); + + ai_client_shutdown(); +} + +void +test_ai_parse_models_escaped_quotes(void** state) +{ + /* Test parsing model IDs with escaped quotes */ + ai_client_init(); + + AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1"); + + const gchar* json = "{\"object\":\"list\"," + "\"data\":[" + "{\"id\":\"test/model\\\"with\\\"quotes\",\"object\":\"model\",\"created\":0,\"owned_by\":\"test\"}" + "]}"; + + ai_parse_models_from_json(provider, json); + + assert_int_equal(1, g_list_length(provider->models)); + /* Model ID should have the escaped quotes handled */ + GList* models = provider->models; + assert_non_null(models); + + ai_client_shutdown(); +} + +void +test_ai_parse_models_with_whitespace(void** state) +{ + /* Test parsing JSON with whitespace after colons */ + ai_client_init(); + + AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1"); + + /* JSON with spaces after colons (common in formatted JSON) */ + const gchar* json = "{ \"object\": \"list\", " + "\"data\": [ " + "{ \"id\": \"model-with-spaces\", \"object\": \"model\", \"created\": 0, \"owned_by\": \"test\" }" + "]}"; + + ai_parse_models_from_json(provider, json); + + assert_int_equal(1, g_list_length(provider->models)); + GList* models = provider->models; + assert_string_equal("model-with-spaces", models->data); + + ai_client_shutdown(); +} + +/* ======================================================================== + * Setup combining prefs + ai_client for round-trip persistence tests + * ======================================================================== */ + +int +ai_client_setup_with_prefs(void** state) +{ + if (load_preferences(state) != 0) { + return 1; + } + ai_client_init(); + return 0; +} + +int +ai_client_teardown_with_prefs(void** state) +{ + ai_client_shutdown(); + close_preferences(state); + return 0; +} + +/* ======================================================================== + * Chat response parser tests (ai_parse_response) + * ======================================================================== */ + +void +test_ai_parse_response_openai_content(void** state) +{ + const gchar* json = "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Hello, world!\"}}]}"; + auto_gchar gchar* out = ai_parse_response(json); + assert_non_null(out); + assert_string_equal("Hello, world!", out); +} + +void +test_ai_parse_response_perplexity_text(void** state) +{ + const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"Search result\",\"type\":\"output_text\"}]}]}"; + auto_gchar gchar* out = ai_parse_response(json); + assert_non_null(out); + assert_string_equal("Search result", out); +} + +void +test_ai_parse_response_text_preferred_over_content(void** state) +{ + /* When both formats are present, "text" wins (Perplexity path tried first). */ + const gchar* json = "{\"text\":\"from text field\",\"content\":\"from content field\"}"; + auto_gchar gchar* out = ai_parse_response(json); + assert_non_null(out); + assert_string_equal("from text field", out); +} + +void +test_ai_parse_response_escaped_quote(void** state) +{ + const gchar* json = "{\"content\":\"He said \\\"hello\\\" today\"}"; + auto_gchar gchar* out = ai_parse_response(json); + assert_non_null(out); + assert_string_equal("He said \"hello\" today", out); +} + +void +test_ai_parse_response_newline_escape(void** state) +{ + const gchar* json = "{\"content\":\"line one\\nline two\"}"; + auto_gchar gchar* out = ai_parse_response(json); + assert_non_null(out); + assert_string_equal("line one\nline two", out); +} + +void +test_ai_parse_response_empty_content(void** state) +{ + const gchar* json = "{\"content\":\"\"}"; + auto_gchar gchar* out = ai_parse_response(json); + assert_non_null(out); + assert_string_equal("", out); +} + +void +test_ai_parse_response_missing_field(void** state) +{ + const gchar* json = "{\"foo\":\"bar\"}"; + gchar* out = ai_parse_response(json); + assert_null(out); +} + +void +test_ai_parse_response_null_input(void** state) +{ + assert_null(ai_parse_response(NULL)); +} + +void +test_ai_parse_response_empty_input(void** state) +{ + assert_null(ai_parse_response("")); +} + +void +test_ai_parse_response_percent_signs_safe(void** state) +{ + /* Format-string safety: %s, %d in content must not be interpreted later. */ + const gchar* json = "{\"content\":\"100% done with %s and %d markers\"}"; + auto_gchar gchar* out = ai_parse_response(json); + assert_non_null(out); + assert_string_equal("100% done with %s and %d markers", out); +} + +void +test_ai_parse_response_braces_in_content(void** state) +{ + /* Content containing JSON-looking braces should not confuse the parser. */ + const gchar* json = "{\"content\":\"use {key: value} syntax\"}"; + auto_gchar gchar* out = ai_parse_response(json); + assert_non_null(out); + assert_string_equal("use {key: value} syntax", out); +} + +void +test_ai_parse_response_multiline_content(void** state) +{ + const gchar* json = "{\"content\":\"para 1\\n\\npara 2\\nlast\"}"; + auto_gchar gchar* out = ai_parse_response(json); + assert_non_null(out); + assert_string_equal("para 1\n\npara 2\nlast", out); +} + +/* ======================================================================== + * Error response parser tests (ai_parse_error_message) + * ======================================================================== */ + +void +test_ai_parse_error_standard_envelope(void** state) +{ + const gchar* json = "{\"error\":{\"message\":\"Invalid API key provided\",\"type\":\"invalid_request_error\",\"code\":\"invalid_api_key\"}}"; + auto_gchar gchar* out = ai_parse_error_message(json); + assert_non_null(out); + assert_string_equal("Invalid API key provided", out); +} + +void +test_ai_parse_error_with_escapes(void** state) +{ + const gchar* json = "{\"error\":{\"message\":\"Field \\\"model\\\" is required\"}}"; + auto_gchar gchar* out = ai_parse_error_message(json); + assert_non_null(out); + assert_string_equal("Field \"model\" is required", out); +} + +void +test_ai_parse_error_no_error_field(void** state) +{ + const gchar* json = "{\"message\":\"this is not in an error envelope\"}"; + gchar* out = ai_parse_error_message(json); + assert_null(out); +} + +void +test_ai_parse_error_no_message_field(void** state) +{ + const gchar* json = "{\"error\":{\"type\":\"some_error\",\"code\":42}}"; + gchar* out = ai_parse_error_message(json); + assert_null(out); +} + +void +test_ai_parse_error_null_input(void** state) +{ + assert_null(ai_parse_error_message(NULL)); +} + +void +test_ai_parse_error_empty_input(void** state) +{ + assert_null(ai_parse_error_message("")); +} + +void +test_ai_parse_error_tab_escape(void** state) +{ + const gchar* json = "{\"error\":{\"message\":\"col1\\tcol2\"}}"; + auto_gchar gchar* out = ai_parse_error_message(json); + assert_non_null(out); + assert_string_equal("col1\tcol2", out); +} + +void +test_ai_parse_error_backslash_escape(void** state) +{ + const gchar* json = "{\"error\":{\"message\":\"path C:\\\\Users\\\\x\"}}"; + auto_gchar gchar* out = ai_parse_error_message(json); + assert_non_null(out); + assert_string_equal("path C:\\Users\\x", out); +} + +/* ======================================================================== + * Extended JSON escape tests + * ======================================================================== */ + +void +test_ai_json_escape_backspace(void** state) +{ + auto_gchar gchar* out = ai_json_escape("a\bb"); + assert_string_equal("a\\bb", out); +} + +void +test_ai_json_escape_formfeed(void** state) +{ + auto_gchar gchar* out = ai_json_escape("a\fb"); + assert_string_equal("a\\fb", out); +} + +void +test_ai_json_escape_carriage_return(void** state) +{ + auto_gchar gchar* out = ai_json_escape("a\rb"); + assert_string_equal("a\\rb", out); +} + +void +test_ai_json_escape_all_specials_combined(void** state) +{ + auto_gchar gchar* out = ai_json_escape("\"\\/\b\f\n\r\t"); + /* `/` is not escaped by the encoder (valid JSON either way). */ + assert_string_equal("\\\"\\\\/\\b\\f\\n\\r\\t", out); +} + +void +test_ai_json_escape_passes_utf8_through(void** state) +{ + /* UTF-8 bytes are valid in JSON strings and should pass through unchanged. */ + auto_gchar gchar* out = ai_json_escape("Привет 🦀"); + assert_string_equal("Привет 🦀", out); +} + +/* ======================================================================== + * Autocomplete cycling with many providers + * ======================================================================== */ + +void +test_ai_providers_find_cycles_through_many(void** state) +{ + /* Add several providers sharing a prefix; cycling should visit each. */ + ai_add_provider("alpha", "https://a.example/"); + ai_add_provider("beta", "https://b.example/"); + ai_add_provider("alphabet", "https://ab.example/"); + ai_add_provider("alpine", "https://al.example/"); + + /* Three providers share prefix "alp": alpha, alphabet, alpine. */ + GHashTable* seen = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, NULL); + + for (int i = 0; i < 3; i++) { + auto_gchar gchar* match = ai_providers_find("alp", FALSE, NULL); + assert_non_null(match); + /* Each call should return a result that starts with "alp". */ + assert_true(g_str_has_prefix(match, "alp")); + g_hash_table_add(seen, g_strdup(match)); + } + + /* All three should have been seen (deterministic cycling). */ + assert_int_equal(3, g_hash_table_size(seen)); + + g_hash_table_destroy(seen); +} + +void +test_ai_providers_find_previous_walks_backwards(void** state) +{ + ai_add_provider("alpha", "https://a/"); + ai_add_provider("alphabet", "https://ab/"); + ai_add_provider("alpine", "https://al/"); + + /* Forward then backward should yield matches with the shared prefix. */ + auto_gchar gchar* forward = ai_providers_find("alp", FALSE, NULL); + auto_gchar gchar* back = ai_providers_find("alp", TRUE, NULL); + + assert_non_null(forward); + assert_non_null(back); + assert_true(g_str_has_prefix(forward, "alp")); + assert_true(g_str_has_prefix(back, "alp")); +} + +void +test_ai_providers_find_wraps_around(void** state) +{ + ai_add_provider("alpha", "https://a/"); + ai_add_provider("alphabet", "https://ab/"); + + /* Two matches; four forward calls should produce a repeat. */ + auto_gchar gchar* r1 = ai_providers_find("alp", FALSE, NULL); + auto_gchar gchar* r2 = ai_providers_find("alp", FALSE, NULL); + auto_gchar gchar* r3 = ai_providers_find("alp", FALSE, NULL); + auto_gchar gchar* r4 = ai_providers_find("alp", FALSE, NULL); + + assert_non_null(r1); + assert_non_null(r2); + assert_non_null(r3); + assert_non_null(r4); + + /* The cycle of 2 must repeat: r1 == r3 and r2 == r4 (in some order). */ + assert_int_equal(0, g_strcmp0(r1, r3)); + assert_int_equal(0, g_strcmp0(r2, r4)); + assert_int_not_equal(0, g_strcmp0(r1, r2)); +} + +/* ======================================================================== + * Session edge cases + * ======================================================================== */ + +void +test_ai_session_add_message_null_session_no_crash(void** state) +{ + /* Should silently no-op rather than dereference NULL. */ + ai_session_add_message(NULL, "user", "hello"); +} + +void +test_ai_session_add_message_null_role_no_crash(void** state) +{ + ai_add_provider("p_addmsg_nullrole", "https://example.test/anr/"); + AISession* s = ai_session_create("p_addmsg_nullrole", "model-a"); + assert_non_null(s); + ai_session_add_message(s, NULL, "content"); + assert_int_equal(0, g_list_length(s->history)); + ai_session_unref(s); +} + +void +test_ai_session_add_message_null_content_no_crash(void** state) +{ + ai_add_provider("p_addmsg_nullcontent", "https://example.test/anc/"); + AISession* s = ai_session_create("p_addmsg_nullcontent", "model-a"); + assert_non_null(s); + ai_session_add_message(s, "user", NULL); + assert_int_equal(0, g_list_length(s->history)); + ai_session_unref(s); +} + +void +test_ai_session_history_preserves_large_order(void** state) +{ + ai_add_provider("p_history_order", "https://example.test/ho/"); + AISession* s = ai_session_create("p_history_order", "model-a"); + assert_non_null(s); + + for (int i = 0; i < 100; i++) { + auto_gchar gchar* content = g_strdup_printf("msg-%d", i); + ai_session_add_message(s, (i % 2 == 0) ? "user" : "assistant", content); + } + + assert_int_equal(100, g_list_length(s->history)); + + GList* curr = s->history; + for (int i = 0; i < 100 && curr; i++, curr = g_list_next(curr)) { + AIMessage* msg = curr->data; + auto_gchar gchar* expected = g_strdup_printf("msg-%d", i); + assert_string_equal(expected, msg->content); + assert_string_equal((i % 2 == 0) ? "user" : "assistant", msg->role); + } + + ai_session_unref(s); +} + +void +test_ai_session_clear_empty_history(void** state) +{ + ai_add_provider("p_clear_empty", "https://example.test/ce/"); + AISession* s = ai_session_create("p_clear_empty", "model-a"); + assert_non_null(s); + ai_session_clear_history(s); + assert_int_equal(0, g_list_length(s->history)); + ai_session_unref(s); +} + +void +test_ai_session_set_model_null_keeps_old(void** state) +{ + ai_add_provider("p_setmodel_null", "https://example.test/smn/"); + AISession* s = ai_session_create("p_setmodel_null", "model-original"); + assert_non_null(s); + ai_session_set_model(s, NULL); + assert_string_equal("model-original", s->model); + ai_session_unref(s); +} + +void +test_ai_session_unref_null_no_crash(void** state) +{ + ai_session_unref(NULL); +} + +void +test_ai_session_ref_null_returns_null(void** state) +{ + assert_null(ai_session_ref(NULL)); +} + +/* ======================================================================== + * Provider edge cases + * ======================================================================== */ + +void +test_ai_get_provider_null_returns_null(void** state) +{ + assert_null(ai_get_provider(NULL)); +} + +void +test_ai_remove_provider_null_returns_false(void** state) +{ + assert_false(ai_remove_provider(NULL)); +} + +void +test_ai_remove_provider_twice_second_fails(void** state) +{ + ai_add_provider("once", "https://once/"); + assert_true(ai_remove_provider("once")); + assert_false(ai_remove_provider("once")); +} + +void +test_ai_provider_survives_via_session_after_removal(void** state) +{ + ai_add_provider("temp_prov", "https://temp/"); + ai_set_provider_key("temp_prov", "test-key"); + + AISession* s = ai_session_create("temp_prov", "model-x"); + assert_non_null(s); + + /* Remove from registry while session holds a ref. */ + assert_true(ai_remove_provider("temp_prov")); + assert_null(ai_get_provider("temp_prov")); + + /* Session's provider pointer must still be valid. */ + assert_non_null(s->provider); + assert_string_equal("temp_prov", s->provider->name); + assert_string_equal("https://temp/", s->provider->api_url); + + ai_session_unref(s); +} + +/* ======================================================================== + * Settings edge cases + * ======================================================================== */ + +void +test_ai_settings_multiple_keys_independent(void** state) +{ + ai_add_provider("p_settings_multi", "https://example.test/sm/"); + + ai_set_provider_setting("p_settings_multi", "tools", "enabled"); + ai_set_provider_setting("p_settings_multi", "search", "disabled"); + + auto_gchar gchar* tools = ai_get_provider_setting("p_settings_multi", "tools"); + auto_gchar gchar* search = ai_get_provider_setting("p_settings_multi", "search"); + + assert_string_equal("enabled", tools); + assert_string_equal("disabled", search); +} + +void +test_ai_settings_get_missing_returns_null(void** state) +{ + ai_add_provider("p_settings_missing", "https://example.test/smi/"); + gchar* nope = ai_get_provider_setting("p_settings_missing", "not_set"); + assert_null(nope); +} + +void +test_ai_settings_isolated_between_providers(void** state) +{ + /* Distinct URLs to make accidental cross-talk visible if it ever happens. */ + ai_add_provider("p_settings_iso_a", "https://example.test/sia/"); + ai_add_provider("p_settings_iso_b", "https://example.test/sib/"); + + ai_set_provider_setting("p_settings_iso_a", "tools", "yes"); + ai_set_provider_setting("p_settings_iso_b", "tools", "no"); + + auto_gchar gchar* a = ai_get_provider_setting("p_settings_iso_a", "tools"); + auto_gchar gchar* b = ai_get_provider_setting("p_settings_iso_b", "tools"); + + assert_string_equal("yes", a); + assert_string_equal("no", b); +} + +/* ======================================================================== + * Model parsing edge cases + * ======================================================================== */ + +void +test_ai_parse_models_data_not_array(void** state) +{ + AIProvider* p = ai_add_provider("p1", "https://p/"); + assert_non_null(p); + + /* "data" present but not an array — parser should bail without crashing. */ + const gchar* json = "{\"data\":\"not-an-array\"}"; + ai_parse_models_from_json(p, json); + + assert_int_equal(0, g_list_length(p->models)); +} + +void +test_ai_parse_models_empty_data_array(void** state) +{ + AIProvider* p = ai_add_provider("p1", "https://p/"); + assert_non_null(p); + + const gchar* json = "{\"object\":\"list\",\"data\":[]}"; + ai_parse_models_from_json(p, json); + + assert_int_equal(0, g_list_length(p->models)); +} + +void +test_ai_parse_models_id_outside_data_ignored(void** state) +{ + AIProvider* p = ai_add_provider("p1", "https://p/"); + assert_non_null(p); + + /* "id" outside a data-array object must not be picked up. */ + const gchar* json = "{\"request_id\":\"req-123\",\"data\":[]}"; + ai_parse_models_from_json(p, json); + + assert_int_equal(0, g_list_length(p->models)); +} + +void +test_ai_parse_models_multiple_models(void** state) +{ + AIProvider* p = ai_add_provider("p1", "https://p/"); + assert_non_null(p); + + const gchar* json = "{\"object\":\"list\",\"data\":[" + "{\"id\":\"m-1\",\"object\":\"model\"}," + "{\"id\":\"m-2\",\"object\":\"model\"}," + "{\"id\":\"m-3\",\"object\":\"model\"}" + "]}"; + ai_parse_models_from_json(p, json); + + assert_int_equal(3, g_list_length(p->models)); +} + +/* ======================================================================== + * Prefs round-trip tests + * + * These use ai_client_setup_with_prefs which initializes prefs first. + * ai_set_provider_key writes through to prefs_ai_set_token which then + * calls _save_prefs(); a subsequent shutdown+init should reload the key. + * ======================================================================== */ + +void +test_ai_prefs_round_trip_api_key(void** state) +{ + ai_add_provider("p_persist_one", "https://example.test/p1/"); + ai_set_provider_key("p_persist_one", "sk-persisted-123"); + + { + auto_gchar gchar* before = ai_get_provider_key("p_persist_one"); + assert_non_null(before); + assert_string_equal("sk-persisted-123", before); + } + + /* Cycle the AI client; prefs stay loaded across this. */ + ai_client_shutdown(); + ai_client_init(); + + auto_gchar gchar* after = ai_get_provider_key("p_persist_one"); + assert_non_null(after); + assert_string_equal("sk-persisted-123", after); +} + +void +test_ai_prefs_round_trip_remove_key(void** state) +{ + ai_add_provider("p_persist_remove", "https://example.test/pr/"); + ai_set_provider_key("p_persist_remove", "sk-to-be-removed"); + ai_set_provider_key("p_persist_remove", NULL); + + ai_client_shutdown(); + ai_client_init(); + + auto_gchar gchar* after = ai_get_provider_key("p_persist_remove"); + assert_null(after); +} + +void +test_ai_prefs_multiple_providers_persist(void** state) +{ + /* Two providers with distinct URLs so a future bug that swaps URLs + * between providers would be visible (we don't assert URLs here, but + * keeping them distinct keeps the test artifact useful). */ + ai_add_provider("p_persist_multi_a", "https://example.test/pma/"); + ai_add_provider("p_persist_multi_b", "https://example.test/pmb/"); + ai_set_provider_key("p_persist_multi_a", "sk-multi-a"); + ai_set_provider_key("p_persist_multi_b", "sk-multi-b"); + + ai_client_shutdown(); + ai_client_init(); + + auto_gchar gchar* k1 = ai_get_provider_key("p_persist_multi_a"); + auto_gchar gchar* k2 = ai_get_provider_key("p_persist_multi_b"); + + assert_non_null(k1); + assert_non_null(k2); + assert_string_equal("sk-multi-a", k1); + assert_string_equal("sk-multi-b", k2); +} + +/* ======================================================================== + * Autocomplete deeper coverage — providers + * + * Model autocomplete (ai_models_find) tests were removed when that public + * symbol was retired in favour of a static ai_models_ac inside cmd_ac.c. + * The cycling cases below now exercise only the providers-side AC. + * ======================================================================== */ + +void +test_ai_providers_find_after_remove_skips_removed(void** state) +{ + ai_add_provider("zalpha", "https://z/"); + auto_gchar gchar* before = ai_providers_find("zalpha", FALSE, NULL); + assert_non_null(before); + assert_string_equal("zalpha", before); + + assert_true(ai_remove_provider("zalpha")); + + /* After removal, completion must not return the removed provider. */ + auto_gchar gchar* after = ai_providers_find("zalpha", FALSE, NULL); + assert_null(after); +} + +/* ======================================================================== + * Reset hook + persistence (prefs-backed providers and model cache) + * ======================================================================== */ + +void +test_ai_providers_reset_ac_restarts_cycle(void** state) +{ + /* After ai_providers_reset_ac() the cycling cursor must point back to + * the start of the list — the next ai_providers_find() call must + * return the same entry as the very first call before any cycling. */ + ai_add_provider("zreset_a", "https://example.test/zra/"); + ai_add_provider("zreset_b", "https://example.test/zrb/"); + + auto_gchar gchar* first = ai_providers_find("zreset", FALSE, NULL); + auto_gchar gchar* second = ai_providers_find("zreset", FALSE, NULL); + assert_non_null(first); + assert_non_null(second); + assert_string_not_equal(first, second); + + ai_providers_reset_ac(); + + auto_gchar gchar* after_reset = ai_providers_find("zreset", FALSE, NULL); + assert_non_null(after_reset); + assert_string_equal(first, after_reset); +} + +void +test_ai_add_provider_persisted_across_init(void** state) +{ + /* A provider added through the public API must survive an + * ai_client_shutdown + ai_client_init cycle with the same URL. */ + ai_add_provider("p_persist_provider", "https://example.test/persist-prov/"); + assert_non_null(ai_get_provider("p_persist_provider")); + + ai_client_shutdown(); + ai_client_init(); + + AIProvider* after = ai_get_provider("p_persist_provider"); + assert_non_null(after); + assert_string_equal("https://example.test/persist-prov/", after->api_url); +} + +void +test_ai_remove_provider_persisted_across_init(void** state) +{ + /* A provider removed by the user must not be reintroduced by the + * default-bootstrap path on the next init. */ + ai_add_provider("p_persist_remove_prov", "https://example.test/remprov/"); + assert_non_null(ai_get_provider("p_persist_remove_prov")); + + assert_true(ai_remove_provider("p_persist_remove_prov")); + assert_null(ai_get_provider("p_persist_remove_prov")); + + ai_client_shutdown(); + ai_client_init(); + + assert_null(ai_get_provider("p_persist_remove_prov")); +} + +void +test_ai_models_persisted_across_init(void** state) +{ + /* Models cached on a provider must be reloaded from prefs on the next + * ai_client_init — the count and order should match. + * + * Avoid a provider name ending in "_models": prefs_ai_list_providers() + * filters out keys ending with "_models_url" to skip the model-list + * shadow keys, which would also swallow a real provider whose name + * happens to end with "_models". */ + AIProvider* p = ai_add_provider("p_modelpersist", "https://example.test/pmodels/"); + assert_non_null(p); + + const gchar* json = "{\"object\":\"list\",\"data\":[" + "{\"id\":\"model-alpha\",\"object\":\"model\"}," + "{\"id\":\"model-beta\",\"object\":\"model\"}" + "]}"; + ai_parse_models_from_json(p, json); + assert_int_equal(2, g_list_length(p->models)); + + ai_client_shutdown(); + ai_client_init(); + + AIProvider* after = ai_get_provider("p_modelpersist"); + assert_non_null(after); + assert_int_equal(2, g_list_length(after->models)); +} diff --git a/tests/unittests/test_ai_client.h b/tests/unittests/test_ai_client.h new file mode 100644 index 00000000..e99c355d --- /dev/null +++ b/tests/unittests/test_ai_client.h @@ -0,0 +1,140 @@ +/* + * test_ai_client.h - AI client unit test declarations + */ + +int ai_client_setup(void** state); +int ai_client_teardown(void** state); + +void test_ai_client_init(void** state); +void test_ai_add_provider(void** state); +void test_ai_remove_provider(void** state); +void test_ai_list_providers(void** state); +void test_ai_set_provider_key(void** state); +void test_ai_get_provider_key(void** state); +void test_ai_session_create(void** state); +void test_ai_session_ref_unref(void** state); +void test_ai_session_add_message(void** state); +void test_ai_session_clear_history(void** state); +void test_ai_session_set_model(void** state); +void test_ai_json_escape(void** state); +void test_ai_json_escape_null(void** state); +void test_ai_json_escape_empty(void** state); +void test_ai_json_escape_special_chars(void** state); +void test_ai_json_escape_percent_signs(void** state); +void test_ai_json_escape_backslash_quote(void** state); +void test_ai_session_api_key_is_copied(void** state); +void test_ai_add_provider_update_existing(void** state); +void test_ai_add_provider_null_name_returns_null(void** state); +void test_ai_add_provider_null_url_returns_null(void** state); +void test_ai_session_create_null_provider_returns_null(void** state); +void test_ai_session_create_null_model_returns_null(void** state); +void test_ai_session_api_key_null_when_no_key_set(void** state); +/* Provider autocomplete tests */ +void test_ai_providers_find_forward(void** state); +void test_ai_providers_find_forward_custom(void** state); +void test_ai_providers_find_forward_no_match(void** state); +void test_ai_providers_find_forward_partial_match(void** state); +void test_ai_providers_find_next(void** state); +void test_ai_providers_find_previous(void** state); +void test_ai_providers_find_empty_search_str(void** state); +void test_ai_providers_find_null_search_str(void** state); +void test_ai_providers_find_case_insensitive(void** state); +/* Provider default model and settings tests */ +void test_ai_set_provider_default_model(void** state); +void test_ai_get_provider_default_model(void** state); +void test_ai_set_provider_setting(void** state); +void test_ai_get_provider_setting(void** state); +/* Model caching tests */ +void test_ai_models_are_fresh_initial(void** state); +/* Model parsing tests */ +void test_ai_parse_models_openai_format(void** state); +void test_ai_parse_models_perplexity_format(void** state); +void test_ai_parse_models_array_format(void** state); +void test_ai_parse_models_empty_json(void** state); +void test_ai_parse_models_null_handling(void** state); +void test_ai_parse_models_escaped_quotes(void** state); +void test_ai_parse_models_with_whitespace(void** state); +/* AI autocomplete integration tests */ +void test_ai_start_provider_autocomplete_only_on_exact(void** state); +void test_ai_providers_find_cycling(void** state); + +/* Setup that also initializes prefs for round-trip persistence tests. */ +int ai_client_setup_with_prefs(void** state); +int ai_client_teardown_with_prefs(void** state); + +/* Chat response parser tests (ai_parse_response) */ +void test_ai_parse_response_openai_content(void** state); +void test_ai_parse_response_perplexity_text(void** state); +void test_ai_parse_response_text_preferred_over_content(void** state); +void test_ai_parse_response_escaped_quote(void** state); +void test_ai_parse_response_newline_escape(void** state); +void test_ai_parse_response_empty_content(void** state); +void test_ai_parse_response_missing_field(void** state); +void test_ai_parse_response_null_input(void** state); +void test_ai_parse_response_empty_input(void** state); +void test_ai_parse_response_percent_signs_safe(void** state); +void test_ai_parse_response_braces_in_content(void** state); +void test_ai_parse_response_multiline_content(void** state); + +/* Error response parser tests (ai_parse_error_message) */ +void test_ai_parse_error_standard_envelope(void** state); +void test_ai_parse_error_with_escapes(void** state); +void test_ai_parse_error_no_error_field(void** state); +void test_ai_parse_error_no_message_field(void** state); +void test_ai_parse_error_null_input(void** state); +void test_ai_parse_error_empty_input(void** state); +void test_ai_parse_error_tab_escape(void** state); +void test_ai_parse_error_backslash_escape(void** state); + +/* Extended JSON escape tests */ +void test_ai_json_escape_backspace(void** state); +void test_ai_json_escape_formfeed(void** state); +void test_ai_json_escape_carriage_return(void** state); +void test_ai_json_escape_all_specials_combined(void** state); +void test_ai_json_escape_passes_utf8_through(void** state); + +/* Autocomplete cycling with many providers */ +void test_ai_providers_find_cycles_through_many(void** state); +void test_ai_providers_find_previous_walks_backwards(void** state); +void test_ai_providers_find_wraps_around(void** state); + +/* Session edge cases */ +void test_ai_session_add_message_null_session_no_crash(void** state); +void test_ai_session_add_message_null_role_no_crash(void** state); +void test_ai_session_add_message_null_content_no_crash(void** state); +void test_ai_session_history_preserves_large_order(void** state); +void test_ai_session_clear_empty_history(void** state); +void test_ai_session_set_model_null_keeps_old(void** state); +void test_ai_session_unref_null_no_crash(void** state); +void test_ai_session_ref_null_returns_null(void** state); + +/* Provider edge cases */ +void test_ai_get_provider_null_returns_null(void** state); +void test_ai_remove_provider_null_returns_false(void** state); +void test_ai_remove_provider_twice_second_fails(void** state); +void test_ai_provider_survives_via_session_after_removal(void** state); + +/* Settings edge cases */ +void test_ai_settings_multiple_keys_independent(void** state); +void test_ai_settings_get_missing_returns_null(void** state); +void test_ai_settings_isolated_between_providers(void** state); + +/* Model parsing edge cases */ +void test_ai_parse_models_data_not_array(void** state); +void test_ai_parse_models_empty_data_array(void** state); +void test_ai_parse_models_id_outside_data_ignored(void** state); +void test_ai_parse_models_multiple_models(void** state); + +/* Prefs round-trip tests */ +void test_ai_prefs_round_trip_api_key(void** state); +void test_ai_prefs_round_trip_remove_key(void** state); +void test_ai_prefs_multiple_providers_persist(void** state); + +/* Autocomplete deeper coverage */ +void test_ai_providers_find_after_remove_skips_removed(void** state); + +/* Reset hook + persistence */ +void test_ai_providers_reset_ac_restarts_cycle(void** state); +void test_ai_add_provider_persisted_across_init(void** state); +void test_ai_remove_provider_persisted_across_init(void** state); +void test_ai_models_persisted_across_init(void** state); diff --git a/tests/unittests/ui/stub_ai.c b/tests/unittests/ui/stub_ai.c new file mode 100644 index 00000000..f7f26378 --- /dev/null +++ b/tests/unittests/ui/stub_ai.c @@ -0,0 +1,41 @@ +/* + * stub_ai.c - Stubs for AI window functions + */ + +#include "ui/window.h" +#include "ai/ai_client.h" + +ProfWin* +win_create_ai(AISession* session) +{ + /* Return NULL to simulate failure in unit tests */ + (void)session; + return NULL; +} + +void +aiwin_display_response(ProfAiWin* win, const char* response) +{ + /* Stub: do nothing */ + (void)win; + (void)response; +} + +void +aiwin_display_error(ProfAiWin* win, const char* error_msg) +{ + /* Stub: do nothing */ + (void)win; + (void)error_msg; +} + +void +win_print_outgoing(ProfWin* window, const char* show_char, const char* const id, const char* const replace_id, const char* const message) +{ + /* Stub: do nothing */ + (void)window; + (void)show_char; + (void)id; + (void)replace_id; + (void)message; +} diff --git a/tests/unittests/unittests.c b/tests/unittests/unittests.c index cf5906fa..89136378 100644 --- a/tests/unittests/unittests.c +++ b/tests/unittests/unittests.c @@ -36,6 +36,7 @@ #include "test_form.h" #include "test_callbacks.h" #include "test_plugins_disco.h" +#include "test_ai_client.h" #include "test_database_export.h" #include "test_database_stress.h" @@ -659,6 +660,128 @@ main(int argc, char* argv[]) cmocka_unit_test_setup_teardown(test_cmd_force_encryption_invalid_policy, load_preferences, close_preferences), cmocka_unit_test_setup_teardown(test_allow_unencrypted_message_invalid_mode, load_preferences, close_preferences), + // AI client tests + cmocka_unit_test_setup_teardown(test_ai_client_init, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_add_provider, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_remove_provider, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_list_providers, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_set_provider_key, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_get_provider_key, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_create, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_ref_unref, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_add_message, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_clear_history, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_set_model, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_api_key_is_copied, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_add_provider_update_existing, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_add_provider_null_name_returns_null, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_add_provider_null_url_returns_null, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_create_null_provider_returns_null, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_create_null_model_returns_null, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_api_key_null_when_no_key_set, ai_client_setup, ai_client_teardown), + /* Provider autocomplete tests */ + cmocka_unit_test_setup_teardown(test_ai_providers_find_forward, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_custom, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_no_match, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_providers_find_forward_partial_match, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_providers_find_next, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_providers_find_previous, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_providers_find_empty_search_str, ai_client_setup, ai_client_teardown), + /* SIGSEGV on ai_providers_find(NULL, ...) — see test body and + * AI_AUTOCOMPLETE_POSSIBLE_ISSUES.md, issue 1. Re-enable once the + * NULL-search path is guarded. */ + /* cmocka_unit_test_setup_teardown(test_ai_providers_find_null_search_str, ai_client_setup, ai_client_teardown), */ + cmocka_unit_test_setup_teardown(test_ai_providers_find_case_insensitive, ai_client_setup, ai_client_teardown), + /* Provider default model and settings tests */ + cmocka_unit_test_setup_teardown(test_ai_set_provider_default_model, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_get_provider_default_model, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_set_provider_setting, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_get_provider_setting, ai_client_setup, ai_client_teardown), + /* Model caching tests */ + cmocka_unit_test_setup_teardown(test_ai_models_are_fresh_initial, ai_client_setup, ai_client_teardown), + /* Model parsing tests */ + cmocka_unit_test(test_ai_parse_models_openai_format), + cmocka_unit_test(test_ai_parse_models_perplexity_format), + cmocka_unit_test(test_ai_parse_models_array_format), + cmocka_unit_test(test_ai_parse_models_empty_json), + cmocka_unit_test(test_ai_parse_models_null_handling), + cmocka_unit_test(test_ai_parse_models_escaped_quotes), + cmocka_unit_test(test_ai_parse_models_with_whitespace), + /* AI autocomplete integration tests */ + cmocka_unit_test_setup_teardown(test_ai_start_provider_autocomplete_only_on_exact, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_providers_find_cycling, ai_client_setup, ai_client_teardown), + cmocka_unit_test(test_ai_json_escape), + cmocka_unit_test(test_ai_json_escape_null), + cmocka_unit_test(test_ai_json_escape_empty), + cmocka_unit_test(test_ai_json_escape_special_chars), + cmocka_unit_test(test_ai_json_escape_percent_signs), + cmocka_unit_test(test_ai_json_escape_backslash_quote), + /* Chat response parser */ + cmocka_unit_test(test_ai_parse_response_openai_content), + cmocka_unit_test(test_ai_parse_response_perplexity_text), + cmocka_unit_test(test_ai_parse_response_text_preferred_over_content), + cmocka_unit_test(test_ai_parse_response_escaped_quote), + cmocka_unit_test(test_ai_parse_response_newline_escape), + cmocka_unit_test(test_ai_parse_response_empty_content), + cmocka_unit_test(test_ai_parse_response_missing_field), + cmocka_unit_test(test_ai_parse_response_null_input), + cmocka_unit_test(test_ai_parse_response_empty_input), + cmocka_unit_test(test_ai_parse_response_percent_signs_safe), + cmocka_unit_test(test_ai_parse_response_braces_in_content), + cmocka_unit_test(test_ai_parse_response_multiline_content), + /* Error response parser */ + cmocka_unit_test(test_ai_parse_error_standard_envelope), + cmocka_unit_test(test_ai_parse_error_with_escapes), + cmocka_unit_test(test_ai_parse_error_no_error_field), + cmocka_unit_test(test_ai_parse_error_no_message_field), + cmocka_unit_test(test_ai_parse_error_null_input), + cmocka_unit_test(test_ai_parse_error_empty_input), + cmocka_unit_test(test_ai_parse_error_tab_escape), + cmocka_unit_test(test_ai_parse_error_backslash_escape), + /* Extended JSON escape coverage */ + cmocka_unit_test(test_ai_json_escape_backspace), + cmocka_unit_test(test_ai_json_escape_formfeed), + cmocka_unit_test(test_ai_json_escape_carriage_return), + cmocka_unit_test(test_ai_json_escape_all_specials_combined), + cmocka_unit_test(test_ai_json_escape_passes_utf8_through), + /* Autocomplete cycling */ + cmocka_unit_test_setup_teardown(test_ai_providers_find_cycles_through_many, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_providers_find_previous_walks_backwards, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_providers_find_wraps_around, ai_client_setup, ai_client_teardown), + /* Session edge cases */ + cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_session_no_crash, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_role_no_crash, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_content_no_crash, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_history_preserves_large_order, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_clear_empty_history, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_set_model_null_keeps_old, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_unref_null_no_crash, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_session_ref_null_returns_null, ai_client_setup, ai_client_teardown), + /* Provider edge cases */ + cmocka_unit_test_setup_teardown(test_ai_get_provider_null_returns_null, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_remove_provider_null_returns_false, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_remove_provider_twice_second_fails, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_provider_survives_via_session_after_removal, ai_client_setup, ai_client_teardown), + /* Settings */ + cmocka_unit_test_setup_teardown(test_ai_settings_multiple_keys_independent, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_settings_get_missing_returns_null, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_settings_isolated_between_providers, ai_client_setup, ai_client_teardown), + /* Model parsing edge cases */ + cmocka_unit_test_setup_teardown(test_ai_parse_models_data_not_array, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_parse_models_empty_data_array, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_parse_models_id_outside_data_ignored, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_parse_models_multiple_models, ai_client_setup, ai_client_teardown), + /* Prefs round-trip (uses prefs+ai setup) */ + cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_api_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs), + cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_remove_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs), + cmocka_unit_test_setup_teardown(test_ai_prefs_multiple_providers_persist, ai_client_setup_with_prefs, ai_client_teardown_with_prefs), + /* Autocomplete deeper coverage */ + cmocka_unit_test_setup_teardown(test_ai_providers_find_after_remove_skips_removed, ai_client_setup, ai_client_teardown), + /* Reset hook + persistence */ + cmocka_unit_test_setup_teardown(test_ai_providers_reset_ac_restarts_cycle, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_add_provider_persisted_across_init, ai_client_setup_with_prefs, ai_client_teardown_with_prefs), + cmocka_unit_test_setup_teardown(test_ai_remove_provider_persisted_across_init, ai_client_setup_with_prefs, ai_client_teardown_with_prefs), + cmocka_unit_test_setup_teardown(test_ai_models_persisted_across_init, ai_client_setup_with_prefs, ai_client_teardown_with_prefs), // Flatfile export/import round-trip cmocka_unit_test(test_ff_roundtrip_simple_chat), cmocka_unit_test(test_ff_roundtrip_with_all_metadata), diff --git a/tests/unittests/xmpp/stub_xmpp.c b/tests/unittests/xmpp/stub_xmpp.c index e2af28b8..9496c629 100644 --- a/tests/unittests/xmpp/stub_xmpp.c +++ b/tests/unittests/xmpp/stub_xmpp.c @@ -104,6 +104,12 @@ connection_create_uuid(void) return NULL; } +char* +connection_create_stanza_id(void) +{ + return NULL; +} + void connection_free_uuid(char* uuid) {