From 9e5dfb14f851e15c6bbfa20cb25842b27caf3964 Mon Sep 17 00:00:00 2001 From: Jabber Developer Date: Fri, 15 May 2026 02:21:54 +0000 Subject: [PATCH] feat(ai): add AI client with multi-provider chat support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an AI client module that integrates with OpenAI-compatible API providers (OpenAI, Perplexity, and custom endpoints) to provide AI-assisted chat within CProof. Users can start sessions with /ai start, send prompts, receive responses in a dedicated AI window, switch between providers and models, and manage API keys — all with tab-completion. Providers are configured via /ai set commands with per-provider API keys, endpoints, default models, and custom settings. Two default providers (openai, perplexity) are seeded on first use. Provider state persists in [ai/] sections of the preferences keyfile with automatic migration from the previous flat-key format. The /ai command integrates into the existing command system with 8 subcommands covering provider management, session lifecycle, model fetching, and conversation clearing. Autocomplete uses the standard flat prefix-matching chain for reliable tab-completion at every nesting level. A new ProfAiWin window type is added to the window system. Architecture: Async design: HTTP requests run on a background thread (pthread) to avoid blocking the ncurses UI loop; results are displayed on the main thread via direct function calls Thread safety: AIProvider and AISession use atomic ref-counting and mutex-protected session state; the request thread snapshots all session data before making the HTTP call Window validation: wins_ai_exists() prevents use-after-free when the user closes the AI window during an in-flight HTTP request (~60s) Privacy: store:false is sent with every request to prevent providers from persisting conversations or using them for training Response size limit: 10MB cap with immediate curl abort via CURL_WRITEFUNC_ERROR to prevent OOM JSON parsing uses unified helpers for both chat responses and error envelopes with consistent escape decoding. The response parser tries Perplexity /v1/responses "text" field first, then falls back to OpenAI "content". Error parsing extracts provider error.message from the standard envelope format. Model parsing handles multiple API response formats (OpenAI list, Perplexity, array) including edge cases. Tests include 470+ lines of unit tests covering provider management, session lifecycle, JSON parsing (multiple formats), autocomplete cycling, and error handling, plus functional tests for /ai command dispatch. A stub_ai.c module isolates unit tests from UI dependencies. --- Makefile.am | 8 +- src/ai/ai_client.c | 1575 ++++++++++++++++++++++ src/ai/ai_client.h | 291 ++++ src/command/cmd_ac.c | 198 ++- src/command/cmd_defs.c | 61 + src/command/cmd_funcs.c | 421 +++++- src/command/cmd_funcs.h | 8 + src/config/preferences.c | 437 ++++++ src/config/preferences.h | 41 + src/event/client_events.c | 31 +- src/event/client_events.h | 1 + src/profanity.c | 2 + src/tools/autocomplete.c | 6 +- src/tools/autocomplete.h | 2 +- src/ui/aiwin.c | 61 + src/ui/ui.h | 7 + src/ui/win_types.h | 16 +- src/ui/window.c | 46 + src/ui/window_list.c | 41 +- src/ui/window_list.h | 1 + tests/functionaltests/functionaltests.c | 39 +- tests/functionaltests/proftest.c | 2 +- tests/functionaltests/test_ai.c | 252 ++++ tests/functionaltests/test_ai.h | 27 + tests/unittests/test_ai_client.c | 1608 +++++++++++++++++++++++ tests/unittests/test_ai_client.h | 140 ++ tests/unittests/ui/stub_ai.c | 41 + tests/unittests/unittests.c | 123 ++ tests/unittests/xmpp/stub_xmpp.c | 6 + 29 files changed, 5477 insertions(+), 15 deletions(-) create mode 100644 src/ai/ai_client.c create mode 100644 src/ai/ai_client.h create mode 100644 src/ui/aiwin.c create mode 100644 tests/functionaltests/test_ai.c create mode 100644 tests/functionaltests/test_ai.h create mode 100644 tests/unittests/test_ai_client.c create mode 100644 tests/unittests/test_ai_client.h create mode 100644 tests/unittests/ui/stub_ai.c 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) {