feat(ai): add model caching, settings, and commands
Some checks failed
CI Code / Check spelling (pull_request) Successful in 23s
CI Code / Check coding style (pull_request) Successful in 35s
CI Code / Code Coverage (pull_request) Successful in 2m28s
CI Code / Linux (debian) (pull_request) Failing after 6m18s
CI Code / Linux (arch) (pull_request) Failing after 8m46s
CI Code / Linux (ubuntu) (pull_request) Failing after 11m4s

- Introduce model caching with persistence to preferences
- Add provider default model and custom settings management
- Implement `/ai switch`, `/ai models`, and improve `/ai start`
- Add model name autocomplete for chat commands
- Update command definitions and help text
- Add unit tests for new functionality
This commit is contained in:
2026-05-09 13:15:58 +00:00
parent 731b55fa19
commit acae543057
11 changed files with 1255 additions and 98 deletions

View File

@@ -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 <provider> <model>' to start a chat.",
g_list_length(provider->models), provider->name);
_aiwin_display_response(user_data, msg);
}
static gpointer
_models_fetch_thread(gpointer data)
{
ai_request_t* req = (ai_request_t*)data;
AIProvider* provider = ai_get_provider(req->provider_name);
if (!provider) {
g_free(req->provider_name);
g_free(req);
return NULL;
}
/* Build URL for listing models */
auto_gchar gchar* base_url = g_strdup_printf("%s%s", provider->api_url,
g_str_has_suffix(provider->api_url, "/") ? "" : "/");
auto_gchar gchar* request_url = g_strdup_printf("%sv1/models", base_url);
req->request_url = g_strdup(request_url);
req->handler = _models_response_handler;
return _ai_generic_request_thread(req);
}
gboolean
ai_fetch_models(const gchar* provider_name, gpointer user_data)
{
if (!provider_name)
return FALSE;
AIProvider* provider = ai_get_provider(provider_name);
if (!provider) {
log_error("Provider '%s' not found for model fetch", provider_name);
return FALSE;
}
/* If models are fresh, just display them */
if (provider->models_fresh && provider->models) {
cons_show("Cached %d models for provider '%s':", g_list_length(provider->models), provider_name);
GList* curr = provider->models;
while (curr) {
cons_show(" %s", (gchar*)curr->data);
curr = g_list_next(curr);
}
cons_show("");
cons_show("Use '/ai models %s --refresh' to fetch fresh models from the API.", provider_name);
return TRUE;
}
/* Fetch models in a thread */
ai_request_t* req = g_new0(ai_request_t, 1);
req->provider_name = g_strdup(provider_name);
req->user_data = user_data;
GThread* thread = g_thread_new("ai_models_fetch", _models_fetch_thread, req);
if (!thread) {
g_free(req->provider_name);
g_free(req);
log_error("Failed to create thread for model fetch");
return FALSE;
}
g_thread_unref(thread);
cons_show("Fetching models for provider '%s'...", provider_name);
return TRUE;
}
/* ========================================================================
* Session Management
* ======================================================================== */
@@ -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);

View File

@@ -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
* ======================================================================== */

View File

@@ -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 <subcommand> [<arg1>] [<arg2>] */
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 <subcommand> autocomplete (e.g., /ai s<tab> -> /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 <provider> <org_id> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai set org", ai_providers_find, previous, NULL);
// /ai set default-provider <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 <provider> <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 <provider> <setting> <value> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai set custom", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai set custom <provider> <setting> - 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 <provider> - 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 <subcommand> - 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 [<provider>/<model>] - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai start", ai_providers_find, previous, NULL);
// /ai start [<provider>] [<model>] - 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 <provider> <model> - model autocomplete */
result = _ai_model_autocomplete(window, input, previous, "/ai start ");
}
if (result) {
return result;
}
}
// /ai switch <provider> <model> - autocomplete model names for specified provider
result = _ai_model_autocomplete(window, input, previous, "/ai switch");
if (result) {
return result;
}
// /ai clear [<provider>] - 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 <provider> <text>
result = autocomplete_param_with_func(input, "/ai correct", ai_providers_find, previous, NULL);
// /ai switch <model> - 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 <provider> - 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 model names for /ai <cmd> <provider> <model> 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;
}

View File

@@ -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 <name> <url>",
"/ai set token <provider> <token>",
"/ai set org <provider> <org_id>",
"/ai set default-provider <provider>",
"/ai set default-model <provider> <model>",
"/ai set custom <provider> <setting> <value>",
"/ai remove provider <name>",
"/ai providers",
"/ai providers-list",
"/ai start [<provider>/<model>]",
"/ai model <model>",
"/ai clear",
"/ai correct <message>")
"/ai start [<provider>] [<model>]",
"/ai switch <provider> [<model>]",
"/ai switch <model>",
"/ai models <provider>",
"/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 <name> <url>", "Add or update a provider with custom API endpoint" },
{ "set token <provider> <token>", "Set API token for a provider (e.g., openai, perplexity)" },
{ "set org <provider> <org_id>", "Set organization ID for a provider (optional)" },
{ "set default-provider <provider>", "Set global default provider for /ai start" },
{ "set default-model <provider> <model>", "Set default model for a provider" },
{ "set custom <provider> <setting> <value>", "Set provider-level setting (e.g., tools, search)" },
{ "remove provider <name>", "Remove a custom provider" },
{ "providers", "List all available providers" },
{ "providers-list", "List configured providers with keys status" },
{ "start [<provider>/<model>]", "Start new AI chat (e.g., openai/gpt-4o)" },
{ "model <model>", "Change model in current chat window" },
{ "clear", "Clear current chat history" },
{ "correct <message>", "Correct last user message and get new response" })
{ "providers", "List configured providers with full details" },
{ "start [<provider>] [<model>]", "Start new AI chat (space-separated, uses defaults if omitted)" },
{ "switch <provider> [<model>]", "Switch to different provider (and optionally model)" },
{ "switch <model>", "Change model in current session (keeps provider)" },
{ "models <provider>", "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)

View File

@@ -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 <provider>/<model>' to begin a chat.");
cons_show("Use '/ai start' to begin a chat (uses default provider/model).");
cons_show("Use '/ai models <provider>' 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 <provider> <org_id>
} else if (g_strcmp0(args[1], "default-model") == 0) {
// /ai set default-model <provider> <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 <provider> <setting> <value>
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 <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 <url>' 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 [<provider>/<model>]
const gchar* provider_model = (g_strv_length(args) >= 2) ? args[1] : NULL;
// /ai start [<provider>] [<model>]
// 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 <url>' 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 <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 <provider> [<model>] - Change provider and optionally model
// /ai switch <model> - 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 <model>' or '/ai switch %s <model>'.", 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 <key>' 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 <provider> [--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)
{

View File

@@ -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);

View File

@@ -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.

View File

@@ -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

View File

@@ -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 */
}

View File

@@ -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);

View File

@@ -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),