Compare commits
1 Commits
21147f525c
...
test/ai-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
e67ae4e7a5
|
@@ -203,8 +203,13 @@ functionaltest_sources = \
|
||||
tests/functionaltests/test_autoping.c tests/functionaltests/test_autoping.h \
|
||||
tests/functionaltests/test_disco.c tests/functionaltests/test_disco.h \
|
||||
tests/functionaltests/test_export_import.c tests/functionaltests/test_export_import.h \
|
||||
tests/functionaltests/test_ai.c tests/functionaltests/test_ai.h \
|
||||
tests/functionaltests/ai_http_stub.c tests/functionaltests/ai_http_stub.h \
|
||||
tests/functionaltests/test_ai_http.c tests/functionaltests/test_ai_http.h \
|
||||
tests/functionaltests/functionaltests.c
|
||||
|
||||
dist_check_DATA = tests/functionaltests/ai_http_stub.py
|
||||
|
||||
main_source = src/main.c
|
||||
|
||||
python_sources = \
|
||||
|
||||
@@ -21,9 +21,12 @@
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <glib.h>
|
||||
#include <pthread.h>
|
||||
#include <string.h>
|
||||
|
||||
/* Default providers */
|
||||
#define DEFAULT_OPENAI_URL "https://api.openai.com/"
|
||||
#define DEFAULT_PERPLEXITY_URL "https://api.perplexity.ai/"
|
||||
|
||||
/* Global state */
|
||||
static GHashTable* providers = NULL;
|
||||
static GHashTable* provider_keys = NULL;
|
||||
@@ -32,7 +35,6 @@ static Autocomplete providers_ac = NULL;
|
||||
/* ========================================================================
|
||||
* Forward declarations
|
||||
* ======================================================================== */
|
||||
static AIProvider* _ai_add_provider_nopersist(const gchar* name, const gchar* api_url);
|
||||
static void _ai_load_models_for_provider(AIProvider* provider);
|
||||
static void _ai_save_models_for_provider(AIProvider* provider);
|
||||
|
||||
@@ -88,7 +90,9 @@ _aiwin_validate(gpointer user_data)
|
||||
return NULL;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&lock);
|
||||
gboolean win_exists = wins_ai_exists((ProfAiWin*)user_data);
|
||||
pthread_mutex_unlock(&lock);
|
||||
if (!win_exists) {
|
||||
log_warning("[AI-THREAD] aiwin=%p no longer exists — window was closed", (void*)user_data);
|
||||
return NULL;
|
||||
@@ -106,12 +110,12 @@ _aiwin_validate(gpointer user_data)
|
||||
static void
|
||||
_aiwin_display_error(gpointer user_data, const gchar* error_msg)
|
||||
{
|
||||
pthread_mutex_lock(&lock);
|
||||
ProfAiWin* aiwin = _aiwin_validate(user_data);
|
||||
if (!aiwin) {
|
||||
log_warning("[AI-THREAD] Cannot display error: aiwin is invalid or window was closed (msg: %s)", error_msg);
|
||||
return;
|
||||
}
|
||||
pthread_mutex_lock(&lock);
|
||||
aiwin_display_error(aiwin, error_msg);
|
||||
pthread_mutex_unlock(&lock);
|
||||
}
|
||||
@@ -129,14 +133,16 @@ _aiwin_display_response(gpointer user_data, const gchar* response)
|
||||
return;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&lock);
|
||||
/* Try to display in AI window if available */
|
||||
ProfAiWin* aiwin = _aiwin_validate(user_data);
|
||||
if (!aiwin) {
|
||||
pthread_mutex_lock(&lock);
|
||||
cons_show("%s", response);
|
||||
pthread_mutex_unlock(&lock);
|
||||
return;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&lock);
|
||||
aiwin_display_response(aiwin, response);
|
||||
pthread_mutex_unlock(&lock);
|
||||
}
|
||||
@@ -188,11 +194,12 @@ ai_json_escape(const gchar* str)
|
||||
* ======================================================================== */
|
||||
|
||||
static AIProvider*
|
||||
ai_provider_new(const gchar* name, const gchar* api_url)
|
||||
ai_provider_new(const gchar* name, const gchar* api_url, const gchar* org_id)
|
||||
{
|
||||
AIProvider* provider = g_new0(AIProvider, 1);
|
||||
provider->name = g_strdup(name);
|
||||
provider->api_url = g_strdup(api_url ? api_url : "");
|
||||
provider->org_id = g_strdup(org_id);
|
||||
provider->project_id = NULL;
|
||||
provider->default_model = NULL;
|
||||
provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
|
||||
@@ -202,7 +209,7 @@ ai_provider_new(const gchar* name, const gchar* api_url)
|
||||
return provider;
|
||||
}
|
||||
|
||||
AIProvider*
|
||||
static AIProvider*
|
||||
ai_provider_ref(AIProvider* provider)
|
||||
{
|
||||
if (provider) {
|
||||
@@ -223,6 +230,7 @@ ai_provider_unref(AIProvider* provider)
|
||||
|
||||
g_free(provider->name);
|
||||
g_free(provider->api_url);
|
||||
g_free(provider->org_id);
|
||||
g_free(provider->project_id);
|
||||
g_free(provider->default_model);
|
||||
g_hash_table_destroy(provider->settings);
|
||||
@@ -279,33 +287,24 @@ ai_client_init(void)
|
||||
/* Create autocomplete for provider names */
|
||||
providers_ac = autocomplete_new();
|
||||
|
||||
/* Get all providers (defaults if nothing configured, otherwise config values) */
|
||||
GList* all_providers = prefs_ai_get_providers();
|
||||
for (GList* curr = all_providers; curr; curr = g_list_next(curr)) {
|
||||
/* List contains pairs: name, url, name, url, ... */
|
||||
gchar* name = (gchar*)curr->data;
|
||||
curr = g_list_next(curr);
|
||||
if (!curr)
|
||||
break;
|
||||
gchar* url = (gchar*)curr->data;
|
||||
_ai_add_provider_nopersist(name, url);
|
||||
}
|
||||
prefs_free_ai_providers(all_providers);
|
||||
/* Add default providers */
|
||||
ai_add_provider("openai", DEFAULT_OPENAI_URL, NULL);
|
||||
ai_add_provider("perplexity", DEFAULT_PERPLEXITY_URL, NULL);
|
||||
|
||||
/* Load saved API keys from config */
|
||||
ai_load_keys();
|
||||
|
||||
/* Load cached models for all providers */
|
||||
GList* provider_list = ai_list_providers();
|
||||
GList* models_curr = provider_list;
|
||||
while (models_curr) {
|
||||
AIProvider* provider = (AIProvider*)models_curr->data;
|
||||
GList* curr = provider_list;
|
||||
while (curr) {
|
||||
AIProvider* provider = (AIProvider*)curr->data;
|
||||
_ai_load_models_for_provider(provider);
|
||||
models_curr = g_list_next(models_curr);
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
g_list_free(provider_list);
|
||||
|
||||
log_info("AI client initialized with providers from preferences");
|
||||
log_info("AI client initialized with default providers: openai, perplexity");
|
||||
}
|
||||
|
||||
void
|
||||
@@ -336,31 +335,8 @@ ai_get_provider(const gchar* name)
|
||||
return g_hash_table_lookup(providers, name);
|
||||
}
|
||||
|
||||
/* Internal helper to add provider without persisting to config.
|
||||
* Used for default providers and loading from config on startup. */
|
||||
static AIProvider*
|
||||
_ai_add_provider_nopersist(const gchar* name, const gchar* api_url)
|
||||
{
|
||||
/* Check if provider already exists */
|
||||
AIProvider* existing = g_hash_table_lookup(providers, name);
|
||||
if (existing) {
|
||||
g_free(existing->api_url);
|
||||
existing->api_url = g_strdup(api_url);
|
||||
return ai_provider_ref(existing);
|
||||
}
|
||||
|
||||
/* Create new provider (ref_count=1 owned by hash table) */
|
||||
AIProvider* provider = ai_provider_new(name, api_url);
|
||||
g_hash_table_insert(providers, g_strdup(name), provider);
|
||||
|
||||
/* Sync autocomplete */
|
||||
autocomplete_add(providers_ac, name);
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
AIProvider*
|
||||
ai_add_provider(const gchar* name, const gchar* api_url)
|
||||
ai_add_provider(const gchar* name, const gchar* api_url, const gchar* org_id)
|
||||
{
|
||||
if (!name || !api_url)
|
||||
return NULL;
|
||||
@@ -375,17 +351,18 @@ ai_add_provider(const gchar* name, const gchar* api_url)
|
||||
/* Update existing provider */
|
||||
g_free(existing->api_url);
|
||||
existing->api_url = g_strdup(api_url);
|
||||
/* Persist the updated URL to config */
|
||||
prefs_ai_set_provider(name, api_url);
|
||||
g_free(existing->org_id);
|
||||
existing->org_id = g_strdup(org_id);
|
||||
log_info("Updated provider: %s", name);
|
||||
return ai_provider_ref(existing);
|
||||
}
|
||||
|
||||
/* Persist the new provider to config */
|
||||
prefs_ai_set_provider(name, api_url);
|
||||
/* Create new provider (ref_count=1 owned by hash table) */
|
||||
AIProvider* provider = ai_provider_new(name, api_url, org_id);
|
||||
g_hash_table_insert(providers, g_strdup(name), provider);
|
||||
|
||||
/* Add to hash table (reuse internal helper) */
|
||||
AIProvider* provider = _ai_add_provider_nopersist(name, api_url);
|
||||
/* Sync autocomplete */
|
||||
autocomplete_add(providers_ac, name);
|
||||
|
||||
log_info("Added provider: %s (URL: %s)", name, api_url);
|
||||
return provider; /* Caller gets non-owning pointer; hash table owns ref */
|
||||
@@ -397,9 +374,6 @@ ai_remove_provider(const gchar* name)
|
||||
if (!name || !providers)
|
||||
return FALSE;
|
||||
|
||||
/* Remove from config before removing from hash table */
|
||||
prefs_ai_remove_provider(name);
|
||||
|
||||
/* Sync autocomplete before removing */
|
||||
autocomplete_remove(providers_ac, name);
|
||||
|
||||
@@ -428,26 +402,53 @@ ai_list_providers(void)
|
||||
* Provider autocomplete state
|
||||
* ======================================================================== */
|
||||
|
||||
/* Stateful provider name finder with case-sensitive matching.
|
||||
* Maintains position in sorted list for deterministic tab-completion cycling.
|
||||
* The autocomplete is kept in sync via ai_add_provider/ai_remove_provider. */
|
||||
gchar*
|
||||
ai_providers_find(const char* const search_str, gboolean previous, void* context)
|
||||
{
|
||||
/* Initialize autocomplete on first use */
|
||||
if (!providers_ac) {
|
||||
log_debug("[AI-AC] Uninitialized call to ai_providers_find");
|
||||
providers_ac = autocomplete_new();
|
||||
}
|
||||
|
||||
/* NULL search_str is treated as empty string for cycling */
|
||||
const char* effective_search = (search_str != NULL) ? search_str : "";
|
||||
|
||||
/* Use stateful autocomplete */
|
||||
return autocomplete_complete(providers_ac, effective_search, FALSE, previous);
|
||||
}
|
||||
|
||||
/* Stateful model name finder for autocomplete.
|
||||
* Uses the current session's provider models for completion. */
|
||||
gchar*
|
||||
ai_models_find(const char* const search_str, gboolean previous, void* context)
|
||||
{
|
||||
ProfAiWin* aiwin = (ProfAiWin*)context;
|
||||
if (!aiwin || !aiwin->session) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return autocomplete_complete(providers_ac, search_str, FALSE, previous);
|
||||
}
|
||||
|
||||
/* Reset the provider autocomplete state.
|
||||
* Called from cmd_ac_reset() to clear autocomplete state when the user presses Ctrl+C. */
|
||||
void
|
||||
ai_providers_reset_ac(void)
|
||||
{
|
||||
if (!providers_ac) {
|
||||
return;
|
||||
AIProvider* provider = aiwin->session->provider;
|
||||
if (!provider || !provider->models) {
|
||||
return NULL;
|
||||
}
|
||||
autocomplete_reset(providers_ac);
|
||||
|
||||
/* 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*
|
||||
@@ -570,7 +571,7 @@ typedef struct
|
||||
} ai_request_t;
|
||||
|
||||
/**
|
||||
* Build curl headers with auth.
|
||||
* Build curl headers with auth and optional org header.
|
||||
* Returns headers list (caller must free with curl_slist_free_all).
|
||||
*/
|
||||
static struct curl_slist*
|
||||
@@ -581,6 +582,11 @@ _build_curl_headers(AIProvider* provider, const gchar* 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;
|
||||
}
|
||||
|
||||
@@ -632,10 +638,6 @@ _ai_generic_request_thread(gpointer data)
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Keep the provider alive for the duration of this request. Without this, /ai remove provider X could free the provider
|
||||
* struct (via the hash table's destroy-notify) while curl_easy_perform is still using it — classic UAF. */
|
||||
ai_provider_ref(provider);
|
||||
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) {
|
||||
log_error("Failed to initialize curl for %s", req->provider_name);
|
||||
@@ -666,10 +668,6 @@ _ai_generic_request_thread(gpointer data)
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
g_free(response.data);
|
||||
|
||||
/* Release the reference taken after lookup — provider may now be freed. */
|
||||
ai_provider_unref(provider);
|
||||
|
||||
g_free(req->provider_name);
|
||||
g_free(req->request_url);
|
||||
g_free(req);
|
||||
@@ -1052,11 +1050,6 @@ ai_session_create(const gchar* provider_name, const gchar* model)
|
||||
}
|
||||
|
||||
AISession* session = g_new0(AISession, 1);
|
||||
if (pthread_mutex_init(&session->lock, NULL) != 0) {
|
||||
log_error("Failed to initialize session mutex");
|
||||
g_free(session);
|
||||
return NULL;
|
||||
}
|
||||
session->provider_name = g_strdup(provider_name);
|
||||
session->provider = ai_provider_ref(provider);
|
||||
session->model = g_strdup(model);
|
||||
@@ -1087,7 +1080,6 @@ ai_session_unref(AISession* session)
|
||||
return;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&session->lock);
|
||||
g_free(session->provider_name);
|
||||
ai_provider_unref(session->provider);
|
||||
g_free(session->model);
|
||||
@@ -1102,7 +1094,6 @@ ai_session_unref(AISession* session)
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
g_list_free(session->history);
|
||||
pthread_mutex_unlock(&session->lock);
|
||||
|
||||
g_free(session);
|
||||
log_debug("AI session destroyed");
|
||||
@@ -1114,12 +1105,10 @@ ai_session_add_message(AISession* session, const gchar* role, const gchar* conte
|
||||
if (!session || !role || !content)
|
||||
return;
|
||||
|
||||
pthread_mutex_lock(&session->lock);
|
||||
AIMessage* msg = g_new0(AIMessage, 1);
|
||||
msg->role = g_strdup(role);
|
||||
msg->content = g_strdup(content);
|
||||
session->history = g_list_append(session->history, msg);
|
||||
pthread_mutex_unlock(&session->lock);
|
||||
|
||||
log_debug("Added %s message to session (total: %d)", role, g_list_length(session->history));
|
||||
}
|
||||
@@ -1130,7 +1119,6 @@ ai_session_clear_history(AISession* session)
|
||||
if (!session)
|
||||
return;
|
||||
|
||||
pthread_mutex_lock(&session->lock);
|
||||
GList* curr = session->history;
|
||||
while (curr) {
|
||||
AIMessage* msg = curr->data;
|
||||
@@ -1141,7 +1129,6 @@ ai_session_clear_history(AISession* session)
|
||||
}
|
||||
g_list_free(session->history);
|
||||
session->history = NULL;
|
||||
pthread_mutex_unlock(&session->lock);
|
||||
|
||||
log_info("AI session history cleared");
|
||||
}
|
||||
@@ -1160,64 +1147,24 @@ ai_session_set_model(AISession* session, const gchar* model)
|
||||
if (!session || !model)
|
||||
return;
|
||||
|
||||
pthread_mutex_lock(&session->lock);
|
||||
g_free(session->model);
|
||||
session->model = g_strdup(model);
|
||||
pthread_mutex_unlock(&session->lock);
|
||||
log_info("Session model changed to: %s", model);
|
||||
}
|
||||
|
||||
void
|
||||
ai_session_switch(AISession* session, const gchar* provider_name,
|
||||
const gchar* model, gchar* api_key)
|
||||
{
|
||||
if (!session || !provider_name || !model || !api_key)
|
||||
return;
|
||||
|
||||
AIProvider* provider = ai_get_provider(provider_name);
|
||||
if (!provider) {
|
||||
log_warning("Provider '%s' not found for session switch", provider_name);
|
||||
g_free(api_key);
|
||||
return;
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&session->lock);
|
||||
g_free(session->provider_name);
|
||||
session->provider_name = g_strdup(provider_name);
|
||||
|
||||
ai_provider_unref(session->provider);
|
||||
session->provider = ai_provider_ref(provider);
|
||||
|
||||
g_free(session->model);
|
||||
session->model = g_strdup(model);
|
||||
|
||||
g_free(session->api_key);
|
||||
session->api_key = g_strdup(api_key);
|
||||
pthread_mutex_unlock(&session->lock);
|
||||
|
||||
g_free(api_key);
|
||||
log_info("Session switched to %s/%s", provider_name, model);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* API Request Handling
|
||||
* ======================================================================== */
|
||||
|
||||
/**
|
||||
* Build JSON payload from a GList of AIMessage nodes and a prompt string.
|
||||
* This is the thread-safe variant used by _ai_request_thread() which holds
|
||||
* the session lock while calling it.
|
||||
*
|
||||
* @param model The model identifier
|
||||
* @param history GList of AIMessage* (must be held under session lock)
|
||||
* @param prompt The new user prompt to append
|
||||
* @return Newly allocated JSON string (caller must free)
|
||||
*/
|
||||
static gchar*
|
||||
_build_json_payload_from_list(const gchar* model, GList* history, const gchar* prompt)
|
||||
_build_json_payload(AISession* session, const gchar* prompt)
|
||||
{
|
||||
|
||||
/* OpenAI-compatible Responses API format:
|
||||
* {"model": "...", "input": [...], "stream": false, "store": false}
|
||||
* store:false prevents providers from storing/using requests for training */
|
||||
GString* messages_json = g_string_new("");
|
||||
GList* curr = history;
|
||||
GList* curr = session->history;
|
||||
|
||||
while (curr) {
|
||||
AIMessage* msg = curr->data;
|
||||
@@ -1240,7 +1187,7 @@ _build_json_payload_from_list(const gchar* model, GList* history, const gchar* p
|
||||
}
|
||||
g_string_append_printf(messages_json, "{\"role\":\"user\",\"content\":\"%s\"}", escaped_prompt);
|
||||
|
||||
auto_gchar gchar* escaped_model = ai_json_escape(model);
|
||||
auto_gchar gchar* escaped_model = ai_json_escape(session->model);
|
||||
gchar* json_payload = g_strdup_printf(
|
||||
"{\"model\":\"%s\",\"input\":[%s],\"stream\":false,\"store\":false}",
|
||||
escaped_model, messages_json->str);
|
||||
@@ -1249,8 +1196,8 @@ _build_json_payload_from_list(const gchar* model, GList* history, const gchar* p
|
||||
return json_payload;
|
||||
}
|
||||
|
||||
static gchar*
|
||||
_parse_ai_response(const gchar* response_json)
|
||||
gchar*
|
||||
ai_parse_response(const gchar* response_json)
|
||||
{
|
||||
if (!response_json || strlen(response_json) == 0)
|
||||
return NULL;
|
||||
@@ -1336,8 +1283,8 @@ _parse_ai_response(const gchar* response_json)
|
||||
* Extract error message from an API error JSON response.
|
||||
* Handles format: {"error":{"message":"...","type":"...","code":...}}
|
||||
*/
|
||||
static gchar*
|
||||
_parse_error_response(const gchar* error_json)
|
||||
gchar*
|
||||
ai_parse_error_message(const gchar* error_json)
|
||||
{
|
||||
if (!error_json || strlen(error_json) == 0)
|
||||
return NULL;
|
||||
@@ -1401,31 +1348,14 @@ _ai_request_thread(gpointer data)
|
||||
auto_gchar gchar* prompt = args[1];
|
||||
gpointer user_data = args[2];
|
||||
|
||||
/* =====================================================================
|
||||
* CRITICAL: Copy all session state under lock to prevent UAF races with
|
||||
* cmd_ai_switch() and ai_session_clear_history() which mutate session
|
||||
* fields on the main thread without holding a session ref.
|
||||
*
|
||||
* The worker holds a ref to the session (via ai_session_ref in
|
||||
* ai_send_prompt), which prevents the session struct from being freed,
|
||||
* but does NOT prevent field mutations (g_free/g_strdup of strings,
|
||||
* list modifications). We must snapshot all fields atomically.
|
||||
* ===================================================================== */
|
||||
pthread_mutex_lock(&session->lock);
|
||||
gchar* local_provider_name = g_strdup(session->provider_name);
|
||||
AIProvider* local_provider = ai_provider_ref(session->provider);
|
||||
gchar* local_model = g_strdup(session->model);
|
||||
gchar* local_api_key = g_strdup(session->api_key ? session->api_key : "");
|
||||
pthread_mutex_unlock(&session->lock);
|
||||
|
||||
log_debug("[AI-THREAD] Session: %s/%s", local_provider_name, local_model);
|
||||
log_debug("[AI-THREAD] API key length: %zu", strlen(local_api_key));
|
||||
log_debug("[AI-THREAD] Session: %s/%s", session->provider_name, session->model);
|
||||
log_debug("[AI-THREAD] API key length: %zu", session->api_key ? strlen(session->api_key) : 0);
|
||||
|
||||
/* Check for API key first */
|
||||
if (!local_api_key || strlen(local_api_key) == 0) {
|
||||
if (!session->api_key || strlen(session->api_key) == 0) {
|
||||
auto_gchar gchar* error_msg = g_strdup_printf("No API key set for provider '%s'. Use '/ai set token %s <key>' to configure.",
|
||||
local_provider_name, local_provider_name);
|
||||
log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg);
|
||||
session->provider_name, session->provider_name);
|
||||
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
|
||||
_aiwin_display_error(user_data, error_msg);
|
||||
g_free(args);
|
||||
return NULL;
|
||||
@@ -1435,30 +1365,23 @@ _ai_request_thread(gpointer data)
|
||||
log_debug("[AI-THREAD] Curl initialized: %s", curl ? "OK" : "FAILED");
|
||||
if (!curl) {
|
||||
log_error("AI request failed for %s/%s: Failed to initialize curl",
|
||||
local_provider_name, local_model);
|
||||
session->provider_name, session->model);
|
||||
_aiwin_display_error(user_data, "Failed to initialize curl.");
|
||||
g_free(args);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Add user message to history FIRST (under lock) */
|
||||
pthread_mutex_lock(&session->lock);
|
||||
AIMessage* user_msg = g_new0(AIMessage, 1);
|
||||
user_msg->role = g_strdup("user");
|
||||
user_msg->content = g_strdup(prompt);
|
||||
session->history = g_list_append(session->history, user_msg);
|
||||
pthread_mutex_unlock(&session->lock);
|
||||
/* Add user message to history FIRST */
|
||||
ai_session_add_message(session, "user", prompt);
|
||||
log_debug("[AI-THREAD] Added user message to history");
|
||||
|
||||
/* Build JSON payload from local history copy (under lock) */
|
||||
/* Build JSON payload (includes the message we just added) */
|
||||
log_debug("[AI-THREAD] Building JSON payload...");
|
||||
pthread_mutex_lock(&session->lock);
|
||||
auto_gchar gchar* json_payload = _build_json_payload_from_list(local_model, session->history, prompt);
|
||||
pthread_mutex_unlock(&session->lock);
|
||||
auto_gchar gchar* json_payload = _build_json_payload(session, prompt);
|
||||
log_debug("[AI-THREAD] JSON payload: %s", json_payload);
|
||||
|
||||
/* Build headers using shared helper */
|
||||
struct curl_slist* headers = _build_curl_headers(local_provider, local_api_key);
|
||||
struct curl_slist* headers = _build_curl_headers(session->provider, session->api_key);
|
||||
|
||||
/* Response buffer */
|
||||
struct curl_response_t response;
|
||||
@@ -1466,11 +1389,11 @@ _ai_request_thread(gpointer data)
|
||||
response.size = 0;
|
||||
|
||||
/* Build request URL */
|
||||
const gchar* api_url = local_provider->api_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", local_model);
|
||||
log_debug("[AI-THREAD] Model: %s", session->model);
|
||||
|
||||
/* Set URL and execute request */
|
||||
curl_easy_setopt(curl, CURLOPT_URL, request_url);
|
||||
@@ -1490,35 +1413,30 @@ _ai_request_thread(gpointer data)
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
auto_gchar gchar* error_msg = g_strdup(curl_easy_strerror(res));
|
||||
log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg);
|
||||
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
|
||||
_aiwin_display_error(user_data, error_msg);
|
||||
} else if (http_code >= 400) {
|
||||
/* Handle HTTP errors */
|
||||
log_debug("[AI-THREAD] HTTP error response body (%zu bytes): %s",
|
||||
response.size, response.data ? response.data : "NULL");
|
||||
/* Try to extract the actual error message from the JSON response */
|
||||
auto_gchar gchar* parsed_error = _parse_error_response(response.data);
|
||||
auto_gchar gchar* parsed_error = ai_parse_error_message(response.data);
|
||||
auto_gchar gchar* error_msg = parsed_error ? g_strdup_printf("HTTP %ld: %s", http_code, parsed_error) : g_strdup_printf("HTTP %ld: %s", http_code, response.data && strlen(response.data) > 0 ? response.data : "Unknown error");
|
||||
log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg);
|
||||
log_error("AI request failed for %s/%s: %s", session->provider_name, session->model, error_msg);
|
||||
_aiwin_display_error(user_data, error_msg);
|
||||
} else {
|
||||
/* Parse response - transfer ownership to auto_gchar for cleanup */
|
||||
log_debug("[AI-THREAD] Raw API response (%zu bytes): %s", response.size, response.data ? response.data : "NULL");
|
||||
auto_gchar gchar* response_data = response.data;
|
||||
response.data = NULL;
|
||||
auto_gchar gchar* content = _parse_ai_response(response_data);
|
||||
auto_gchar gchar* content = ai_parse_response(response_data);
|
||||
if (content) {
|
||||
/* Add assistant response to history (under lock) */
|
||||
pthread_mutex_lock(&session->lock);
|
||||
AIMessage* assistant_msg = g_new0(AIMessage, 1);
|
||||
assistant_msg->role = g_strdup("assistant");
|
||||
assistant_msg->content = g_strdup(content);
|
||||
session->history = g_list_append(session->history, assistant_msg);
|
||||
pthread_mutex_unlock(&session->lock);
|
||||
/* Add assistant response to history */
|
||||
ai_session_add_message(session, "assistant", content);
|
||||
_aiwin_display_response(user_data, content);
|
||||
} else {
|
||||
log_error("AI response parse failed for %s/%s: %.200s...",
|
||||
local_provider_name, local_model, response_data);
|
||||
session->provider_name, session->model, response_data);
|
||||
_aiwin_display_error(user_data, "Failed to parse AI response.");
|
||||
}
|
||||
}
|
||||
@@ -1526,12 +1444,6 @@ _ai_request_thread(gpointer data)
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
/* Cleanup local copies */
|
||||
g_free(local_provider_name);
|
||||
ai_provider_unref(local_provider);
|
||||
g_free(local_model);
|
||||
g_free(local_api_key);
|
||||
|
||||
g_free(args);
|
||||
|
||||
return NULL;
|
||||
|
||||
@@ -19,6 +19,7 @@ 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) */
|
||||
gchar* default_model; /* Default model for this provider */
|
||||
GHashTable* settings; /* Extensible per-provider settings (e.g., tools=enabled, search=disabled) */
|
||||
@@ -32,7 +33,6 @@ typedef struct ai_provider_t
|
||||
*/
|
||||
typedef struct ai_session_t
|
||||
{
|
||||
pthread_mutex_t lock; /* Protects all session fields below */
|
||||
gchar* provider_name; /* Provider name */
|
||||
AIProvider* provider; /* Provider configuration */
|
||||
gchar* model; /* Model identifier (e.g., "gpt-4", "sonar") */
|
||||
@@ -66,9 +66,10 @@ AIProvider* ai_get_provider(const gchar* name);
|
||||
* Add or update a provider configuration.
|
||||
* @param name The provider name
|
||||
* @param api_url The API endpoint URL
|
||||
* @param org_id Optional organization ID (can be NULL)
|
||||
* @return New AIProvider* (caller must unref when done)
|
||||
*/
|
||||
AIProvider* ai_add_provider(const gchar* name, const gchar* api_url);
|
||||
AIProvider* ai_add_provider(const gchar* name, const gchar* api_url, const gchar* org_id);
|
||||
|
||||
/**
|
||||
* Remove a provider by name.
|
||||
@@ -77,13 +78,6 @@ AIProvider* ai_add_provider(const gchar* name, const gchar* api_url);
|
||||
*/
|
||||
gboolean ai_remove_provider(const gchar* name);
|
||||
|
||||
/**
|
||||
* Increment the reference count of a provider.
|
||||
* @param provider The provider to reference
|
||||
* @return The same provider pointer
|
||||
*/
|
||||
AIProvider* ai_provider_ref(AIProvider* provider);
|
||||
|
||||
/**
|
||||
* Decrement the reference count of a provider.
|
||||
* @param provider The provider to unreference
|
||||
@@ -115,12 +109,6 @@ gchar* ai_providers_find(const char* const search_str, gboolean previous, void*
|
||||
*/
|
||||
gchar* ai_models_find(const char* const search_str, gboolean previous, void* context);
|
||||
|
||||
/**
|
||||
* Reset the provider autocomplete state.
|
||||
* Called from cmd_ac_reset() to clear autocomplete state.
|
||||
*/
|
||||
void ai_providers_reset_ac(void);
|
||||
|
||||
/**
|
||||
* Get the API key for a provider.
|
||||
* @param provider_name The provider name
|
||||
@@ -181,7 +169,7 @@ gboolean ai_fetch_models(const gchar* provider_name, gpointer user_data);
|
||||
gboolean ai_models_are_fresh(const gchar* provider_name);
|
||||
|
||||
/* ========================================================================
|
||||
* Model Parsing (for testing)
|
||||
* Parsing helpers (exposed for testing)
|
||||
* ======================================================================== */
|
||||
|
||||
/**
|
||||
@@ -192,6 +180,24 @@ gboolean ai_models_are_fresh(const gchar* provider_name);
|
||||
*/
|
||||
void ai_parse_models_from_json(AIProvider* provider, const gchar* json);
|
||||
|
||||
/**
|
||||
* Extract assistant content from an LLM response body. Tries Perplexity
|
||||
* /v1/responses "text" first, falls back to OpenAI "content". Decodes
|
||||
* the \" and \n escape sequences only.
|
||||
* @param response_json The JSON body from the provider
|
||||
* @return Newly allocated content string, or NULL if not found (caller frees)
|
||||
*/
|
||||
gchar* ai_parse_response(const gchar* response_json);
|
||||
|
||||
/**
|
||||
* Extract human-readable error message from an API error envelope.
|
||||
* Expected format: {"error":{"message":"...","type":"...","code":...}}
|
||||
* Decodes \" \n \\ \t escapes.
|
||||
* @param error_json The JSON body of the error response
|
||||
* @return Newly allocated error message, or NULL if not parseable (caller frees)
|
||||
*/
|
||||
gchar* ai_parse_error_message(const gchar* error_json);
|
||||
|
||||
/* ========================================================================
|
||||
* Session Management
|
||||
* ======================================================================== */
|
||||
@@ -245,19 +251,6 @@ const gchar* ai_session_get_model(AISession* session);
|
||||
*/
|
||||
void ai_session_set_model(AISession* session, const gchar* model);
|
||||
|
||||
/**
|
||||
* Atomically switch session provider, model, and API key.
|
||||
* All mutations happen under the session lock to prevent races with
|
||||
* _ai_request_thread() which snapshots session state before making requests.
|
||||
*
|
||||
* @param session The session
|
||||
* @param provider_name New provider name
|
||||
* @param model New model identifier
|
||||
* @param api_key New API key (caller must free after calling this)
|
||||
*/
|
||||
void ai_session_switch(AISession* session, const gchar* provider_name,
|
||||
const gchar* model, gchar* api_key);
|
||||
|
||||
/* ========================================================================
|
||||
* Request Handling
|
||||
* ======================================================================== */
|
||||
|
||||
@@ -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);
|
||||
@@ -468,8 +469,7 @@ static Autocomplete* all_acs[] = {
|
||||
&ai_subcommands_ac,
|
||||
&ai_set_subcommands_ac,
|
||||
&ai_set_custom_subcommands_ac,
|
||||
&ai_remove_subcommands_ac,
|
||||
&ai_models_ac
|
||||
&ai_remove_subcommands_ac
|
||||
};
|
||||
|
||||
static GHashTable* ac_funcs = NULL;
|
||||
@@ -1192,6 +1192,7 @@ cmd_ac_init(void)
|
||||
|
||||
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);
|
||||
|
||||
@@ -1696,7 +1697,6 @@ cmd_ac_reset(ProfWin* window)
|
||||
win_reset_search_attempts();
|
||||
win_close_reset_search_attempts();
|
||||
plugins_reset_autocomplete();
|
||||
ai_providers_reset_ac();
|
||||
}
|
||||
|
||||
void
|
||||
@@ -4487,10 +4487,6 @@ _force_encryption_autocomplete(ProfWin* window, const char* const input, gboolea
|
||||
return result;
|
||||
}
|
||||
|
||||
/* Forward declaration */
|
||||
static char* _ai_provider_and_model_autocomplete(ProfWin* window, const char* const input,
|
||||
gboolean previous, const char* cmd);
|
||||
|
||||
static char*
|
||||
_ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
|
||||
{
|
||||
@@ -4499,6 +4495,7 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
|
||||
/* 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) */
|
||||
@@ -4513,6 +4510,12 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
|
||||
return result;
|
||||
}
|
||||
|
||||
// /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) {
|
||||
@@ -4554,20 +4557,34 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai start <provider> <model> - autocomplete provider names and model names
|
||||
result = _ai_provider_and_model_autocomplete(window, input, previous, "/ai start");
|
||||
// /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 switch <provider> <model> - autocomplete provider names and model names
|
||||
result = _ai_provider_and_model_autocomplete(window, input, previous, "/ai switch");
|
||||
result = autocomplete_param_with_func(input, "/ai switch", ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// /ai set default-model <provider> <model> - autocomplete provider names and model names
|
||||
result = _ai_provider_and_model_autocomplete(window, input, previous, "/ai set default-model");
|
||||
// /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;
|
||||
}
|
||||
@@ -4587,26 +4604,13 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocomplete provider names and model names for /ai <cmd> <provider> <model> patterns.
|
||||
* First tries to autocomplete provider names, then model names if provider is valid.
|
||||
* 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.
|
||||
* The cmd_prefix should NOT include trailing space (e.g., "/ai start" not "/ai start ").
|
||||
*/
|
||||
static char*
|
||||
_ai_provider_and_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* cmd)
|
||||
_ai_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* cmd_prefix)
|
||||
{
|
||||
char* result;
|
||||
|
||||
/* First, try provider name autocomplete */
|
||||
result = autocomplete_param_with_func(input, cmd, ai_providers_find, previous, NULL);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/* Provider was specified, try model name autocomplete */
|
||||
/* Build the full command prefix with space */
|
||||
auto_gchar gchar* cmd_prefix = g_strdup_printf("%s ", cmd);
|
||||
|
||||
if (!g_str_has_prefix(input, cmd_prefix)) {
|
||||
return NULL;
|
||||
}
|
||||
@@ -4642,6 +4646,6 @@ _ai_provider_and_model_autocomplete(ProfWin* window, const char* const input, gb
|
||||
|
||||
auto_gchar gchar* full_prefix = g_strdup_printf("%s%s", cmd_prefix, rest);
|
||||
|
||||
result = autocomplete_param_with_ac(input, full_prefix, ai_models_ac, TRUE, previous);
|
||||
char* result = autocomplete_param_with_ac(input, full_prefix, ai_models_ac, TRUE, previous);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2804,6 +2804,7 @@ static const struct cmd_t command_defs[] = {
|
||||
"/ai",
|
||||
"/ai set provider <name> <url>",
|
||||
"/ai set token <provider> <token>",
|
||||
"/ai set default-provider <provider>",
|
||||
"/ai set default-model <provider> <model>",
|
||||
"/ai set custom <provider> <setting> <value>",
|
||||
"/ai remove provider <name>",
|
||||
@@ -2822,12 +2823,14 @@ static const struct cmd_t command_defs[] = {
|
||||
{ "", "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 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 configured providers with full details" },
|
||||
{ "start <provider> [<model>]", "Start new AI chat (space-separated, uses defaults if omitted)" },
|
||||
{ "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(
|
||||
@@ -2835,6 +2838,7 @@ static const struct cmd_t command_defs[] = {
|
||||
"/ai set token openai sk-xxx",
|
||||
"/ai set token perplexity pplx-xxx",
|
||||
"/ai set provider custom https://my-api.com/v1/chat/completions",
|
||||
"/ai set default-provider perplexity",
|
||||
"/ai set default-model perplexity sonar",
|
||||
"/ai set custom perplexity tools enabled",
|
||||
"/ai remove provider custom",
|
||||
|
||||
@@ -8516,7 +8516,7 @@ _cmd_execute_default(ProfWin* window, const char* inp)
|
||||
case WIN_AI:
|
||||
{
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
cl_ev_send_ai_msg(aiwin, inp, connection_create_stanza_id());
|
||||
cl_ev_send_ai_msg(aiwin, inp, NULL);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -10839,7 +10839,7 @@ cmd_ai_set(ProfWin* window, const char* const command, gchar** args)
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
if (ai_add_provider(args[2], args[3])) {
|
||||
if (ai_add_provider(args[2], args[3], NULL)) {
|
||||
cons_show("Provider '%s' configured with URL: %s", args[2], args[3]);
|
||||
} else {
|
||||
cons_show_error("Failed to configure provider '%s'.", args[2]);
|
||||
@@ -10876,6 +10876,23 @@ cmd_ai_set(ProfWin* window, const char* const command, gchar** args)
|
||||
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;
|
||||
}
|
||||
|
||||
cons_bad_cmd_usage(command);
|
||||
@@ -10944,11 +10961,8 @@ cmd_ai_start(ProfWin* window, const char* const command, gchar** args)
|
||||
if (!model) {
|
||||
model = ai_get_provider_default_model(provider_name);
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
cons_show_error("No model specified and no default model set for provider '%s'.", provider_name);
|
||||
cons_show("Use '/ai set default-model %s <model>' or '/ai switch %s <model>'.", provider_name, provider_name);
|
||||
return TRUE;
|
||||
model = "gpt-4o"; // Fallback
|
||||
}
|
||||
|
||||
// Check for API key
|
||||
@@ -11010,12 +11024,12 @@ cmd_ai_model(ProfWin* window, const char* const command, gchar** args)
|
||||
|
||||
const gchar* model = args[1];
|
||||
|
||||
// Must be in an AI window to use this command
|
||||
if (!window || window->type != WIN_AI) {
|
||||
cons_show_error("Must be in an AI chat window to use this command.");
|
||||
// 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;
|
||||
}
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
@@ -11041,12 +11055,12 @@ cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Must be in an AI window to use this command
|
||||
if (!window || window->type != WIN_AI) {
|
||||
cons_show_error("Must be in an AI chat window to use this command.");
|
||||
// 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;
|
||||
}
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
@@ -11060,12 +11074,14 @@ cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
|
||||
|
||||
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) {
|
||||
@@ -11081,10 +11097,6 @@ cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
|
||||
} else {
|
||||
// arg1 is a model name, keep current provider
|
||||
provider_name = aiwin->session->provider_name;
|
||||
if (!provider_name) {
|
||||
cons_show_error("Session has no provider name.");
|
||||
return TRUE;
|
||||
}
|
||||
model = arg1;
|
||||
}
|
||||
|
||||
@@ -11096,8 +11108,19 @@ cmd_ai_switch(ProfWin* window, const char* const command, gchar** args)
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Atomically switch session provider, model, and API key (thread-safe)
|
||||
ai_session_switch(aiwin->session, provider_name, model, g_steal_pointer(&api_key));
|
||||
// Update 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);
|
||||
@@ -11144,7 +11167,8 @@ cmd_ai_models(ProfWin* window, const char* const command, gchar** args)
|
||||
cons_show("Use '/ai models %s' to fetch fresh models from the API.", provider_name);
|
||||
} else {
|
||||
// Default: fetch fresh models from API
|
||||
ai_fetch_models(provider_name, window);
|
||||
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
|
||||
ai_fetch_models(provider_name, aiwin);
|
||||
}
|
||||
|
||||
return TRUE;
|
||||
@@ -11154,20 +11178,70 @@ gboolean
|
||||
cmd_ai_clear(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai clear
|
||||
// Must be in an AI window to use this command
|
||||
if (!window || window->type != WIN_AI) {
|
||||
cons_show_error("Must be in an AI chat window to use this command.");
|
||||
ProfAiWin* aiwin = (window && window->type == WIN_AI) ? (ProfAiWin*)window : wins_get_ai();
|
||||
|
||||
if (aiwin) {
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
if (aiwin->session) {
|
||||
ai_session_clear_history(aiwin->session);
|
||||
}
|
||||
cons_show("Chat history cleared.");
|
||||
} else {
|
||||
cons_show("No active AI chat window to clear.");
|
||||
}
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
cmd_ai_correct(ProfWin* window, const char* const command, gchar** args)
|
||||
{
|
||||
// /ai correct <message>
|
||||
// Join all arguments from args[1] onwards to support multi-word prompts
|
||||
auto_gchar gchar* prompt = g_strjoinv(" ", &args[1]);
|
||||
if (prompt == NULL || strlen(prompt) == 0) {
|
||||
cons_bad_cmd_usage(command);
|
||||
return TRUE;
|
||||
}
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
if (aiwin->session) {
|
||||
ai_session_clear_history(aiwin->session);
|
||||
// Get current AI window
|
||||
ProfAiWin* aiwin = wins_get_ai();
|
||||
if (!aiwin) {
|
||||
cons_show("No active AI chat window. Use '/ai start <provider>/<model>' first.");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
win_clear(window);
|
||||
cons_show("AI Chat history cleared.");
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
|
||||
if (!aiwin->session) {
|
||||
cons_show("No active session in this chat window.");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Get the last user message and replace it
|
||||
GList* history = aiwin->session->history;
|
||||
GList* last_user_msg = NULL;
|
||||
for (GList* curr = history; curr; curr = g_list_next(curr)) {
|
||||
AIMessage* msg = curr->data;
|
||||
if (g_strcmp0(msg->role, "user") == 0) {
|
||||
last_user_msg = curr;
|
||||
}
|
||||
}
|
||||
|
||||
if (!last_user_msg) {
|
||||
cons_show("No user messages in this chat to correct.");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Replace the last user message
|
||||
AIMessage* msg = last_user_msg->data;
|
||||
g_free(msg->content);
|
||||
msg->content = g_strdup(prompt);
|
||||
|
||||
// Resend the prompt
|
||||
cons_show("Correcting message...");
|
||||
ai_send_prompt(aiwin->session, prompt, aiwin);
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
@@ -11196,6 +11270,9 @@ cmd_ai_providers(ProfWin* window, const char* const command, gchar** args)
|
||||
cons_show(" %s", provider->name);
|
||||
cons_show(" URL: %s", provider->api_url);
|
||||
cons_show(" Key: %s", key ? "configured" : "NOT configured");
|
||||
if (provider->org_id && strlen(provider->org_id) > 0) {
|
||||
cons_show(" Org: %s", provider->org_id);
|
||||
}
|
||||
cons_show("");
|
||||
g_free(key);
|
||||
}
|
||||
|
||||
@@ -1863,128 +1863,6 @@ prefs_free_ai_models(GList* models)
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* AI Provider Management
|
||||
* ======================================================================== */
|
||||
|
||||
gboolean
|
||||
prefs_ai_set_provider(const char* const provider, const char* const url)
|
||||
{
|
||||
if (!provider || !url)
|
||||
return FALSE;
|
||||
|
||||
if (!prefs)
|
||||
return FALSE;
|
||||
|
||||
/* Store URL with "_url" suffix to avoid collision with API key storage */
|
||||
g_key_file_set_string(prefs, PREF_GROUP_AI, g_strconcat(provider, "_url", NULL), url);
|
||||
_save_prefs();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gboolean
|
||||
prefs_ai_remove_provider(const char* const provider)
|
||||
{
|
||||
if (!provider)
|
||||
return FALSE;
|
||||
|
||||
if (!prefs)
|
||||
return FALSE;
|
||||
|
||||
auto_gchar gchar* url_key = g_strconcat(provider, "_url", NULL);
|
||||
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, url_key, NULL)) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
g_key_file_remove_key(prefs, PREF_GROUP_AI, url_key, NULL);
|
||||
_save_prefs();
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
char*
|
||||
prefs_ai_get_provider_url(const char* const provider)
|
||||
{
|
||||
if (!provider || !prefs)
|
||||
return NULL;
|
||||
|
||||
auto_gchar gchar* key = g_strconcat(provider, "_url", NULL);
|
||||
if (!g_key_file_has_key(prefs, PREF_GROUP_AI, key, NULL)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return g_key_file_get_string(prefs, PREF_GROUP_AI, key, NULL);
|
||||
}
|
||||
|
||||
GList*
|
||||
prefs_ai_list_providers(void)
|
||||
{
|
||||
if (!prefs || !g_key_file_has_group(prefs, PREF_GROUP_AI)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
GList* result = NULL;
|
||||
gsize len = 0;
|
||||
auto_gcharv gchar** keys = g_key_file_get_keys(prefs, PREF_GROUP_AI, &len, NULL);
|
||||
|
||||
for (gsize i = 0; i < len; i++) {
|
||||
char* key = keys[i];
|
||||
/* Match keys ending with "_url" that are not "_models" suffix */
|
||||
if (g_str_has_suffix(key, "_url") && !g_str_has_suffix(key, "_models_url")) {
|
||||
/* Extract provider name by removing "_url" suffix */
|
||||
gsize key_len = strlen(key);
|
||||
if (key_len > 4) {
|
||||
gchar* provider_name = g_strndup(key, key_len - 4);
|
||||
result = g_list_append(result, provider_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void
|
||||
prefs_free_ai_providers(GList* providers)
|
||||
{
|
||||
if (providers) {
|
||||
g_list_free_full(providers, g_free);
|
||||
}
|
||||
}
|
||||
|
||||
GList*
|
||||
prefs_ai_get_providers(void)
|
||||
{
|
||||
/* If user has configured any providers, return those */
|
||||
GList* configured = prefs_ai_list_providers();
|
||||
if (configured && g_list_length(configured) > 0) {
|
||||
/* Build result list with name, url pairs */
|
||||
GList* result = NULL;
|
||||
GList* curr = configured;
|
||||
while (curr) {
|
||||
gchar* name = (gchar*)curr->data;
|
||||
gchar* url = prefs_ai_get_provider_url(name);
|
||||
if (url && strlen(url) > 0) {
|
||||
result = g_list_append(result, g_strdup(name));
|
||||
result = g_list_append(result, g_strdup(url));
|
||||
}
|
||||
g_free(url);
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
prefs_free_ai_providers(configured);
|
||||
return result;
|
||||
}
|
||||
prefs_free_ai_providers(configured);
|
||||
|
||||
prefs_ai_set_provider("openai", "https://api.openai.com/");
|
||||
prefs_ai_set_provider("perplexity", "https://api.perplexity.ai/");
|
||||
|
||||
GList* result = NULL;
|
||||
result = g_list_append(result, g_strdup("openai"));
|
||||
result = g_list_append(result, g_strdup("https://api.openai.com/"));
|
||||
result = g_list_append(result, g_strdup("perplexity"));
|
||||
result = g_list_append(result, g_strdup("https://api.perplexity.ai/"));
|
||||
return result;
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -366,14 +366,6 @@ char* prefs_ai_get_token(const char* const provider);
|
||||
GList* prefs_ai_list_tokens(void);
|
||||
void prefs_free_ai_tokens(GList* tokens);
|
||||
|
||||
/* AI provider management */
|
||||
gboolean prefs_ai_set_provider(const char* const provider, const char* const url);
|
||||
gboolean prefs_ai_remove_provider(const char* const provider);
|
||||
char* prefs_ai_get_provider_url(const char* const provider);
|
||||
GList* prefs_ai_list_providers(void);
|
||||
void prefs_free_ai_providers(GList* providers);
|
||||
GList* prefs_ai_get_providers(void);
|
||||
|
||||
/* 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);
|
||||
|
||||
@@ -305,7 +305,7 @@ autocomplete_complete(Autocomplete ac, const gchar* search_str, gboolean quote,
|
||||
// autocomplete_func func is used -> autocomplete_param_with_func
|
||||
// Autocomplete ac, gboolean quote are used -> autocomplete_param_with_ac
|
||||
static char*
|
||||
_autocomplete_param_common(const char* const input, const char* command, autocomplete_func func, Autocomplete ac, gboolean quote, gboolean previous, void* context)
|
||||
_autocomplete_param_common(const char* const input, char* command, autocomplete_func func, Autocomplete ac, gboolean quote, gboolean previous, void* context)
|
||||
{
|
||||
int len;
|
||||
auto_gchar gchar* command_cpy = g_strdup_printf("%s ", command);
|
||||
@@ -344,7 +344,7 @@ _autocomplete_param_common(const char* const input, const char* command, autocom
|
||||
}
|
||||
|
||||
char*
|
||||
autocomplete_param_with_func(const char* const input, const char* command, autocomplete_func func, gboolean previous, void* context)
|
||||
autocomplete_param_with_func(const char* const input, char* command, autocomplete_func func, gboolean previous, void* context)
|
||||
{
|
||||
return _autocomplete_param_common(input, command, func, NULL, FALSE, previous, context);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ gchar* autocomplete_complete(Autocomplete ac, const gchar* search_str, gboolean
|
||||
GList* autocomplete_create_list(Autocomplete ac);
|
||||
gint autocomplete_length(Autocomplete ac);
|
||||
|
||||
char* autocomplete_param_with_func(const char* const input, const char* command,
|
||||
char* autocomplete_param_with_func(const char* const input, char* command,
|
||||
autocomplete_func func, gboolean previous, void* context);
|
||||
|
||||
char* autocomplete_param_with_ac(const char* const input, char* command,
|
||||
|
||||
@@ -867,6 +867,24 @@ wins_get_prune_wins(void)
|
||||
return result;
|
||||
}
|
||||
|
||||
ProfAiWin*
|
||||
wins_get_ai(void)
|
||||
{
|
||||
GList* curr = values;
|
||||
|
||||
while (curr) {
|
||||
ProfWin* window = curr->data;
|
||||
if (window->type == WIN_AI) {
|
||||
ProfAiWin* aiwin = (ProfAiWin*)window;
|
||||
assert(aiwin->memcheck == PROFAIWIN_MEMCHECK);
|
||||
return aiwin;
|
||||
}
|
||||
curr = g_list_next(curr);
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
gboolean
|
||||
wins_ai_exists(ProfAiWin* const aiwin)
|
||||
{
|
||||
|
||||
@@ -63,6 +63,7 @@ ProfPrivateWin* wins_get_private(const char* const fulljid);
|
||||
ProfPluginWin* wins_get_plugin(const char* const tag);
|
||||
ProfXMLWin* wins_get_xmlconsole(void);
|
||||
ProfVcardWin* wins_get_vcard(void);
|
||||
ProfAiWin* wins_get_ai(void);
|
||||
gboolean wins_ai_exists(ProfAiWin* const aiwin);
|
||||
|
||||
void wins_close_plugin(char* tag);
|
||||
|
||||
100
tests/functionaltests/ai_http_stub.c
Normal file
100
tests/functionaltests/ai_http_stub.c
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* ai_http_stub.c
|
||||
*
|
||||
* Spawns tests/functionaltests/ai_http_stub.py as a child process for
|
||||
* functional tests that exercise the AI client's HTTP code path. The
|
||||
* stub listens on 127.0.0.1 on an OS-chosen port and prints "PORT=<n>"
|
||||
* as its first line; we read it back and hand the port to the caller.
|
||||
*/
|
||||
|
||||
#include "ai_http_stub.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define STUB_SCRIPT "../tests/functionaltests/ai_http_stub.py"
|
||||
#define READLINE_MAX 64
|
||||
|
||||
static pid_t stub_pid = 0;
|
||||
|
||||
int
|
||||
ai_http_stub_start(const char* mode, const char* reply)
|
||||
{
|
||||
int pipefd[2];
|
||||
if (pipe(pipefd) != 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
close(pipefd[0]);
|
||||
close(pipefd[1]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (pid == 0) {
|
||||
/* Child: redirect stdout to the pipe write end, then exec the stub. */
|
||||
close(pipefd[0]);
|
||||
dup2(pipefd[1], STDOUT_FILENO);
|
||||
close(pipefd[1]);
|
||||
|
||||
if (mode) {
|
||||
setenv("AI_STUB_MODE", mode, 1);
|
||||
}
|
||||
if (reply) {
|
||||
setenv("AI_STUB_REPLY", reply, 1);
|
||||
}
|
||||
/* Argument "0" -> let the OS pick a free port. */
|
||||
execlp("python3", "python3", STUB_SCRIPT, "0", (char*)NULL);
|
||||
/* Falls through on exec failure. */
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
/* Parent: read "PORT=<n>\n" from the child's stdout. */
|
||||
close(pipefd[1]);
|
||||
|
||||
char buf[READLINE_MAX] = { 0 };
|
||||
ssize_t total = 0;
|
||||
while (total < (ssize_t)sizeof(buf) - 1) {
|
||||
ssize_t n = read(pipefd[0], buf + total, sizeof(buf) - 1 - total);
|
||||
if (n <= 0) {
|
||||
break;
|
||||
}
|
||||
total += n;
|
||||
if (memchr(buf, '\n', total)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
close(pipefd[0]);
|
||||
|
||||
int port = 0;
|
||||
if (sscanf(buf, "PORT=%d", &port) != 1 || port <= 0) {
|
||||
kill(pid, SIGTERM);
|
||||
waitpid(pid, NULL, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
stub_pid = pid;
|
||||
return port;
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_stub_stop(void)
|
||||
{
|
||||
if (stub_pid <= 0) {
|
||||
return;
|
||||
}
|
||||
kill(stub_pid, SIGTERM);
|
||||
/* Give the stub a moment to exit cleanly, then reap. */
|
||||
int status = 0;
|
||||
pid_t waited = waitpid(stub_pid, &status, 0);
|
||||
(void)waited;
|
||||
(void)status;
|
||||
stub_pid = 0;
|
||||
}
|
||||
25
tests/functionaltests/ai_http_stub.h
Normal file
25
tests/functionaltests/ai_http_stub.h
Normal file
@@ -0,0 +1,25 @@
|
||||
#ifndef __H_AI_HTTP_STUB
|
||||
#define __H_AI_HTTP_STUB
|
||||
|
||||
#include <sys/types.h>
|
||||
|
||||
/*
|
||||
* Spawn the Python AI stub script (tests/functionaltests/ai_http_stub.py).
|
||||
*
|
||||
* mode — one of "ok", "openai", "401", "500", "models" (see the stub
|
||||
* script). Selects the response body and HTTP status.
|
||||
* reply — body text used by "ok"/"openai"/"401" modes. Pass NULL for
|
||||
* the default ("MOCK-REPLY").
|
||||
*
|
||||
* Returns the listening port (>0) on success, 0 on failure. The child's
|
||||
* pid is recorded internally; call ai_http_stub_stop() to terminate it.
|
||||
*/
|
||||
int ai_http_stub_start(const char* mode, const char* reply);
|
||||
|
||||
/*
|
||||
* Send SIGTERM to the most recently spawned stub and reap it.
|
||||
* No-op if no stub is running.
|
||||
*/
|
||||
void ai_http_stub_stop(void);
|
||||
|
||||
#endif
|
||||
117
tests/functionaltests/ai_http_stub.py
Normal file
117
tests/functionaltests/ai_http_stub.py
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Minimal HTTP stub for AI functional tests.
|
||||
|
||||
Listens on 127.0.0.1:<port> and serves canned JSON responses for any POST.
|
||||
Behaviour is selected via the URL path and the AI_STUB_MODE environment
|
||||
variable, set by the test runner before exec().
|
||||
|
||||
Modes:
|
||||
- "ok" : 200 with a valid OpenAI Responses-API body
|
||||
({"output":[{"content":[{"text":"...","type":"output_text"}]}]})
|
||||
- "openai" : 200 with a legacy OpenAI Chat Completions body
|
||||
({"choices":[{"message":{"content":"..."}}]})
|
||||
- "401" : 401 with an OpenAI-style error envelope
|
||||
- "500" : 500 with a body that triggers the parse-error fallback
|
||||
- "models" : 200 with a /v1/models list body
|
||||
|
||||
The reply text is taken from AI_STUB_REPLY (default: "MOCK-REPLY").
|
||||
|
||||
The stub writes its actual listening port to stdout as a single line
|
||||
"PORT=<n>" so the parent can read it back, then runs until SIGTERM.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
|
||||
REPLY = os.environ.get("AI_STUB_REPLY", "MOCK-REPLY")
|
||||
MODE = os.environ.get("AI_STUB_MODE", "ok")
|
||||
|
||||
|
||||
def _dumps(body: object) -> bytes:
|
||||
"""Produce compact JSON without whitespace — profanity's hand-rolled
|
||||
parser does strstr for `"key":"`, which fails when json.dumps inserts
|
||||
a space after the colon by default."""
|
||||
return json.dumps(body, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def _body_for(mode: str) -> tuple[int, bytes]:
|
||||
if mode == "ok":
|
||||
body = {
|
||||
"output": [
|
||||
{
|
||||
"content": [
|
||||
{"text": REPLY, "type": "output_text"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
return 200, _dumps(body)
|
||||
if mode == "openai":
|
||||
body = {
|
||||
"choices": [
|
||||
{"message": {"role": "assistant", "content": REPLY}}
|
||||
]
|
||||
}
|
||||
return 200, _dumps(body)
|
||||
if mode == "401":
|
||||
body = {
|
||||
"error": {
|
||||
"message": REPLY,
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_api_key",
|
||||
}
|
||||
}
|
||||
return 401, _dumps(body)
|
||||
if mode == "500":
|
||||
return 500, b"<html><body>Internal Server Error</body></html>"
|
||||
if mode == "models":
|
||||
body = {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "model-a", "object": "model"},
|
||||
{"id": "model-b", "object": "model"},
|
||||
{"id": "model-c", "object": "model"},
|
||||
],
|
||||
}
|
||||
return 200, _dumps(body)
|
||||
return 200, b"{}"
|
||||
|
||||
|
||||
class StubHandler(BaseHTTPRequestHandler):
|
||||
def _send(self):
|
||||
status, body = _body_for(MODE)
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
if length:
|
||||
self.rfile.read(length)
|
||||
self._send()
|
||||
|
||||
def do_GET(self):
|
||||
self._send()
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
return
|
||||
|
||||
|
||||
def main() -> None:
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 0
|
||||
srv = HTTPServer(("127.0.0.1", port), StubHandler)
|
||||
actual = srv.server_address[1]
|
||||
print(f"PORT={actual}", flush=True)
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -59,6 +59,8 @@
|
||||
#ifdef HAVE_SQLITE
|
||||
#include "test_export_import.h"
|
||||
#endif
|
||||
#include "test_ai.h"
|
||||
#include "test_ai_http.h"
|
||||
|
||||
/* Macro to wrap each test with setup/teardown functions */
|
||||
#define PROF_FUNC_TEST(test) \
|
||||
@@ -66,6 +68,14 @@
|
||||
#test, test, init_prof_test, close_prof_test, #test \
|
||||
}
|
||||
|
||||
/* AI tests use a custom init that primes stabber via prof_connect so
|
||||
* stbbr_stop() in close_prof_test() doesn't hang on a never-connected
|
||||
* server thread. See ai_init_test() in test_ai.c. */
|
||||
#define PROF_FUNC_TEST_AI(test) \
|
||||
{ \
|
||||
#test, test, ai_init_test, close_prof_test, #test \
|
||||
}
|
||||
|
||||
#ifdef HAVE_SQLITE
|
||||
/* Custom init that sets a non-UTC timezone (POSIX TST-3 = UTC+3) */
|
||||
static int
|
||||
@@ -84,9 +94,9 @@ main(int argc, char* argv[])
|
||||
int group = 0; /* 0 = all groups */
|
||||
if (argc > 1) {
|
||||
group = atoi(argv[1]);
|
||||
if (group < 1 || group > 4) {
|
||||
if (group < 1 || group > 5) {
|
||||
fprintf(stderr, "Usage: %s [group]\n", argv[0]);
|
||||
fprintf(stderr, " group: 1-4 to run specific group, or omit for all\n");
|
||||
fprintf(stderr, " group: 1-5 to run specific group, or omit for all\n");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -307,6 +317,39 @@ main(int argc, char* argv[])
|
||||
#endif
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* GROUP 5: AI command surface (console + HTTP stub)
|
||||
* Standalone because AI tests don't use stabber's XMPP path; mixing
|
||||
* them with XMPP-bound tests in the same group poisons stabber state
|
||||
* between fixture init/teardown cycles and hangs subsequent
|
||||
* prof_connect() calls.
|
||||
* ============================================================ */
|
||||
const struct CMUnitTest group5_tests[] = {
|
||||
/* Tier A — no network calls */
|
||||
PROF_FUNC_TEST_AI(ai_no_args_shows_help),
|
||||
PROF_FUNC_TEST_AI(ai_providers_lists_defaults),
|
||||
PROF_FUNC_TEST_AI(ai_providers_list_shows_key_status),
|
||||
PROF_FUNC_TEST_AI(ai_set_provider_adds_custom),
|
||||
PROF_FUNC_TEST_AI(ai_set_token_marks_key_set),
|
||||
PROF_FUNC_TEST_AI(ai_start_unknown_provider_errors),
|
||||
PROF_FUNC_TEST_AI(ai_start_without_key_errors),
|
||||
PROF_FUNC_TEST_AI(ai_start_with_key_opens_window),
|
||||
PROF_FUNC_TEST_AI(ai_clear_without_window_errors),
|
||||
PROF_FUNC_TEST_AI(ai_remove_provider_works),
|
||||
PROF_FUNC_TEST_AI(ai_remove_provider_unknown_errors),
|
||||
PROF_FUNC_TEST_AI(ai_set_default_provider_unknown_errors),
|
||||
PROF_FUNC_TEST_AI(ai_set_default_model_updates_provider),
|
||||
PROF_FUNC_TEST_AI(ai_switch_without_window_errors),
|
||||
PROF_FUNC_TEST_AI(ai_bad_subcommand_shows_usage),
|
||||
|
||||
/* Tier B — full request/response through Python HTTP stub */
|
||||
PROF_FUNC_TEST_AI(ai_http_prompt_returns_ok),
|
||||
PROF_FUNC_TEST_AI(ai_http_prompt_openai_format),
|
||||
PROF_FUNC_TEST_AI(ai_http_prompt_401_shows_error),
|
||||
PROF_FUNC_TEST_AI(ai_http_prompt_500_surfaced),
|
||||
PROF_FUNC_TEST_AI(ai_http_models_fetch_lists_models),
|
||||
};
|
||||
|
||||
/* Test group registry for easy extension */
|
||||
struct
|
||||
{
|
||||
@@ -318,6 +361,7 @@ main(int argc, char* argv[])
|
||||
{ "Group 2: ExportImport/Receipts", group2_tests, ARRAY_SIZE(group2_tests) },
|
||||
{ "Group 3: Presence/Disconnect/DBHistory/Message/MUC-DB", group3_tests, ARRAY_SIZE(group3_tests) },
|
||||
{ "Group 4: Session/MUC/Carbons/MigrationStress", group4_tests, ARRAY_SIZE(group4_tests) },
|
||||
{ "Group 5: AI command surface (Tier A + Tier B)", group5_tests, ARRAY_SIZE(group5_tests) },
|
||||
};
|
||||
const int num_groups = ARRAY_SIZE(groups);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
#include "proftest.h"
|
||||
|
||||
/* Number of parallel test groups for CI builds */
|
||||
#define TEST_GROUPS 4
|
||||
#define TEST_GROUPS 5
|
||||
|
||||
/* Number of ports reserved per test group; allows skipping TIME_WAIT ports */
|
||||
#define PORTS_PER_GROUP 50
|
||||
|
||||
241
tests/functionaltests/test_ai.c
Normal file
241
tests/functionaltests/test_ai.c
Normal file
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* test_ai.c
|
||||
*
|
||||
* Functional tests for the /ai command surface (Tier A — no network).
|
||||
*
|
||||
* These tests interact with profanity through its PTY console and exercise
|
||||
* paths that do not reach libcurl: command parsing, provider registration,
|
||||
* key/setting/default storage, error messages, and AI window creation.
|
||||
*
|
||||
* Tests that require an actual provider HTTP exchange (prompt -> response,
|
||||
* /ai models <provider>, HTTP error envelopes) are out of scope here and
|
||||
* belong in a separate suite backed by a local HTTP stub.
|
||||
*/
|
||||
|
||||
#include <glib.h>
|
||||
#include "prof_cmocka.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "proftest.h"
|
||||
#include "test_ai.h"
|
||||
|
||||
int
|
||||
ai_init_test(void** state)
|
||||
{
|
||||
int ret = init_prof_test(state);
|
||||
if (ret != 0) {
|
||||
return ret;
|
||||
}
|
||||
/* Connect to stabber even though AI tests don't use XMPP. Without this
|
||||
* stabber's accept loop has no client to disconnect on /quit, and
|
||||
* stbbr_stop() in close_prof_test() hangs uninterruptibly when joining
|
||||
* the server thread. */
|
||||
prof_connect();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void
|
||||
ai_no_args_shows_help(void** state)
|
||||
{
|
||||
/* `/ai` with no arguments lists the built-in providers and a usage hint. */
|
||||
prof_input("/ai");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_exact("AI Chat - OpenAI-compatible API client"));
|
||||
assert_true(prof_output_exact("Configured providers:"));
|
||||
assert_true(prof_output_regex("openai"));
|
||||
assert_true(prof_output_regex("perplexity"));
|
||||
assert_true(prof_output_regex("Use '/ai start' to begin a chat"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_providers_lists_defaults(void** state)
|
||||
{
|
||||
/* `/ai providers` (no "list") shows the built-in list with URLs. */
|
||||
prof_input("/ai providers");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_exact("Available AI providers:"));
|
||||
assert_true(prof_output_regex("openai"));
|
||||
assert_true(prof_output_regex("perplexity"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_providers_list_shows_key_status(void** state)
|
||||
{
|
||||
/* `/ai providers list` lists each configured provider with key status. */
|
||||
prof_input("/ai providers list");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_exact("Configured providers:"));
|
||||
/* No tokens have been set yet in this fresh session. */
|
||||
assert_true(prof_output_regex("Key: NOT configured"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_set_provider_adds_custom(void** state)
|
||||
{
|
||||
/* Adding a custom provider should make it appear in /ai providers list. */
|
||||
prof_input("/ai set provider mock http://127.0.0.1:1/");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'mock' configured"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai providers list");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("mock"));
|
||||
assert_true(prof_output_regex("http://127.0.0.1:1/"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_set_token_marks_key_set(void** state)
|
||||
{
|
||||
/* Setting a token must flip the provider's key status to "configured". */
|
||||
prof_input("/ai set token openai sk-fake-test-key");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("API token set for provider: openai"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai providers list");
|
||||
|
||||
prof_timeout(5);
|
||||
/* openai now shows "Key: configured" while perplexity stays unconfigured. */
|
||||
assert_true(prof_output_regex("Key: configured"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_start_unknown_provider_errors(void** state)
|
||||
{
|
||||
/* Unknown provider name should error out without creating a window. */
|
||||
prof_input("/ai start nope_provider somemodel");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'nope_provider' not found"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_start_without_key_errors(void** state)
|
||||
{
|
||||
/* Known provider without an API key should refuse to start. */
|
||||
prof_input("/ai start openai gpt-4");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("No API key set for provider 'openai'"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_start_with_key_opens_window(void** state)
|
||||
{
|
||||
/* With a token set, /ai start should create a WIN_AI window. */
|
||||
prof_input("/ai set token openai sk-fake-test-key");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("API token set for provider: openai"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai start openai gpt-4");
|
||||
|
||||
prof_timeout(5);
|
||||
/* /ai start switches focus to the new WIN_AI window; cons_show output
|
||||
* to the console is therefore not the right place to look. The window
|
||||
* itself prints "AI Chat: <provider>/<model>" as its first line. */
|
||||
assert_true(prof_output_regex("AI Chat: openai/gpt-4"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_clear_without_window_errors(void** state)
|
||||
{
|
||||
/* /ai clear from the console (no active AI window) should report nicely. */
|
||||
prof_input("/ai clear");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("No active AI chat window to clear"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_remove_provider_works(void** state)
|
||||
{
|
||||
/* Round-trip: add -> verify present -> remove -> verify gone. */
|
||||
prof_input("/ai set provider tmpprov http://127.0.0.1:2/");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'tmpprov' configured"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai remove provider tmpprov");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'tmpprov' removed"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai remove provider tmpprov");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'tmpprov' not found"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_remove_provider_unknown_errors(void** state)
|
||||
{
|
||||
/* Removing a provider that was never added must report "not found". */
|
||||
prof_input("/ai remove provider nonexistent_xyz");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'nonexistent_xyz' not found"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_set_default_provider_unknown_errors(void** state)
|
||||
{
|
||||
/* Setting an unknown default provider should error, not silently accept. */
|
||||
prof_input("/ai set default-provider does_not_exist");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'does_not_exist' not found"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_set_default_model_updates_provider(void** state)
|
||||
{
|
||||
/* Setting a default model is acknowledged on the console. */
|
||||
prof_input("/ai set default-model openai gpt-5-preview");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Default model for provider 'openai' set to: gpt-5-preview"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_switch_without_window_errors(void** state)
|
||||
{
|
||||
/* /ai switch with no active AI window should produce an actionable error. */
|
||||
prof_input("/ai switch openai gpt-4");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("No active AI chat window"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
|
||||
void
|
||||
ai_bad_subcommand_shows_usage(void** state)
|
||||
{
|
||||
/* An unknown /ai subcommand should fall through to cons_bad_cmd_usage. */
|
||||
prof_input("/ai not_a_subcommand");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Invalid usage, see '/help ai'"));
|
||||
prof_timeout_reset();
|
||||
}
|
||||
28
tests/functionaltests/test_ai.h
Normal file
28
tests/functionaltests/test_ai.h
Normal file
@@ -0,0 +1,28 @@
|
||||
#ifndef __H_FUNC_TEST_AI
|
||||
#define __H_FUNC_TEST_AI
|
||||
|
||||
/*
|
||||
* AI tests don't exercise the XMPP path, but the test fixture's
|
||||
* stbbr_stop() hangs indefinitely when no client ever connected to
|
||||
* stabber. ai_init_test wraps init_prof_test with a prof_connect()
|
||||
* so stabber sees a graceful disconnect at teardown.
|
||||
*/
|
||||
int ai_init_test(void** state);
|
||||
|
||||
void ai_no_args_shows_help(void** state);
|
||||
void ai_providers_lists_defaults(void** state);
|
||||
void ai_providers_list_shows_key_status(void** state);
|
||||
void ai_set_provider_adds_custom(void** state);
|
||||
void ai_set_token_marks_key_set(void** state);
|
||||
void ai_start_unknown_provider_errors(void** state);
|
||||
void ai_start_without_key_errors(void** state);
|
||||
void ai_start_with_key_opens_window(void** state);
|
||||
void ai_clear_without_window_errors(void** state);
|
||||
void ai_remove_provider_works(void** state);
|
||||
void ai_remove_provider_unknown_errors(void** state);
|
||||
void ai_set_default_provider_unknown_errors(void** state);
|
||||
void ai_set_default_model_updates_provider(void** state);
|
||||
void ai_switch_without_window_errors(void** state);
|
||||
void ai_bad_subcommand_shows_usage(void** state);
|
||||
|
||||
#endif
|
||||
157
tests/functionaltests/test_ai_http.c
Normal file
157
tests/functionaltests/test_ai_http.c
Normal file
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* test_ai_http.c
|
||||
*
|
||||
* Functional tests for the /ai command surface that exercise the libcurl
|
||||
* HTTP code path. A small Python HTTP stub (ai_http_stub.py) is spawned
|
||||
* per-test on a free local port; profanity is configured to point at it
|
||||
* via `/ai set provider`. The stub serves canned JSON whose shape is
|
||||
* controlled via AI_STUB_MODE.
|
||||
*
|
||||
* These tests intentionally do not race the worker thread directly —
|
||||
* they rely on prof_timeout() retries to catch the response once it
|
||||
* propagates back through the AI window.
|
||||
*/
|
||||
|
||||
#include <glib.h>
|
||||
#include "prof_cmocka.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "proftest.h"
|
||||
#include "ai_http_stub.h"
|
||||
|
||||
#define WAIT_AI_RESPONSE_SEC 10
|
||||
|
||||
/*
|
||||
* Configure profanity to use the spawned stub as a provider named "mock"
|
||||
* with a dummy bearer token, then issue /ai start to open the AI window.
|
||||
* Returns the stub port on success, fails the test on stub-spawn failure.
|
||||
*/
|
||||
static int
|
||||
_setup_mock_provider(const char* mode, const char* reply)
|
||||
{
|
||||
int port = ai_http_stub_start(mode, reply);
|
||||
assert_int_not_equal(0, port);
|
||||
|
||||
char cmd[256];
|
||||
g_snprintf(cmd, sizeof(cmd), "/ai set provider mock http://127.0.0.1:%d/", port);
|
||||
prof_input(cmd);
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("Provider 'mock' configured"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai set token mock fake-test-token");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("API token set for provider: mock"));
|
||||
prof_timeout_reset();
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_prompt_returns_ok(void** state)
|
||||
{
|
||||
/* Happy path: stub returns Responses API "text" body; reply appears in window. */
|
||||
_setup_mock_provider("ok", "STUB-HELLO");
|
||||
|
||||
prof_input("/ai start mock testmodel");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("AI Chat: mock/testmodel"));
|
||||
prof_timeout_reset();
|
||||
|
||||
/* Plain text in AI window goes through cl_ev_send_ai_msg -> ai_send_prompt. */
|
||||
prof_input("hello stub");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
assert_true(prof_output_regex("STUB-HELLO"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_prompt_openai_format(void** state)
|
||||
{
|
||||
/* Legacy OpenAI chat completions shape: "choices[].message.content". */
|
||||
_setup_mock_provider("openai", "FROM-CHOICES");
|
||||
|
||||
prof_input("/ai start mock m");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("AI Chat: mock/m"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("anything");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
assert_true(prof_output_regex("FROM-CHOICES"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_prompt_401_shows_error(void** state)
|
||||
{
|
||||
/* 401 with an OpenAI-style error envelope.
|
||||
* ai_parse_error_message() should extract the inner message; the AI
|
||||
* window then displays "HTTP 401: <message>". */
|
||||
_setup_mock_provider("401", "Bad token, try another");
|
||||
|
||||
prof_input("/ai start mock m");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("AI Chat: mock/m"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("prompt");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
/* Either the parsed message surfaces, or the raw HTTP 401 envelope. */
|
||||
assert_true(prof_output_regex("HTTP 401") && prof_output_regex("Bad token, try another"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_prompt_500_surfaced(void** state)
|
||||
{
|
||||
/* 500 with a non-JSON HTML body. Parser falls back to raw body in HTTP error. */
|
||||
_setup_mock_provider("500", NULL);
|
||||
|
||||
prof_input("/ai start mock m");
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("AI Chat: mock/m"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("trigger");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
assert_true(prof_output_regex("HTTP 500"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
|
||||
void
|
||||
ai_http_models_fetch_lists_models(void** state)
|
||||
{
|
||||
/* /v1/models endpoint returns a list of three models. /ai models <p>
|
||||
* caches them silently; /ai models <p> --cached then displays the names. */
|
||||
_setup_mock_provider("models", NULL);
|
||||
|
||||
prof_input("/ai models mock");
|
||||
|
||||
prof_timeout(WAIT_AI_RESPONSE_SEC);
|
||||
assert_true(prof_output_regex("Cached 3 models for provider 'mock'"));
|
||||
prof_timeout_reset();
|
||||
|
||||
prof_input("/ai models mock --cached");
|
||||
|
||||
prof_timeout(5);
|
||||
assert_true(prof_output_regex("model-a"));
|
||||
assert_true(prof_output_regex("model-b"));
|
||||
assert_true(prof_output_regex("model-c"));
|
||||
prof_timeout_reset();
|
||||
|
||||
ai_http_stub_stop();
|
||||
}
|
||||
10
tests/functionaltests/test_ai_http.h
Normal file
10
tests/functionaltests/test_ai_http.h
Normal file
@@ -0,0 +1,10 @@
|
||||
#ifndef __H_FUNC_TEST_AI_HTTP
|
||||
#define __H_FUNC_TEST_AI_HTTP
|
||||
|
||||
void ai_http_prompt_returns_ok(void** state);
|
||||
void ai_http_prompt_openai_format(void** state);
|
||||
void ai_http_prompt_401_shows_error(void** state);
|
||||
void ai_http_prompt_500_surfaced(void** state);
|
||||
void ai_http_models_fetch_lists_models(void** state);
|
||||
|
||||
#endif
|
||||
@@ -1,7 +1,10 @@
|
||||
#include "prof_cmocka.h"
|
||||
#include "common.h"
|
||||
#include "ai/ai_client.h"
|
||||
#include "config/preferences.h"
|
||||
#include "helpers.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ========================================================================
|
||||
* Setup/Teardown
|
||||
@@ -44,15 +47,17 @@ void
|
||||
test_ai_add_provider(void** state)
|
||||
{
|
||||
/* Add a custom provider (hash table owns ref; caller gets non-owning pointer) */
|
||||
AIProvider* provider = ai_add_provider("custom", "https://custom.api.com/v1");
|
||||
AIProvider* provider = ai_add_provider("custom", "https://custom.api.com/v1", "my-org");
|
||||
assert_non_null(provider);
|
||||
assert_string_equal("custom", provider->name);
|
||||
assert_string_equal("https://custom.api.com/v1", provider->api_url);
|
||||
assert_string_equal("my-org", provider->org_id);
|
||||
|
||||
/* Update existing provider (returns ref; caller owns it) */
|
||||
AIProvider* updated = ai_add_provider("custom", "https://new.api.com/v1");
|
||||
AIProvider* updated = ai_add_provider("custom", "https://new.api.com/v1", NULL);
|
||||
assert_non_null(updated);
|
||||
assert_string_equal("https://new.api.com/v1", updated->api_url);
|
||||
assert_null(updated->org_id);
|
||||
|
||||
ai_provider_unref(updated);
|
||||
}
|
||||
@@ -64,7 +69,7 @@ test_ai_remove_provider(void** state)
|
||||
assert_false(ai_remove_provider("nonexistent"));
|
||||
|
||||
/* Add and remove custom provider */
|
||||
ai_add_provider("temp", "https://temp.api.com/v1");
|
||||
ai_add_provider("temp", "https://temp.api.com/v1", NULL);
|
||||
assert_true(ai_remove_provider("temp"));
|
||||
assert_null(ai_get_provider("temp"));
|
||||
}
|
||||
@@ -80,7 +85,7 @@ test_ai_list_providers(void** state)
|
||||
g_list_free(providers);
|
||||
|
||||
/* Add another provider */
|
||||
ai_add_provider("test", "https://test.api.com/v1");
|
||||
ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
providers = ai_list_providers();
|
||||
assert_int_equal(3, g_list_length(providers));
|
||||
|
||||
@@ -299,27 +304,29 @@ void
|
||||
test_ai_add_provider_update_existing(void** state)
|
||||
{
|
||||
/* Add a provider (hash table owns ref) */
|
||||
AIProvider* provider = ai_add_provider("custom", "https://first.api.com/v1");
|
||||
AIProvider* provider = ai_add_provider("custom", "https://first.api.com/v1", "org1");
|
||||
assert_non_null(provider);
|
||||
assert_string_equal("https://first.api.com/v1", provider->api_url);
|
||||
assert_string_equal("org1", provider->org_id);
|
||||
|
||||
/* Update the same provider (returns ref) */
|
||||
provider = ai_add_provider("custom", "https://second.api.com/v1");
|
||||
provider = ai_add_provider("custom", "https://second.api.com/v1", "org2");
|
||||
assert_non_null(provider);
|
||||
assert_string_equal("https://second.api.com/v1", provider->api_url);
|
||||
assert_string_equal("org2", provider->org_id);
|
||||
ai_provider_unref(provider);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_add_provider_null_name_returns_null(void** state)
|
||||
{
|
||||
assert_null(ai_add_provider(NULL, "https://api.com/v1"));
|
||||
assert_null(ai_add_provider(NULL, "https://api.com/v1", NULL));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_add_provider_null_url_returns_null(void** state)
|
||||
{
|
||||
assert_null(ai_add_provider("test", NULL));
|
||||
assert_null(ai_add_provider("test", NULL, NULL));
|
||||
}
|
||||
|
||||
void
|
||||
@@ -370,7 +377,7 @@ void
|
||||
test_ai_providers_find_forward_custom(void** state)
|
||||
{
|
||||
/* Add a custom provider and test */
|
||||
ai_add_provider("custom", "https://custom.api.com/v1");
|
||||
ai_add_provider("custom", "https://custom.api.com/v1", NULL);
|
||||
|
||||
auto_gchar gchar* result = ai_providers_find("c", FALSE, NULL);
|
||||
assert_non_null(result);
|
||||
@@ -480,7 +487,7 @@ test_ai_models_find_null_session(void** state)
|
||||
void
|
||||
test_ai_models_find_null_provider(void** state)
|
||||
{
|
||||
ai_add_provider("temp", "https://temp.api.com/v1");
|
||||
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);
|
||||
@@ -652,7 +659,7 @@ test_ai_parse_models_openai_format(void** state)
|
||||
/* Format: {"object":"list","data":[{"id":"model1",...},...]} */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
|
||||
/* JSON response from Perplexity/OpenAI API */
|
||||
const gchar* json = "{\"object\":\"list\","
|
||||
@@ -688,7 +695,7 @@ test_ai_parse_models_perplexity_format(void** state)
|
||||
/* Test parsing actual Perplexity API response with all models from curl test */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("perplexity", "https://api.perplexity.ai/");
|
||||
AIProvider* provider = ai_add_provider("perplexity", "https://api.perplexity.ai/", NULL);
|
||||
|
||||
const gchar* json = "{\"object\":\"list\","
|
||||
"\"data\":["
|
||||
@@ -761,7 +768,7 @@ test_ai_parse_models_array_format(void** state)
|
||||
/* Test parsing simple array format: ["model1", "model2", ...] */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
|
||||
const gchar* json = "[\"model1\", \"model2\", \"model3\"]";
|
||||
|
||||
@@ -786,7 +793,7 @@ test_ai_parse_models_empty_json(void** state)
|
||||
/* Test parsing empty/invalid JSON */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
|
||||
ai_parse_models_from_json(provider, "");
|
||||
assert_null(provider->models);
|
||||
@@ -806,7 +813,7 @@ test_ai_parse_models_null_handling(void** state)
|
||||
/* Test NULL handling */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
|
||||
/* NULL json should not crash */
|
||||
ai_parse_models_from_json(provider, NULL);
|
||||
@@ -822,7 +829,7 @@ test_ai_parse_models_escaped_quotes(void** state)
|
||||
/* Test parsing model IDs with escaped quotes */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
|
||||
const gchar* json = "{\"object\":\"list\","
|
||||
"\"data\":["
|
||||
@@ -846,7 +853,7 @@ test_ai_parse_models_with_whitespace(void** state)
|
||||
/* Test parsing JSON with whitespace after colons */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1");
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
|
||||
/* JSON with spaces after colons (common in formatted JSON) */
|
||||
const gchar* json = "{ \"object\": \"list\", "
|
||||
@@ -863,3 +870,598 @@ test_ai_parse_models_with_whitespace(void** state)
|
||||
ai_provider_unref(provider);
|
||||
ai_client_shutdown();
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Setup combining prefs + ai_client for round-trip persistence tests
|
||||
* ======================================================================== */
|
||||
|
||||
int
|
||||
ai_client_setup_with_prefs(void** state)
|
||||
{
|
||||
if (load_preferences(state) != 0) {
|
||||
return 1;
|
||||
}
|
||||
ai_client_init();
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
ai_client_teardown_with_prefs(void** state)
|
||||
{
|
||||
ai_client_shutdown();
|
||||
close_preferences(state);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Chat response parser tests (ai_parse_response)
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_parse_response_openai_content(void** state)
|
||||
{
|
||||
const gchar* json = "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Hello, world!\"}}]}";
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("Hello, world!", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_perplexity_text(void** state)
|
||||
{
|
||||
const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"Search result\",\"type\":\"output_text\"}]}]}";
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("Search result", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_text_preferred_over_content(void** state)
|
||||
{
|
||||
/* When both formats are present, "text" wins (Perplexity path tried first). */
|
||||
const gchar* json = "{\"text\":\"from text field\",\"content\":\"from content field\"}";
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("from text field", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_escaped_quote(void** state)
|
||||
{
|
||||
const gchar* json = "{\"content\":\"He said \\\"hello\\\" today\"}";
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("He said \"hello\" today", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_newline_escape(void** state)
|
||||
{
|
||||
const gchar* json = "{\"content\":\"line one\\nline two\"}";
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("line one\nline two", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_empty_content(void** state)
|
||||
{
|
||||
const gchar* json = "{\"content\":\"\"}";
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_missing_field(void** state)
|
||||
{
|
||||
const gchar* json = "{\"foo\":\"bar\"}";
|
||||
gchar* out = ai_parse_response(json);
|
||||
assert_null(out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_null_input(void** state)
|
||||
{
|
||||
assert_null(ai_parse_response(NULL));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_empty_input(void** state)
|
||||
{
|
||||
assert_null(ai_parse_response(""));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_percent_signs_safe(void** state)
|
||||
{
|
||||
/* Format-string safety: %s, %d in content must not be interpreted later. */
|
||||
const gchar* json = "{\"content\":\"100% done with %s and %d markers\"}";
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("100% done with %s and %d markers", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_braces_in_content(void** state)
|
||||
{
|
||||
/* Content containing JSON-looking braces should not confuse the parser. */
|
||||
const gchar* json = "{\"content\":\"use {key: value} syntax\"}";
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("use {key: value} syntax", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_multiline_content(void** state)
|
||||
{
|
||||
const gchar* json = "{\"content\":\"para 1\\n\\npara 2\\nlast\"}";
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("para 1\n\npara 2\nlast", out);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Error response parser tests (ai_parse_error_message)
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_parse_error_standard_envelope(void** state)
|
||||
{
|
||||
const gchar* json = "{\"error\":{\"message\":\"Invalid API key provided\",\"type\":\"invalid_request_error\",\"code\":\"invalid_api_key\"}}";
|
||||
auto_gchar gchar* out = ai_parse_error_message(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("Invalid API key provided", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_error_with_escapes(void** state)
|
||||
{
|
||||
const gchar* json = "{\"error\":{\"message\":\"Field \\\"model\\\" is required\"}}";
|
||||
auto_gchar gchar* out = ai_parse_error_message(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("Field \"model\" is required", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_error_no_error_field(void** state)
|
||||
{
|
||||
const gchar* json = "{\"message\":\"this is not in an error envelope\"}";
|
||||
gchar* out = ai_parse_error_message(json);
|
||||
assert_null(out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_error_no_message_field(void** state)
|
||||
{
|
||||
const gchar* json = "{\"error\":{\"type\":\"some_error\",\"code\":42}}";
|
||||
gchar* out = ai_parse_error_message(json);
|
||||
assert_null(out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_error_null_input(void** state)
|
||||
{
|
||||
assert_null(ai_parse_error_message(NULL));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_error_empty_input(void** state)
|
||||
{
|
||||
assert_null(ai_parse_error_message(""));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_error_tab_escape(void** state)
|
||||
{
|
||||
const gchar* json = "{\"error\":{\"message\":\"col1\\tcol2\"}}";
|
||||
auto_gchar gchar* out = ai_parse_error_message(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("col1\tcol2", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_error_backslash_escape(void** state)
|
||||
{
|
||||
const gchar* json = "{\"error\":{\"message\":\"path C:\\\\Users\\\\x\"}}";
|
||||
auto_gchar gchar* out = ai_parse_error_message(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("path C:\\Users\\x", out);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Extended JSON escape tests
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_json_escape_backspace(void** state)
|
||||
{
|
||||
auto_gchar gchar* out = ai_json_escape("a\bb");
|
||||
assert_string_equal("a\\bb", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_json_escape_formfeed(void** state)
|
||||
{
|
||||
auto_gchar gchar* out = ai_json_escape("a\fb");
|
||||
assert_string_equal("a\\fb", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_json_escape_carriage_return(void** state)
|
||||
{
|
||||
auto_gchar gchar* out = ai_json_escape("a\rb");
|
||||
assert_string_equal("a\\rb", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_json_escape_all_specials_combined(void** state)
|
||||
{
|
||||
auto_gchar gchar* out = ai_json_escape("\"\\/\b\f\n\r\t");
|
||||
/* `/` is not escaped by the encoder (valid JSON either way). */
|
||||
assert_string_equal("\\\"\\\\/\\b\\f\\n\\r\\t", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_json_escape_passes_utf8_through(void** state)
|
||||
{
|
||||
/* UTF-8 bytes are valid in JSON strings and should pass through unchanged. */
|
||||
auto_gchar gchar* out = ai_json_escape("Привет 🦀");
|
||||
assert_string_equal("Привет 🦀", out);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Autocomplete cycling with many providers
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_providers_find_cycles_through_many(void** state)
|
||||
{
|
||||
/* Add several providers sharing a prefix; cycling should visit each. */
|
||||
ai_add_provider("alpha", "https://a.example/", NULL);
|
||||
ai_add_provider("beta", "https://b.example/", NULL);
|
||||
ai_add_provider("alphabet", "https://ab.example/", NULL);
|
||||
ai_add_provider("alpine", "https://al.example/", NULL);
|
||||
|
||||
/* Three providers share prefix "alp": alpha, alphabet, alpine. */
|
||||
GHashTable* seen = g_hash_table_new(g_str_hash, g_str_equal);
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
auto_gchar gchar* match = ai_providers_find("alp", FALSE, NULL);
|
||||
assert_non_null(match);
|
||||
/* Each call should return a result that starts with "alp". */
|
||||
assert_true(g_str_has_prefix(match, "alp"));
|
||||
g_hash_table_add(seen, g_strdup(match));
|
||||
}
|
||||
|
||||
/* All three should have been seen (deterministic cycling). */
|
||||
assert_int_equal(3, g_hash_table_size(seen));
|
||||
|
||||
g_hash_table_destroy(seen);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_previous_walks_backwards(void** state)
|
||||
{
|
||||
ai_add_provider("alpha", "https://a/", NULL);
|
||||
ai_add_provider("alphabet", "https://ab/", NULL);
|
||||
ai_add_provider("alpine", "https://al/", NULL);
|
||||
|
||||
/* Forward then backward should yield matches with the shared prefix. */
|
||||
auto_gchar gchar* forward = ai_providers_find("alp", FALSE, NULL);
|
||||
auto_gchar gchar* back = ai_providers_find("alp", TRUE, NULL);
|
||||
|
||||
assert_non_null(forward);
|
||||
assert_non_null(back);
|
||||
assert_true(g_str_has_prefix(forward, "alp"));
|
||||
assert_true(g_str_has_prefix(back, "alp"));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_providers_find_wraps_around(void** state)
|
||||
{
|
||||
ai_add_provider("alpha", "https://a/", NULL);
|
||||
ai_add_provider("alphabet", "https://ab/", NULL);
|
||||
|
||||
/* Two matches; four forward calls should produce a repeat. */
|
||||
auto_gchar gchar* r1 = ai_providers_find("alp", FALSE, NULL);
|
||||
auto_gchar gchar* r2 = ai_providers_find("alp", FALSE, NULL);
|
||||
auto_gchar gchar* r3 = ai_providers_find("alp", FALSE, NULL);
|
||||
auto_gchar gchar* r4 = ai_providers_find("alp", FALSE, NULL);
|
||||
|
||||
assert_non_null(r1);
|
||||
assert_non_null(r2);
|
||||
assert_non_null(r3);
|
||||
assert_non_null(r4);
|
||||
|
||||
/* The cycle of 2 must repeat: r1 == r3 and r2 == r4 (in some order). */
|
||||
assert_int_equal(0, g_strcmp0(r1, r3));
|
||||
assert_int_equal(0, g_strcmp0(r2, r4));
|
||||
assert_int_not_equal(0, g_strcmp0(r1, r2));
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Session edge cases
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_session_add_message_null_session_no_crash(void** state)
|
||||
{
|
||||
/* Should silently no-op rather than dereference NULL. */
|
||||
ai_session_add_message(NULL, "user", "hello");
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_add_message_null_role_no_crash(void** state)
|
||||
{
|
||||
AISession* s = ai_session_create("openai", "gpt-4");
|
||||
assert_non_null(s);
|
||||
ai_session_add_message(s, NULL, "content");
|
||||
assert_int_equal(0, g_list_length(s->history));
|
||||
ai_session_unref(s);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_add_message_null_content_no_crash(void** state)
|
||||
{
|
||||
AISession* s = ai_session_create("openai", "gpt-4");
|
||||
assert_non_null(s);
|
||||
ai_session_add_message(s, "user", NULL);
|
||||
assert_int_equal(0, g_list_length(s->history));
|
||||
ai_session_unref(s);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_history_preserves_large_order(void** state)
|
||||
{
|
||||
AISession* s = ai_session_create("openai", "gpt-4");
|
||||
assert_non_null(s);
|
||||
|
||||
for (int i = 0; i < 100; i++) {
|
||||
auto_gchar gchar* content = g_strdup_printf("msg-%d", i);
|
||||
ai_session_add_message(s, (i % 2 == 0) ? "user" : "assistant", content);
|
||||
}
|
||||
|
||||
assert_int_equal(100, g_list_length(s->history));
|
||||
|
||||
GList* curr = s->history;
|
||||
for (int i = 0; i < 100 && curr; i++, curr = g_list_next(curr)) {
|
||||
AIMessage* msg = curr->data;
|
||||
auto_gchar gchar* expected = g_strdup_printf("msg-%d", i);
|
||||
assert_string_equal(expected, msg->content);
|
||||
assert_string_equal((i % 2 == 0) ? "user" : "assistant", msg->role);
|
||||
}
|
||||
|
||||
ai_session_unref(s);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_clear_empty_history(void** state)
|
||||
{
|
||||
AISession* s = ai_session_create("openai", "gpt-4");
|
||||
assert_non_null(s);
|
||||
ai_session_clear_history(s);
|
||||
assert_int_equal(0, g_list_length(s->history));
|
||||
ai_session_unref(s);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_set_model_null_keeps_old(void** state)
|
||||
{
|
||||
AISession* s = ai_session_create("openai", "gpt-4");
|
||||
assert_non_null(s);
|
||||
ai_session_set_model(s, NULL);
|
||||
assert_string_equal("gpt-4", s->model);
|
||||
ai_session_unref(s);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_unref_null_no_crash(void** state)
|
||||
{
|
||||
ai_session_unref(NULL);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_session_ref_null_returns_null(void** state)
|
||||
{
|
||||
assert_null(ai_session_ref(NULL));
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Provider edge cases
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_get_provider_null_returns_null(void** state)
|
||||
{
|
||||
assert_null(ai_get_provider(NULL));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_remove_provider_null_returns_false(void** state)
|
||||
{
|
||||
assert_false(ai_remove_provider(NULL));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_remove_provider_twice_second_fails(void** state)
|
||||
{
|
||||
ai_add_provider("once", "https://once/", NULL);
|
||||
assert_true(ai_remove_provider("once"));
|
||||
assert_false(ai_remove_provider("once"));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_provider_survives_via_session_after_removal(void** state)
|
||||
{
|
||||
ai_add_provider("temp_prov", "https://temp/", NULL);
|
||||
ai_set_provider_key("temp_prov", "test-key");
|
||||
|
||||
AISession* s = ai_session_create("temp_prov", "model-x");
|
||||
assert_non_null(s);
|
||||
|
||||
/* Remove from registry while session holds a ref. */
|
||||
assert_true(ai_remove_provider("temp_prov"));
|
||||
assert_null(ai_get_provider("temp_prov"));
|
||||
|
||||
/* Session's provider pointer must still be valid. */
|
||||
assert_non_null(s->provider);
|
||||
assert_string_equal("temp_prov", s->provider->name);
|
||||
assert_string_equal("https://temp/", s->provider->api_url);
|
||||
|
||||
ai_session_unref(s);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Settings edge cases
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_settings_multiple_keys_independent(void** state)
|
||||
{
|
||||
ai_set_provider_setting("openai", "tools", "enabled");
|
||||
ai_set_provider_setting("openai", "search", "disabled");
|
||||
|
||||
auto_gchar gchar* tools = ai_get_provider_setting("openai", "tools");
|
||||
auto_gchar gchar* search = ai_get_provider_setting("openai", "search");
|
||||
|
||||
assert_string_equal("enabled", tools);
|
||||
assert_string_equal("disabled", search);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_settings_get_missing_returns_null(void** state)
|
||||
{
|
||||
gchar* nope = ai_get_provider_setting("openai", "not_set");
|
||||
assert_null(nope);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_settings_isolated_between_providers(void** state)
|
||||
{
|
||||
ai_set_provider_setting("openai", "tools", "yes");
|
||||
ai_set_provider_setting("perplexity", "tools", "no");
|
||||
|
||||
auto_gchar gchar* a = ai_get_provider_setting("openai", "tools");
|
||||
auto_gchar gchar* b = ai_get_provider_setting("perplexity", "tools");
|
||||
|
||||
assert_string_equal("yes", a);
|
||||
assert_string_equal("no", b);
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Model parsing edge cases
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_parse_models_data_not_array(void** state)
|
||||
{
|
||||
AIProvider* p = ai_add_provider("p1", "https://p/", NULL);
|
||||
assert_non_null(p);
|
||||
|
||||
/* "data" present but not an array — parser should bail without crashing. */
|
||||
const gchar* json = "{\"data\":\"not-an-array\"}";
|
||||
ai_parse_models_from_json(p, json);
|
||||
|
||||
assert_int_equal(0, g_list_length(p->models));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_models_empty_data_array(void** state)
|
||||
{
|
||||
AIProvider* p = ai_add_provider("p1", "https://p/", NULL);
|
||||
assert_non_null(p);
|
||||
|
||||
const gchar* json = "{\"object\":\"list\",\"data\":[]}";
|
||||
ai_parse_models_from_json(p, json);
|
||||
|
||||
assert_int_equal(0, g_list_length(p->models));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_models_id_outside_data_ignored(void** state)
|
||||
{
|
||||
AIProvider* p = ai_add_provider("p1", "https://p/", NULL);
|
||||
assert_non_null(p);
|
||||
|
||||
/* "id" outside a data-array object must not be picked up. */
|
||||
const gchar* json = "{\"request_id\":\"req-123\",\"data\":[]}";
|
||||
ai_parse_models_from_json(p, json);
|
||||
|
||||
assert_int_equal(0, g_list_length(p->models));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_models_multiple_models(void** state)
|
||||
{
|
||||
AIProvider* p = ai_add_provider("p1", "https://p/", NULL);
|
||||
assert_non_null(p);
|
||||
|
||||
const gchar* json = "{\"object\":\"list\",\"data\":["
|
||||
"{\"id\":\"m-1\",\"object\":\"model\"},"
|
||||
"{\"id\":\"m-2\",\"object\":\"model\"},"
|
||||
"{\"id\":\"m-3\",\"object\":\"model\"}"
|
||||
"]}";
|
||||
ai_parse_models_from_json(p, json);
|
||||
|
||||
assert_int_equal(3, g_list_length(p->models));
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Prefs round-trip tests
|
||||
*
|
||||
* These use ai_client_setup_with_prefs which initializes prefs first.
|
||||
* ai_set_provider_key writes through to prefs_ai_set_token which then
|
||||
* calls _save_prefs(); a subsequent shutdown+init should reload the key.
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_prefs_round_trip_api_key(void** state)
|
||||
{
|
||||
ai_set_provider_key("openai", "sk-persisted-123");
|
||||
|
||||
{
|
||||
auto_gchar gchar* before = ai_get_provider_key("openai");
|
||||
assert_non_null(before);
|
||||
assert_string_equal("sk-persisted-123", before);
|
||||
}
|
||||
|
||||
/* Cycle the AI client; prefs stay loaded across this. */
|
||||
ai_client_shutdown();
|
||||
ai_client_init();
|
||||
|
||||
auto_gchar gchar* after = ai_get_provider_key("openai");
|
||||
assert_non_null(after);
|
||||
assert_string_equal("sk-persisted-123", after);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_prefs_round_trip_remove_key(void** state)
|
||||
{
|
||||
ai_set_provider_key("openai", "sk-to-be-removed");
|
||||
ai_set_provider_key("openai", NULL);
|
||||
|
||||
ai_client_shutdown();
|
||||
ai_client_init();
|
||||
|
||||
auto_gchar gchar* after = ai_get_provider_key("openai");
|
||||
assert_null(after);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_prefs_multiple_providers_persist(void** state)
|
||||
{
|
||||
ai_set_provider_key("openai", "sk-openai-key");
|
||||
ai_set_provider_key("perplexity", "pplx-key");
|
||||
|
||||
ai_client_shutdown();
|
||||
ai_client_init();
|
||||
|
||||
auto_gchar gchar* k1 = ai_get_provider_key("openai");
|
||||
auto_gchar gchar* k2 = ai_get_provider_key("perplexity");
|
||||
|
||||
assert_non_null(k1);
|
||||
assert_non_null(k2);
|
||||
assert_string_equal("sk-openai-key", k1);
|
||||
assert_string_equal("pplx-key", k2);
|
||||
}
|
||||
|
||||
@@ -60,3 +60,75 @@ 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);
|
||||
|
||||
/* Setup that also initializes prefs for round-trip persistence tests. */
|
||||
int ai_client_setup_with_prefs(void** state);
|
||||
int ai_client_teardown_with_prefs(void** state);
|
||||
|
||||
/* Chat response parser tests (ai_parse_response) */
|
||||
void test_ai_parse_response_openai_content(void** state);
|
||||
void test_ai_parse_response_perplexity_text(void** state);
|
||||
void test_ai_parse_response_text_preferred_over_content(void** state);
|
||||
void test_ai_parse_response_escaped_quote(void** state);
|
||||
void test_ai_parse_response_newline_escape(void** state);
|
||||
void test_ai_parse_response_empty_content(void** state);
|
||||
void test_ai_parse_response_missing_field(void** state);
|
||||
void test_ai_parse_response_null_input(void** state);
|
||||
void test_ai_parse_response_empty_input(void** state);
|
||||
void test_ai_parse_response_percent_signs_safe(void** state);
|
||||
void test_ai_parse_response_braces_in_content(void** state);
|
||||
void test_ai_parse_response_multiline_content(void** state);
|
||||
|
||||
/* Error response parser tests (ai_parse_error_message) */
|
||||
void test_ai_parse_error_standard_envelope(void** state);
|
||||
void test_ai_parse_error_with_escapes(void** state);
|
||||
void test_ai_parse_error_no_error_field(void** state);
|
||||
void test_ai_parse_error_no_message_field(void** state);
|
||||
void test_ai_parse_error_null_input(void** state);
|
||||
void test_ai_parse_error_empty_input(void** state);
|
||||
void test_ai_parse_error_tab_escape(void** state);
|
||||
void test_ai_parse_error_backslash_escape(void** state);
|
||||
|
||||
/* Extended JSON escape tests */
|
||||
void test_ai_json_escape_backspace(void** state);
|
||||
void test_ai_json_escape_formfeed(void** state);
|
||||
void test_ai_json_escape_carriage_return(void** state);
|
||||
void test_ai_json_escape_all_specials_combined(void** state);
|
||||
void test_ai_json_escape_passes_utf8_through(void** state);
|
||||
|
||||
/* Autocomplete cycling with many providers */
|
||||
void test_ai_providers_find_cycles_through_many(void** state);
|
||||
void test_ai_providers_find_previous_walks_backwards(void** state);
|
||||
void test_ai_providers_find_wraps_around(void** state);
|
||||
|
||||
/* Session edge cases */
|
||||
void test_ai_session_add_message_null_session_no_crash(void** state);
|
||||
void test_ai_session_add_message_null_role_no_crash(void** state);
|
||||
void test_ai_session_add_message_null_content_no_crash(void** state);
|
||||
void test_ai_session_history_preserves_large_order(void** state);
|
||||
void test_ai_session_clear_empty_history(void** state);
|
||||
void test_ai_session_set_model_null_keeps_old(void** state);
|
||||
void test_ai_session_unref_null_no_crash(void** state);
|
||||
void test_ai_session_ref_null_returns_null(void** state);
|
||||
|
||||
/* Provider edge cases */
|
||||
void test_ai_get_provider_null_returns_null(void** state);
|
||||
void test_ai_remove_provider_null_returns_false(void** state);
|
||||
void test_ai_remove_provider_twice_second_fails(void** state);
|
||||
void test_ai_provider_survives_via_session_after_removal(void** state);
|
||||
|
||||
/* Settings edge cases */
|
||||
void test_ai_settings_multiple_keys_independent(void** state);
|
||||
void test_ai_settings_get_missing_returns_null(void** state);
|
||||
void test_ai_settings_isolated_between_providers(void** state);
|
||||
|
||||
/* Model parsing edge cases */
|
||||
void test_ai_parse_models_data_not_array(void** state);
|
||||
void test_ai_parse_models_empty_data_array(void** state);
|
||||
void test_ai_parse_models_id_outside_data_ignored(void** state);
|
||||
void test_ai_parse_models_multiple_models(void** state);
|
||||
|
||||
/* Prefs round-trip tests */
|
||||
void test_ai_prefs_round_trip_api_key(void** state);
|
||||
void test_ai_prefs_round_trip_remove_key(void** state);
|
||||
void test_ai_prefs_multiple_providers_persist(void** state);
|
||||
|
||||
@@ -716,6 +716,65 @@ main(int argc, char* argv[])
|
||||
cmocka_unit_test(test_ai_json_escape_special_chars),
|
||||
cmocka_unit_test(test_ai_json_escape_percent_signs),
|
||||
cmocka_unit_test(test_ai_json_escape_backslash_quote),
|
||||
/* Chat response parser */
|
||||
cmocka_unit_test(test_ai_parse_response_openai_content),
|
||||
cmocka_unit_test(test_ai_parse_response_perplexity_text),
|
||||
cmocka_unit_test(test_ai_parse_response_text_preferred_over_content),
|
||||
cmocka_unit_test(test_ai_parse_response_escaped_quote),
|
||||
cmocka_unit_test(test_ai_parse_response_newline_escape),
|
||||
cmocka_unit_test(test_ai_parse_response_empty_content),
|
||||
cmocka_unit_test(test_ai_parse_response_missing_field),
|
||||
cmocka_unit_test(test_ai_parse_response_null_input),
|
||||
cmocka_unit_test(test_ai_parse_response_empty_input),
|
||||
cmocka_unit_test(test_ai_parse_response_percent_signs_safe),
|
||||
cmocka_unit_test(test_ai_parse_response_braces_in_content),
|
||||
cmocka_unit_test(test_ai_parse_response_multiline_content),
|
||||
/* Error response parser */
|
||||
cmocka_unit_test(test_ai_parse_error_standard_envelope),
|
||||
cmocka_unit_test(test_ai_parse_error_with_escapes),
|
||||
cmocka_unit_test(test_ai_parse_error_no_error_field),
|
||||
cmocka_unit_test(test_ai_parse_error_no_message_field),
|
||||
cmocka_unit_test(test_ai_parse_error_null_input),
|
||||
cmocka_unit_test(test_ai_parse_error_empty_input),
|
||||
cmocka_unit_test(test_ai_parse_error_tab_escape),
|
||||
cmocka_unit_test(test_ai_parse_error_backslash_escape),
|
||||
/* Extended JSON escape coverage */
|
||||
cmocka_unit_test(test_ai_json_escape_backspace),
|
||||
cmocka_unit_test(test_ai_json_escape_formfeed),
|
||||
cmocka_unit_test(test_ai_json_escape_carriage_return),
|
||||
cmocka_unit_test(test_ai_json_escape_all_specials_combined),
|
||||
cmocka_unit_test(test_ai_json_escape_passes_utf8_through),
|
||||
/* Autocomplete cycling */
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_cycles_through_many, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_previous_walks_backwards, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_wraps_around, ai_client_setup, ai_client_teardown),
|
||||
/* Session edge cases */
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_session_no_crash, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_role_no_crash, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_add_message_null_content_no_crash, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_history_preserves_large_order, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_clear_empty_history, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_set_model_null_keeps_old, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_unref_null_no_crash, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_session_ref_null_returns_null, ai_client_setup, ai_client_teardown),
|
||||
/* Provider edge cases */
|
||||
cmocka_unit_test_setup_teardown(test_ai_get_provider_null_returns_null, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_remove_provider_null_returns_false, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_remove_provider_twice_second_fails, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_provider_survives_via_session_after_removal, ai_client_setup, ai_client_teardown),
|
||||
/* Settings */
|
||||
cmocka_unit_test_setup_teardown(test_ai_settings_multiple_keys_independent, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_settings_get_missing_returns_null, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_settings_isolated_between_providers, ai_client_setup, ai_client_teardown),
|
||||
/* Model parsing edge cases */
|
||||
cmocka_unit_test_setup_teardown(test_ai_parse_models_data_not_array, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_parse_models_empty_data_array, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_parse_models_id_outside_data_ignored, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_parse_models_multiple_models, ai_client_setup, ai_client_teardown),
|
||||
/* Prefs round-trip (uses prefs+ai setup) */
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_api_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_remove_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_multiple_providers_persist, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
// Flatfile export/import round-trip
|
||||
cmocka_unit_test(test_ff_roundtrip_simple_chat),
|
||||
cmocka_unit_test(test_ff_roundtrip_with_all_metadata),
|
||||
|
||||
Reference in New Issue
Block a user