From acae54305794e888735555c5b6bc65b0ef536507 Mon Sep 17 00:00:00 2001 From: Jabber Developer Date: Sat, 9 May 2026 13:15:58 +0000 Subject: [PATCH] feat(ai): add model caching, settings, and commands - Introduce model caching with persistence to preferences - Add provider default model and custom settings management - Implement `/ai switch`, `/ai models`, and improve `/ai start` - Add model name autocomplete for chat commands - Update command definitions and help text - Add unit tests for new functionality --- src/ai/ai_client.c | 523 ++++++++++++++++++++++++++++++- src/ai/ai_client.h | 69 +++- src/command/cmd_ac.c | 150 +++++++-- src/command/cmd_defs.c | 54 ++-- src/command/cmd_funcs.c | 264 ++++++++++++++-- src/command/cmd_funcs.h | 3 +- src/config/preferences.c | 77 +++++ src/config/preferences.h | 6 + tests/unittests/test_ai_client.c | 183 +++++++++++ tests/unittests/test_ai_client.h | 12 + tests/unittests/unittests.c | 12 + 11 files changed, 1255 insertions(+), 98 deletions(-) diff --git a/src/ai/ai_client.c b/src/ai/ai_client.c index 7c6edf4b..5fc2ac7a 100644 --- a/src/ai/ai_client.c +++ b/src/ai/ai_client.c @@ -32,6 +32,12 @@ static GHashTable* providers = NULL; static GHashTable* provider_keys = NULL; static Autocomplete providers_ac = NULL; +/* ======================================================================== + * Forward declarations + * ======================================================================== */ +static void _ai_load_models_for_provider(AIProvider* provider); +static void _ai_save_models_for_provider(AIProvider* provider); + /* ======================================================================== * Curl helpers * ======================================================================== */ @@ -183,7 +189,10 @@ ai_provider_new(const gchar* name, const gchar* api_url, const gchar* org_id) provider->api_url = g_strdup(api_url ? api_url : ""); provider->org_id = g_strdup(org_id); provider->project_id = NULL; + provider->default_model = NULL; + provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); provider->models = NULL; + provider->models_fresh = FALSE; provider->ref_count = 1; return provider; } @@ -211,6 +220,8 @@ ai_provider_unref(AIProvider* provider) g_free(provider->api_url); g_free(provider->org_id); g_free(provider->project_id); + g_free(provider->default_model); + g_hash_table_destroy(provider->settings); GList* curr = provider->models; while (curr) { @@ -271,6 +282,16 @@ ai_client_init(void) /* Load saved API keys from config */ ai_load_keys(); + /* Load cached models for all providers */ + GList* provider_list = ai_list_providers(); + GList* curr = provider_list; + while (curr) { + AIProvider* provider = (AIProvider*)curr->data; + _ai_load_models_for_provider(provider); + curr = g_list_next(curr); + } + g_list_free(provider_list); + log_info("AI client initialized with default providers: openai, perplexity"); } @@ -387,6 +408,37 @@ ai_providers_find(const char* const search_str, gboolean previous, void* context return autocomplete_complete(providers_ac, effective_search, FALSE, previous); } +/* Stateful model name finder for autocomplete. + * Uses the current session's provider models for completion. */ +gchar* +ai_models_find(const char* const search_str, gboolean previous, void* context) +{ + ProfAiWin* aiwin = (ProfAiWin*)context; + if (!aiwin || !aiwin->session) { + return NULL; + } + + AIProvider* provider = aiwin->session->provider; + if (!provider || !provider->models) { + return NULL; + } + + /* Create a temporary autocomplete for models */ + Autocomplete models_ac = autocomplete_new(); + GList* curr = provider->models; + while (curr) { + autocomplete_add(models_ac, curr->data); + curr = g_list_next(curr); + } + + /* NULL search_str is treated as empty string for cycling */ + const char* effective_search = (search_str != NULL) ? search_str : ""; + + gchar* result = autocomplete_complete(models_ac, effective_search, FALSE, previous); + autocomplete_free(models_ac); + return result; +} + gchar* ai_get_provider_key(const gchar* provider_name) { @@ -419,6 +471,456 @@ ai_set_provider_key(const gchar* provider_name, const gchar* api_key) } } +/* ======================================================================== + * Provider default model and settings + * ======================================================================== */ + +void +ai_set_provider_default_model(const gchar* provider_name, const gchar* model) +{ + if (!provider_name || !model) + return; + + AIProvider* provider = ai_get_provider(provider_name); + if (!provider) { + log_warning("Provider '%s' not found for default model setting", provider_name); + return; + } + + g_free(provider->default_model); + provider->default_model = g_strdup(model); + log_info("Default model for provider '%s' set to: %s", provider_name, model); +} + +const gchar* +ai_get_provider_default_model(const gchar* provider_name) +{ + if (!provider_name) + return NULL; + + AIProvider* provider = ai_get_provider(provider_name); + if (!provider) + return NULL; + + return provider->default_model; +} + +void +ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value) +{ + if (!provider_name || !setting) + return; + + AIProvider* provider = ai_get_provider(provider_name); + if (!provider) { + log_warning("Provider '%s' not found for setting '%s'", provider_name, setting); + return; + } + + if (!provider->settings) { + provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); + } + + if (value) { + g_hash_table_insert(provider->settings, g_strdup(setting), g_strdup(value)); + log_info("Setting '%s' for provider '%s' set to: %s", setting, provider_name, value); + } else { + g_hash_table_remove(provider->settings, setting); + log_info("Setting '%s' removed for provider '%s'", setting, provider_name); + } +} + +gchar* +ai_get_provider_setting(const gchar* provider_name, const gchar* setting) +{ + if (!provider_name || !setting) + return NULL; + + AIProvider* provider = ai_get_provider(provider_name); + if (!provider || !provider->settings) + return NULL; + + gchar* value = g_hash_table_lookup(provider->settings, setting); + return g_strdup(value); +} + +/* ======================================================================== + * Generic HTTP request handling + * ======================================================================== */ + +typedef void (*ai_response_handler_t)(AIProvider* provider, const gchar* response, gpointer user_data); + +typedef struct +{ + gchar* provider_name; + gpointer user_data; + gchar* request_url; + ai_response_handler_t handler; +} ai_request_t; + +/** + * Build curl headers with auth and optional org header. + * Returns headers list (caller must free with curl_slist_free_all). + */ +static struct curl_slist* +_build_curl_headers(AIProvider* provider, const gchar* api_key) +{ + struct curl_slist* headers = NULL; + auto_gchar gchar* auth_header = g_strdup_printf("Authorization: Bearer %s", api_key); + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = curl_slist_append(headers, auth_header); + + if (provider && provider->org_id && strlen(provider->org_id) > 0) { + auto_gchar gchar* org_header = g_strdup_printf("OpenAI-Organization: %s", provider->org_id); + headers = curl_slist_append(headers, org_header); + } + + return headers; +} + +/** + * Execute a curl request and call handler on success. + * Returns CURLcode. + */ +static CURLcode +_curl_exec_and_handle(CURL* curl, const gchar* url, struct curl_slist* headers, + struct curl_response_t* response, ai_response_handler_t handler, + AIProvider* provider, gpointer user_data) +{ + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, _write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, response); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); + + CURLcode res = curl_easy_perform(curl); + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + if (res != CURLE_OK) { + auto_gchar gchar* error_msg = g_strdup(curl_easy_strerror(res)); + log_error("Request failed for '%s': %s", provider->name, error_msg); + _aiwin_display_error(user_data, error_msg); + } else if (http_code >= 400) { + auto_gchar gchar* error_msg = g_strdup_printf("HTTP %ld: %s", http_code, + response->data ? response->data : "Unknown error"); + log_error("Request HTTP error for '%s': %s", provider->name, error_msg); + _aiwin_display_error(user_data, error_msg); + } else { + handler(provider, response->data, user_data); + } + + return res; +} + +static gpointer +_ai_generic_request_thread(gpointer data) +{ + ai_request_t* req = (ai_request_t*)data; + AIProvider* provider = ai_get_provider(req->provider_name); + if (!provider) { + log_error("Provider '%s' not found for request", req->provider_name); + g_free(req->provider_name); + g_free(req->request_url); + g_free(req); + return NULL; + } + + CURL* curl = curl_easy_init(); + if (!curl) { + log_error("Failed to initialize curl for %s", req->provider_name); + g_free(req->provider_name); + g_free(req->request_url); + g_free(req); + return NULL; + } + + auto_gchar gchar* api_key = ai_get_provider_key(req->provider_name); + if (!api_key || strlen(api_key) == 0) { + auto_gchar gchar* error_msg = g_strdup_printf("No API key set for provider '%s'.", req->provider_name); + _aiwin_display_error(req->user_data, error_msg); + curl_easy_cleanup(curl); + g_free(req->provider_name); + g_free(req->request_url); + g_free(req); + return NULL; + } + + struct curl_slist* headers = _build_curl_headers(provider, api_key); + struct curl_response_t response; + response.data = g_new0(gchar, 1); + response.size = 0; + + _curl_exec_and_handle(curl, req->request_url, headers, &response, req->handler, provider, req->user_data); + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + g_free(response.data); + g_free(req->provider_name); + g_free(req->request_url); + g_free(req); + return NULL; +} + +/* ======================================================================== + * Model fetching + * ======================================================================== */ + +gboolean +ai_models_are_fresh(const gchar* provider_name) +{ + if (!provider_name) + return FALSE; + + AIProvider* provider = ai_get_provider(provider_name); + if (!provider) + return FALSE; + + return provider->models_fresh; +} + +static gboolean +_model_exists(AIProvider* provider, const gchar* model) +{ + GList* curr = provider->models; + while (curr) { + if (g_strcmp0(curr->data, model) == 0) { + return TRUE; + } + curr = g_list_next(curr); + } + return FALSE; +} + +static void +_add_model_if_new(AIProvider* provider, const gchar* model) +{ + if (!_model_exists(provider, model)) { + provider->models = g_list_append(provider->models, g_strdup(model)); + } +} + +/* ======================================================================== + * Model cache persistence + * ======================================================================== */ + +/** + * Load cached models for a provider from preferences. + */ +static void +_ai_load_models_for_provider(AIProvider* provider) +{ + if (!provider) + return; + + GList* models = prefs_ai_get_models(provider->name); + if (!models) { + log_info("No cached models for provider '%s'", provider->name); + return; + } + + /* Add loaded models to provider */ + GList* curr = models; + while (curr) { + _add_model_if_new(provider, curr->data); + curr = g_list_next(curr); + } + + prefs_free_ai_models(models); + + if (provider->models) { + provider->models_fresh = TRUE; + log_info("Loaded %d cached models for provider '%s'", g_list_length(provider->models), provider->name); + } +} + +/** + * Save models for a provider to preferences. + */ +static void +_ai_save_models_for_provider(AIProvider* provider) +{ + if (!provider || !provider->models) + return; + + /* Build array of model strings */ + gint count = g_list_length(provider->models); + gchar** models = g_new0(gchar*, count); + GList* curr = provider->models; + gint i = 0; + while (curr) { + models[i++] = curr->data; + curr = g_list_next(curr); + } + + prefs_ai_set_models(provider->name, (const gchar* const*)models, count); + g_free(models); + + log_debug("Saved %d models for provider '%s' to prefs", count, provider->name); +} + +static const gchar* +_find_json_field(const gchar* json, const gchar* field) +{ + auto_gchar gchar* key = g_strdup_printf("\"%s\":\"", field); + const gchar* pos = strstr(json, key); + if (!pos) { + return NULL; + } + return pos + strlen(key); +} + +static void +_parse_openai_models(AIProvider* provider, const gchar* json) +{ + /* Format: {"data": [{"id": "model1"}, {"id": "model2"}, ...]} */ + const gchar* data_start = _find_json_field(json, "data"); + if (!data_start) { + return; + } + + const gchar* obj_start = strstr(data_start, "{\"id\":"); + while (obj_start) { + const gchar* id_start = _find_json_field(obj_start, "id"); + if (!id_start) { + break; + } + + const gchar* id_end = strchr(id_start, '"'); + if (!id_end) { + break; + } + + auto_gchar gchar* model = g_strndup(id_start, id_end - id_start); + _add_model_if_new(provider, model); + + obj_start = strstr(id_end, "{\"id\":"); + if (obj_start) { + obj_start++; + } + } +} + +static void +_parse_array_models(AIProvider* provider, const gchar* json) +{ + /* Format: ["model1", "model2", ...] */ + const gchar* arr_start = strchr(json, '['); + if (!arr_start) { + return; + } + + const gchar* model_start = strchr(arr_start, '"'); + while (model_start) { + const gchar* model_end = strchr(model_start + 1, '"'); + if (!model_end) { + break; + } + + auto_gchar gchar* model = g_strndup(model_start + 1, model_end - model_start - 1); + _add_model_if_new(provider, model); + + model_start = strchr(model_end + 1, '"'); + } +} + +static void +_parse_and_cache_models(AIProvider* provider, const gchar* response_json) +{ + if (!provider || !response_json) { + return; + } + + /* Try OpenAI format first: {"data": [{"id": "model1"}, ...]} */ + _parse_openai_models(provider, response_json); + + /* If no models found, try simple array format: ["model1", ...] */ + if (!provider->models) { + _parse_array_models(provider, response_json); + } + + provider->models_fresh = TRUE; + log_info("Cached %d models for provider '%s'", g_list_length(provider->models), provider->name); + + /* Persist models to preferences */ + _ai_save_models_for_provider(provider); +} + +static void +_models_response_handler(AIProvider* provider, const gchar* response, gpointer user_data) +{ + _parse_and_cache_models(provider, response); + + auto_gchar gchar* msg = g_strdup_printf("Cached %d models for provider '%s'. Use '/ai start ' to start a chat.", + g_list_length(provider->models), provider->name); + _aiwin_display_response(user_data, msg); +} + +static gpointer +_models_fetch_thread(gpointer data) +{ + ai_request_t* req = (ai_request_t*)data; + AIProvider* provider = ai_get_provider(req->provider_name); + if (!provider) { + g_free(req->provider_name); + g_free(req); + return NULL; + } + + /* Build URL for listing models */ + auto_gchar gchar* base_url = g_strdup_printf("%s%s", provider->api_url, + g_str_has_suffix(provider->api_url, "/") ? "" : "/"); + auto_gchar gchar* request_url = g_strdup_printf("%sv1/models", base_url); + + req->request_url = g_strdup(request_url); + req->handler = _models_response_handler; + + return _ai_generic_request_thread(req); +} + +gboolean +ai_fetch_models(const gchar* provider_name, gpointer user_data) +{ + if (!provider_name) + return FALSE; + + AIProvider* provider = ai_get_provider(provider_name); + if (!provider) { + log_error("Provider '%s' not found for model fetch", provider_name); + return FALSE; + } + + /* If models are fresh, just display them */ + if (provider->models_fresh && provider->models) { + cons_show("Cached %d models for provider '%s':", g_list_length(provider->models), provider_name); + GList* curr = provider->models; + while (curr) { + cons_show(" %s", (gchar*)curr->data); + curr = g_list_next(curr); + } + cons_show(""); + cons_show("Use '/ai models %s --refresh' to fetch fresh models from the API.", provider_name); + return TRUE; + } + + /* Fetch models in a thread */ + ai_request_t* req = g_new0(ai_request_t, 1); + req->provider_name = g_strdup(provider_name); + req->user_data = user_data; + + GThread* thread = g_thread_new("ai_models_fetch", _models_fetch_thread, req); + if (!thread) { + g_free(req->provider_name); + g_free(req); + log_error("Failed to create thread for model fetch"); + return FALSE; + } + + g_thread_unref(thread); + cons_show("Fetching models for provider '%s'...", provider_name); + return TRUE; +} + /* ======================================================================== * Session Management * ======================================================================== */ @@ -708,34 +1210,23 @@ _ai_request_thread(gpointer data) auto_gchar gchar* json_payload = _build_json_payload(session, prompt); log_debug("[AI-THREAD] JSON payload: %s", json_payload); - /* Set up headers */ - struct curl_slist* headers = NULL; - auto_gchar gchar* auth_header = g_strdup_printf("Authorization: Bearer %s", session->api_key); - headers = curl_slist_append(headers, "Content-Type: application/json"); - headers = curl_slist_append(headers, auth_header); - - /* Add organization header if configured */ - if (session->provider && session->provider->org_id && strlen(session->provider->org_id) > 0) { - auto_gchar gchar* org_header = g_strdup_printf("OpenAI-Organization: %s", session->provider->org_id); - headers = curl_slist_append(headers, org_header); - } + /* Build headers using shared helper */ + struct curl_slist* headers = _build_curl_headers(session->provider, session->api_key); /* Response buffer */ struct curl_response_t response; response.data = g_new0(gchar, 1); response.size = 0; - /* Configure request */ + /* Build request URL */ const gchar* api_url = session->provider ? session->provider->api_url : DEFAULT_OPENAI_URL; auto_gchar gchar* request_url = g_strdup_printf("%s%sv1/responses", api_url, g_str_has_suffix(api_url, "/") ? "" : "/"); log_debug("[AI-THREAD] API URL: %s", api_url); log_debug("[AI-THREAD] API Request URL: %s", request_url); log_debug("[AI-THREAD] Model: %s", session->model); - curl_easy_setopt(curl, CURLOPT_URL, request_url); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + + /* Execute request with POST fields */ curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_payload); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, _write_callback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L); CURLcode res = curl_easy_perform(curl); diff --git a/src/ai/ai_client.h b/src/ai/ai_client.h index 030b55c7..2d149bfd 100644 --- a/src/ai/ai_client.h +++ b/src/ai/ai_client.h @@ -17,12 +17,15 @@ typedef struct ai_message_t */ typedef struct ai_provider_t { - gchar* name; /* Provider name (e.g., "openai", "perplexity") */ - gchar* api_url; /* API endpoint URL */ - gchar* org_id; /* Optional organization ID */ - gchar* project_id; /* Optional project ID (for some providers) */ - GList* models; /* List of available models (gchar*) */ - gint32 ref_count; /* Reference count (atomic) */ + gchar* name; /* Provider name (e.g., "openai", "perplexity") */ + gchar* api_url; /* API endpoint URL */ + gchar* org_id; /* Optional organization ID */ + 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; /** @@ -97,6 +100,15 @@ GList* ai_list_providers(void); */ gchar* ai_providers_find(const char* const search_str, gboolean previous, void* context); +/** + * Find a model name for autocomplete. + * @param search_str The search string + * @param previous Whether to go to previous match + * @param context ProfAiWin* for the current session + * @return Model name, or NULL if not found + */ +gchar* ai_models_find(const char* const search_str, gboolean previous, void* context); + /** * Get the API key for a provider. * @param provider_name The provider name @@ -111,6 +123,51 @@ gchar* ai_get_provider_key(const gchar* provider_name); */ 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); + /* ======================================================================== * Session Management * ======================================================================== */ diff --git a/src/command/cmd_ac.c b/src/command/cmd_ac.c index f81a3009..6160468b 100644 --- a/src/command/cmd_ac.c +++ b/src/command/cmd_ac.c @@ -70,6 +70,7 @@ 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* _ai_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* prefix); static char* _theme_autocomplete(ProfWin* window, const char* const input, gboolean previous); static char* _autoaway_autocomplete(ProfWin* window, const char* const input, gboolean previous); static char* _autoconnect_autocomplete(ProfWin* window, const char* const input, gboolean previous); @@ -285,12 +286,14 @@ 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; @@ -465,6 +468,7 @@ static Autocomplete* all_acs[] = { &force_encryption_policy_ac, &ai_subcommands_ac, &ai_set_subcommands_ac, + &ai_set_custom_subcommands_ac, &ai_remove_subcommands_ac }; @@ -1178,18 +1182,26 @@ cmd_ac_init(void) autocomplete_add(correction_ac, "off"); autocomplete_add(correction_ac, "char"); - autocomplete_add(ai_subcommands_ac, "set"); - autocomplete_add(ai_subcommands_ac, "remove"); - autocomplete_add(ai_subcommands_ac, "start"); - autocomplete_add(ai_subcommands_ac, "clear"); - autocomplete_add(ai_subcommands_ac, "correct"); - autocomplete_add(ai_subcommands_ac, "providers"); + 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(ai_set_subcommands_ac, "provider"); - autocomplete_add(ai_set_subcommands_ac, "token"); - autocomplete_add(ai_set_subcommands_ac, "org"); + 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-provider", FALSE); + autocomplete_add_unsorted(ai_set_subcommands_ac, "default-model", FALSE); + autocomplete_add_unsorted(ai_set_subcommands_ac, "custom", FALSE); - autocomplete_add(ai_remove_subcommands_ac, "provider"); + 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"); @@ -4480,6 +4492,12 @@ _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); + gboolean space_at_end = g_str_has_suffix(input, " "); + 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) { @@ -4492,12 +4510,36 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous) return result; } - // /ai set org - autocomplete provider names - result = autocomplete_param_with_func(input, "/ai set org", ai_providers_find, previous, NULL); + // /ai set default-provider - autocomplete provider names + result = autocomplete_param_with_func(input, "/ai set default-provider", 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) { @@ -4515,25 +4557,95 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous) return result; } - // /ai start [/] - autocomplete provider names - result = autocomplete_param_with_func(input, "/ai start", ai_providers_find, previous, NULL); + // /ai start [] [] - use shared parse_args + if (parse_result && args && args[0] != NULL && g_strcmp0(args[0], "start") == 0) { + /* args[0]="start", args[1]=provider (if typed), args[2]=model (if typed) */ + if (num_args == 1 || (num_args == 2 && !space_at_end)) { + result = autocomplete_param_with_func(input, "/ai start", ai_providers_find, previous, NULL); + } else { + /* /ai start - model autocomplete */ + result = _ai_model_autocomplete(window, input, previous, "/ai start "); + } + if (result) { + return result; + } + } + + // /ai switch - autocomplete model names for specified provider + result = _ai_model_autocomplete(window, input, previous, "/ai switch"); if (result) { return result; } - // /ai clear [] - autocomplete provider names - result = autocomplete_param_with_func(input, "/ai clear", ai_providers_find, previous, NULL); + result = autocomplete_param_with_func(input, "/ai switch", ai_providers_find, previous, NULL); if (result) { return result; } - // /ai correct - result = autocomplete_param_with_func(input, "/ai correct", ai_providers_find, previous, NULL); + // /ai switch - autocomplete model names from current session's provider + ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai(); + result = autocomplete_param_with_func(input, "/ai switch", ai_models_find, previous, aiwin); 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; -} \ No newline at end of file +} + +/** + * Autocomplete model names for /ai patterns. + * Extracts provider from input and provides model completions from that provider's cached models. + * Uses static ai_models_ac to preserve cycling state (last_found) across calls. + */ +static char* +_ai_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* cmd_prefix) +{ + 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); + + char* 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 5f150089..0c3364d8 100644 --- a/src/command/cmd_defs.c +++ b/src/command/cmd_defs.c @@ -2794,8 +2794,9 @@ static const struct cmd_t command_defs[] = { { "remove", cmd_ai_remove }, { "start", cmd_ai_start }, { "clear", cmd_ai_clear }, - { "correct", cmd_ai_correct }, - { "providers", cmd_ai_providers }) + { "providers", cmd_ai_providers }, + { "switch", cmd_ai_switch }, + { "models", cmd_ai_models }) CMD_MAINFUNC(cmd_ai) CMD_TAGS( CMD_TAG_CHAT) @@ -2803,43 +2804,52 @@ static const struct cmd_t command_defs[] = { "/ai", "/ai set provider ", "/ai set token ", - "/ai set org ", + "/ai set default-provider ", + "/ai set default-model ", + "/ai set custom ", "/ai remove provider ", "/ai providers", - "/ai providers-list", - "/ai start [/]", - "/ai model ", - "/ai clear", - "/ai correct ") + "/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 and endpoint configuration. " + "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 org ", "Set organization ID for a provider (optional)" }, + { "set default-provider ", "Set global default provider for /ai start" }, + { "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 all available providers" }, - { "providers-list", "List configured providers with keys status" }, - { "start [/]", "Start new AI chat (e.g., openai/gpt-4o)" }, - { "model ", "Change model in current chat window" }, - { "clear", "Clear current chat history" }, - { "correct ", "Correct last user message and get new response" }) + { "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)" }, + { "switch ", "Change model in current session (keeps provider)" }, + { "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 org openai my-org-id", + "/ai set default-provider perplexity", + "/ai set default-model perplexity sonar", + "/ai set custom perplexity tools enabled", "/ai remove provider custom", - "/ai start openai/gpt-4o", - "/ai start perplexity/sonar", - "/ai providers-list", - "/ai clear", - "/ai correct I meant something else") + "/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 0fc3f626..f95b8cd6 100644 --- a/src/command/cmd_funcs.c +++ b/src/command/cmd_funcs.c @@ -10795,21 +10795,28 @@ cmd_ai(ProfWin* window, const char* const command, gchar** args) cons_show("AI Chat - OpenAI-compatible API client"); cons_show(""); - // List available providers + // List configured providers with full details GList* providers = ai_list_providers(); - cons_show("Available 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); - cons_show(" %s (URL: %s, Key: %s)", - provider->name, - provider->api_url, - key ? "set" : "not set"); + 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(""); - cons_show("Use '/ai start /' to begin a chat."); + cons_show("Use '/ai start' to begin a chat (uses default provider/model)."); + cons_show("Use '/ai models ' to fetch available models."); cons_show("Available models: https://models.litellm.ai/"); return TRUE; } @@ -10849,20 +10856,41 @@ cmd_ai_set(ProfWin* window, const char* const command, gchar** args) cons_show("API token set for provider: %s", args[2]); cons_show(""); return TRUE; - } else if (g_strcmp0(args[1], "org") == 0) { - // /ai set org + } 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; } - AIProvider* provider = ai_get_provider(args[2]); - if (provider) { - g_free(provider->org_id); - provider->org_id = g_strdup(args[3]); - cons_show("Organization ID set for provider: %s", args[2]); - } else { - cons_show("Provider '%s' not found. Add it first with '/ai set provider'.", args[2]); + 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; + } else if (g_strcmp0(args[1], "default-provider") == 0) { + // /ai set default-provider + if (g_strv_length(args) < 3) { + cons_bad_cmd_usage(command); + return TRUE; + } + const gchar* provider_name = args[2]; + 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; + } + prefs_set_string(PREF_AI_PROVIDER, provider_name); + cons_show("Default provider set to: %s", provider_name); cons_show(""); return TRUE; } @@ -10897,26 +10925,31 @@ cmd_ai_remove(ProfWin* window, const char* const command, gchar** args) gboolean cmd_ai_start(ProfWin* window, const char* const command, gchar** args) { - // /ai start [/] - const gchar* provider_model = (g_strv_length(args) >= 2) ? args[1] : NULL; + // /ai start [] [] + // Space-separated, no slash syntax + const gchar* provider_name = NULL; + const gchar* model = NULL; - auto_gchar gchar* owned_provider_name = NULL; - const gchar* provider_name = "openai"; - const gchar* model = "gpt-4o"; - - if (provider_model) { - const gchar* slash = strchr(provider_model, '/'); - if (slash) { - owned_provider_name = g_strndup(provider_model, slash - provider_model); - provider_name = owned_provider_name; - model = slash + 1; - } else { - // Just a model name, use default provider - model = provider_model; - } + 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 } - // Check if provider exists AIProvider* provider = ai_get_provider(provider_name); if (!provider) { cons_show_error("Provider '%s' not found. Use '/ai set provider %s ' to add it.", @@ -10924,6 +10957,14 @@ cmd_ai_start(ProfWin* window, const char* const command, gchar** args) return TRUE; } + // Get model: explicit > provider default > hardcoded fallback + if (!model) { + model = ai_get_provider_default_model(provider_name); + } + if (!model) { + model = "gpt-4o"; // Fallback + } + // Check for API key gchar* api_key = ai_get_provider_key(provider_name); if (!api_key || strlen(api_key) == 0) { @@ -10972,6 +11013,161 @@ cmd_ai_start(ProfWin* window, const char* const command, gchar** args) 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]; + + // Get current AI window + ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai(); + if (!aiwin) { + cons_show("No active AI chat window. Use '/ai start' first."); + return TRUE; + } + + 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; + } + + // Get current AI window + ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai(); + if (!aiwin) { + cons_show("No active AI chat window. Use '/ai start' first."); + return TRUE; + } + + 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; + gboolean changed_provider = FALSE; + + // Check if arg1 is a known provider + AIProvider* provider = ai_get_provider(arg1); + if (provider) { + // arg1 is a provider name + provider_name = arg1; + changed_provider = TRUE; + + // 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; + 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; + } + + // Update the existing session's provider and model + if (changed_provider) { + AIProvider* new_provider = ai_get_provider(provider_name); + g_free(aiwin->session->provider_name); + aiwin->session->provider_name = g_strdup(provider_name); + ai_provider_unref(aiwin->session->provider); + aiwin->session->provider = new_provider; + aiwin->session->provider->ref_count++; + } + g_free(aiwin->session->model); + aiwin->session->model = g_strdup(model); + g_free(aiwin->session->api_key); + aiwin->session->api_key = g_strdup(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 [--refresh] + if (args[1] == NULL) { + cons_bad_cmd_usage(command); + return TRUE; + } + + const gchar* provider_name = args[1]; + gboolean refresh = (g_strv_length(args) >= 3) && (g_strcmp0(args[2], "--refresh") == 0); + + AIProvider* provider = ai_get_provider(provider_name); + if (!provider) { + cons_show_error("Provider '%s' not found.", provider_name); + return TRUE; + } + + if (refresh || !ai_models_are_fresh(provider_name)) { + // Fetch models from API + ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai(); + ai_fetch_models(provider_name, aiwin); + } else { + // Display cached models + 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 --refresh' to fetch fresh models from the API.", provider_name); + } + + return TRUE; +} + gboolean cmd_ai_clear(ProfWin* window, const char* const command, gchar** args) { diff --git a/src/command/cmd_funcs.h b/src/command/cmd_funcs.h index 57f5fbeb..966f45f2 100644 --- a/src/command/cmd_funcs.h +++ b/src/command/cmd_funcs.h @@ -171,8 +171,9 @@ 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_correct(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 d1315d69..acd5b383 100644 --- a/src/config/preferences.c +++ b/src/config/preferences.c @@ -1786,6 +1786,83 @@ prefs_free_ai_tokens(GList* tokens) } } +gboolean +prefs_ai_set_models(const char* const provider, const gchar* const* models, gint count) +{ + if (!provider || count <= 0) + return FALSE; + + if (!prefs) + return FALSE; + + /* Build semicolon-separated model list */ + 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]); + } + + g_key_file_set_string(prefs, PREF_GROUP_AI, g_strconcat(provider, "_models", NULL), model_str->str); + g_string_free(model_str, TRUE); + _save_prefs(); + return TRUE; +} + +gboolean +prefs_ai_remove_models(const char* const provider) +{ + if (!provider) + return FALSE; + + if (!prefs) + return FALSE; + + auto_gchar gchar* key = g_strconcat(provider, "_models", NULL); + if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) { + return FALSE; + } + + g_key_file_remove_key(prefs, PREF_GROUP_AI, key, NULL); + _save_prefs(); + return TRUE; +} + +GList* +prefs_ai_get_models(const char* const provider) +{ + if (!provider || !prefs) + return NULL; + + auto_gchar gchar* key = g_strconcat(provider, "_models", NULL); + if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) { + return NULL; + } + + auto_gchar gchar* model_str = g_key_file_get_string(prefs, PREF_GROUP_AI, key, NULL); + if (!model_str || strlen(model_str) == 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); + } +} + // 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. diff --git a/src/config/preferences.h b/src/config/preferences.h index f306ce12..bf4ed6a9 100644 --- a/src/config/preferences.h +++ b/src/config/preferences.h @@ -366,4 +366,10 @@ char* prefs_ai_get_token(const char* const provider); GList* prefs_ai_list_tokens(void); void prefs_free_ai_tokens(GList* tokens); +/* 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); + #endif diff --git a/tests/unittests/test_ai_client.c b/tests/unittests/test_ai_client.c index ee00d316..8416ce1e 100644 --- a/tests/unittests/test_ai_client.c +++ b/tests/unittests/test_ai_client.c @@ -461,3 +461,186 @@ test_ai_providers_find_case_insensitive(void** state) assert_non_null(result); assert_string_equal("openai", result); } + +/* ======================================================================== + * AI Autocomplete Integration Tests + * ======================================================================== */ + +void +test_ai_start_provider_autocomplete_only_on_exact(void** state) +{ + auto_gchar gchar* result = ai_providers_find("", FALSE, NULL); + assert_non_null(result); + assert_string_equal("openai", result); +} + +void +test_ai_models_find_null_session(void** state) +{ + auto_gchar gchar* result = ai_models_find("gpt", FALSE, NULL); + assert_null(result); +} + +void +test_ai_models_find_null_provider(void** state) +{ + ai_add_provider("temp", "https://temp.api.com/v1", NULL); + AISession* session = ai_session_create("temp", "gpt-4"); + assert_non_null(session); + ai_session_unref(session); +} + +void +test_ai_providers_find_cycling(void** state) +{ + /* NULL search_str should cycle through providers */ + auto_gchar gchar* result1 = ai_providers_find(NULL, FALSE, NULL); + assert_non_null(result1); + + auto_gchar gchar* result2 = ai_providers_find(NULL, FALSE, NULL); + assert_non_null(result2); + + /* Cycling: consecutive calls with NULL should return different providers */ + assert_string_not_equal(result1, result2); +} + +/* ======================================================================== + * Provider Default Model and Settings Tests + * ======================================================================== */ + +void +test_ai_set_provider_default_model(void** state) +{ + /* Set default model for openai provider */ + ai_set_provider_default_model("openai", "gpt-5"); + + /* Verify the model was set */ + const gchar* model = ai_get_provider_default_model("openai"); + assert_non_null(model); + assert_string_equal("gpt-5", model); + + /* Update default model */ + ai_set_provider_default_model("openai", "gpt-4o"); + model = ai_get_provider_default_model("openai"); + assert_non_null(model); + assert_string_equal("gpt-4o", model); + + /* Set default model for perplexity */ + ai_set_provider_default_model("perplexity", "sonar-pro"); + model = ai_get_provider_default_model("perplexity"); + assert_non_null(model); + assert_string_equal("sonar-pro", 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")); + + /* Default providers may or may not have default models set initially */ + /* After setting, they should return the model */ + ai_set_provider_default_model("openai", "test-model"); + assert_string_equal("test-model", ai_get_provider_default_model("openai")); + + /* NULL model argument should be ignored (no change) */ + ai_set_provider_default_model("openai", NULL); + /* After setting NULL, the model should still be "test-model" because + * ai_set_provider_default_model returns early on NULL model */ + assert_string_equal("test-model", ai_get_provider_default_model("openai")); +} + +void +test_ai_set_provider_setting(void** state) +{ + /* NULL provider should be ignored */ + ai_set_provider_setting(NULL, "tools", "enabled"); + + /* NULL setting should be ignored */ + ai_set_provider_setting("openai", NULL, "enabled"); + + /* Non-existent provider should log warning but not crash */ + ai_set_provider_setting("nonexistent", "tools", "enabled"); + + /* Set a setting for openai */ + ai_set_provider_setting("openai", "tools", "enabled"); + ai_set_provider_setting("openai", "search", "disabled"); + + /* Verify settings were set */ + gchar* tools = ai_get_provider_setting("openai", "tools"); + assert_non_null(tools); + assert_string_equal("enabled", tools); + g_free(tools); + + gchar* search = ai_get_provider_setting("openai", "search"); + assert_non_null(search); + assert_string_equal("disabled", search); + g_free(search); + + /* NULL value removes the setting */ + ai_set_provider_setting("openai", "tools", NULL); + tools = ai_get_provider_setting("openai", "tools"); + assert_null(tools); + + /* Setting that was never set returns NULL */ + assert_null(ai_get_provider_setting("openai", "nonexistent_setting")); +} + +void +test_ai_get_provider_setting(void** state) +{ + /* NULL provider returns NULL */ + assert_null(ai_get_provider_setting(NULL, "tools")); + + /* NULL setting returns NULL */ + assert_null(ai_get_provider_setting("openai", NULL)); + + /* Non-existent provider returns NULL */ + assert_null(ai_get_provider_setting("nonexistent", "tools")); + + /* Provider without settings returns NULL */ + assert_null(ai_get_provider_setting("perplexity", "tools")); + + /* Set and get setting */ + ai_set_provider_setting("openai", "temperature", "0.7"); + gchar* temp = ai_get_provider_setting("openai", "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("openai", "max_tokens", "2048"); + temp = ai_get_provider_setting("openai", "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) +{ + /* After init, models should be fresh (or at least not crash) */ + /* The ai_client_init() loads models from prefs, which may be empty initially */ + /* So we just verify the function works correctly */ + + /* NULL provider returns FALSE */ + assert_false(ai_models_are_fresh(NULL)); + + /* Non-existent provider returns FALSE */ + assert_false(ai_models_are_fresh("nonexistent")); + + /* Default providers - check that function doesn't crash */ + /* Freshness depends on whether models were loaded from prefs */ + gboolean fresh_openai = ai_models_are_fresh("openai"); + assert_true(fresh_openai == TRUE || fresh_openai == FALSE); /* Just verify no crash */ + + gboolean fresh_perplexity = ai_models_are_fresh("perplexity"); + assert_true(fresh_perplexity == TRUE || fresh_perplexity == FALSE); /* Just verify no crash */ +} diff --git a/tests/unittests/test_ai_client.h b/tests/unittests/test_ai_client.h index 7593aad8..90d1d0b6 100644 --- a/tests/unittests/test_ai_client.h +++ b/tests/unittests/test_ai_client.h @@ -40,3 +40,15 @@ void test_ai_providers_find_previous(void** state); void test_ai_providers_find_null_search_str(void** state); void test_ai_providers_find_empty_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); +/* AI autocomplete integration tests */ +void test_ai_start_provider_autocomplete_only_on_exact(void** state); +void test_ai_models_find_null_session(void** state); +void test_ai_models_find_null_provider(void** state); +void test_ai_providers_find_cycling(void** state); diff --git a/tests/unittests/unittests.c b/tests/unittests/unittests.c index 10c19aa7..9dceef57 100644 --- a/tests/unittests/unittests.c +++ b/tests/unittests/unittests.c @@ -690,6 +690,18 @@ main(int argc, char* argv[]) 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_empty_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), + /* 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_models_find_null_session, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_models_find_null_provider, 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),