/* * ai_client.c - AI client for interacting with OpenAI-compatible API providers * * Supports multiple providers (OpenAI, Perplexity, etc.) with per-provider * API keys, custom endpoints, and model selection. * * Copyright (C) 2026 CProof Developers * * SPDX-License-Identifier: GPL-3.0-or-later WITH OpenSSL-exception */ // vim: expandtab:ts=4:sts=4:sw=4 #include "ai_client.h" #include "common.h" #include "log.h" #include "config/preferences.h" #include "profanity.h" #include "tools/autocomplete.h" #include "ui/ui.h" #include "ui/window_list.h" #include #include #include #include /* Global state */ static GHashTable* providers = NULL; static GHashTable* provider_keys = NULL; static Autocomplete providers_ac = NULL; /* ======================================================================== * Forward declarations * ======================================================================== */ static AIProvider* _ai_add_provider_nopersist(const gchar* name, const gchar* api_url); static void _ai_load_models_for_provider(AIProvider* provider); static void _ai_save_models_for_provider(AIProvider* provider); static const gchar* _find_json_field(const gchar* json, const gchar* field); static gchar* _extract_json_string(const gchar* json, const gchar* field); /* ======================================================================== * Curl helpers * ======================================================================== */ struct curl_response_t { gchar* data; size_t size; }; static size_t _write_callback(void* ptr, size_t size, size_t nmemb, void* userdata) { size_t realsize = size * nmemb; struct curl_response_t* res = (struct curl_response_t*)userdata; /* Limit response size to 10MB to prevent OOM */ if (res->size + realsize > 10 * 1024 * 1024) { log_error("[AI-THREAD] Response too large, aborting transfer"); return CURL_WRITEFUNC_ERROR; } gchar* new_data = g_realloc(res->data, res->size + realsize + 1); if (!new_data) { log_error("[AI-THREAD] Failed to allocate memory for response"); return realsize; } res->data = new_data; memcpy(res->data + res->size, ptr, realsize); res->size += realsize; res->data[res->size] = '\0'; return realsize; } /* ======================================================================== * Thread-safe UI display helpers * ======================================================================== */ /** * Validate aiwin pointer by checking if it still exists in the window list. * Detects if the original window was freed (TOCTOU protection). * Returns the validated pointer if valid, NULL if window was closed. */ static ProfAiWin* _aiwin_validate(gpointer user_data) { if (!user_data) { return NULL; } gboolean win_exists = wins_ai_exists((ProfAiWin*)user_data); if (!win_exists) { log_warning("[AI-THREAD] aiwin=%p no longer exists — window was closed", (void*)user_data); return NULL; } /* Pointer is valid */ return (ProfAiWin*)user_data; } /** * Display an error message in the AI window (thread-safe). * @param user_data The original user_data pointer (may be NULL) * @param error_msg The error message to display */ static void _aiwin_display_error(gpointer user_data, const gchar* error_msg) { pthread_mutex_lock(&lock); ProfAiWin* aiwin = _aiwin_validate(user_data); if (!aiwin) { log_warning("[AI-THREAD] Cannot display error: aiwin is invalid or window was closed (msg: %s)", error_msg); pthread_mutex_unlock(&lock); return; } aiwin_display_error(aiwin, error_msg); pthread_mutex_unlock(&lock); } /** * Display a response message. If aiwin is provided and valid, display in AI window. * Otherwise, display in console (for commands run from console). * @param user_data The original user_data pointer (may be NULL) * @param response The response message to display */ static void _aiwin_display_response(gpointer user_data, const gchar* response) { if (!response || strlen(response) == 0) { return; } pthread_mutex_lock(&lock); ProfAiWin* aiwin = _aiwin_validate(user_data); if (!aiwin) { cons_show("%s", response); pthread_mutex_unlock(&lock); return; } aiwin_display_response(aiwin, response); pthread_mutex_unlock(&lock); } /* ======================================================================== * JSON helpers * ======================================================================== */ gchar* ai_json_escape(const gchar* str) { if (!str) return g_strdup(""); GString* result = g_string_new(""); for (const gchar* p = str; *p; p++) { switch (*p) { case '"': g_string_append(result, "\\\""); break; case '\\': g_string_append(result, "\\\\"); break; case '\b': g_string_append(result, "\\b"); break; case '\f': g_string_append(result, "\\f"); break; case '\n': g_string_append(result, "\\n"); break; case '\r': g_string_append(result, "\\r"); break; case '\t': g_string_append(result, "\\t"); break; default: g_string_append_c(result, *p); break; } } return g_string_free(result, FALSE); } /* Keys emitted as fixed payload fields; custom settings must not duplicate them */ static gboolean _is_reserved_payload_key(const gchar* key) { return g_strcmp0(key, "model") == 0 || g_strcmp0(key, "messages") == 0 || g_strcmp0(key, "stream") == 0; } /* RFC 8259 scalars (number/true/false/null) may be emitted bare in a JSON payload */ static gboolean _is_json_bare_token(const gchar* value) { if (g_strcmp0(value, "true") == 0 || g_strcmp0(value, "false") == 0 || g_strcmp0(value, "null") == 0) return TRUE; return g_regex_match_simple("^-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?$", value, 0, 0); } /** * Convert a 4-hex-digit sequence at @p to a 16-bit code point. * Returns -1 on invalid hex. */ static int _hex4_to_int(const gchar* p) { int val = 0; for (int i = 0; i < 4; i++) { char c = p[i]; val <<= 4; if (c >= '0' && c <= '9') val |= c - '0'; else if (c >= 'a' && c <= 'f') val |= c - 'a' + 10; else if (c >= 'A' && c <= 'F') val |= c - 'A' + 10; else return -1; } return val; } /** * Write a UTF-8 encoding of @cp to @w, advancing @w. * Returns the updated pointer. */ static gchar* _write_utf8(gchar* w, unsigned int cp) { if (cp <= 0x7F) { *w++ = (char)cp; } else if (cp <= 0x7FF) { *w++ = (char)(0xC0 | (cp >> 6)); *w++ = (char)(0x80 | (cp & 0x3F)); } else if (cp <= 0xFFFF) { *w++ = (char)(0xE0 | (cp >> 12)); *w++ = (char)(0x80 | ((cp >> 6) & 0x3F)); *w++ = (char)(0x80 | (cp & 0x3F)); } else if (cp <= 0x10FFFF) { *w++ = (char)(0xF0 | (cp >> 18)); *w++ = (char)(0x80 | ((cp >> 12) & 0x3F)); *w++ = (char)(0x80 | ((cp >> 6) & 0x3F)); *w++ = (char)(0x80 | (cp & 0x3F)); } /* Invalid code point: silently skip */ return w; } /* Decode JSON string escapes in [start, end) into a freshly allocated buffer. * Handles \" \\ \/ \n \t \r \b \f and \uXXXX (including surrogate pairs). */ static gchar* _json_unescape_substring(const gchar* start, const gchar* end) { /* Estimate upper bound: each \uXXXX becomes up to 6 UTF-8 bytes. */ gsize max_len = (end - start) * 6 + 1; gchar* out = g_new0(gchar, max_len); gchar* w = out; for (const gchar* in = start; in < end;) { if (*in == '\\' && in + 1 < end) { switch (in[1]) { case '"': *w++ = '"'; in += 2; break; case '\\': *w++ = '\\'; in += 2; break; case '/': *w++ = '/'; in += 2; break; case 'n': *w++ = '\n'; in += 2; break; case 't': *w++ = '\t'; in += 2; break; case 'r': *w++ = '\r'; in += 2; break; case 'b': *w++ = '\b'; in += 2; break; case 'f': *w++ = '\f'; in += 2; break; case 'u': /* Handle \uXXXX or surrogate pair \uHHHH\uLLLL */ if (in + 6 > end) { /* Not enough characters — emit literal */ *w++ = *in++; break; } if (in[2] != '\\' || in[3] != 'u') { /* Single \uXXXX */ int cp = _hex4_to_int(in + 2); if (cp < 0) { /* Invalid hex — emit literal backslash */ *w++ = *in++; } else if (cp < 0xD800 || cp > 0xDFFF) { /* Not a surrogate — encode directly */ w = _write_utf8(w, (unsigned int)cp); in += 6; } else { /* High or low surrogate — expect pair */ unsigned int high = (unsigned int)cp; if ((high >= 0xD800 && high <= 0xDBFF) && in + 12 <= end && in[6] == '\\' && in[7] == 'u') { /* High surrogate followed by \u */ int low = _hex4_to_int(in + 8); if (low >= 0xDC00 && low <= 0xDFFF) { /* Valid surrogate pair */ unsigned int cp2 = (0x10000 + ((high & 0x3FF) << 10)) | (low & 0x3FF); w = _write_utf8(w, cp2); in += 12; } else { /* Invalid low surrogate — emit replacement */ w = _write_utf8(w, 0xFFFD); in += 6; } } else { /* Orphan high surrogate or lone low surrogate */ w = _write_utf8(w, 0xFFFD); in += 6; } } } else { /* \uHHHH\uLLLL — surrogate pair */ int high = _hex4_to_int(in + 2); if (high < 0) { *w++ = *in++; break; } int low = _hex4_to_int(in + 8); if ((high >= 0xD800 && high <= 0xDBFF) && (low >= 0xDC00 && low <= 0xDFFF)) { unsigned int cp2 = (0x10000 + ((unsigned int)(high & 0x3FF) << 10)) | (unsigned int)(low & 0x3FF); w = _write_utf8(w, cp2); in += 12; } else { /* Invalid pair — emit replacement for high */ w = _write_utf8(w, 0xFFFD); in += 6; } } break; default: *w++ = *in++; break; } } else { *w++ = *in++; } } *w = '\0'; /* Realloc to exact size */ return g_realloc(out, w - out + 1); } /* Locate "field":"...value..." in json and return the unescaped value. * Skips JSON whitespace around the colon and treats any \X (X != \0) inside * the string body as an opaque escape so an embedded \" cannot be misread as * the closing quote. */ static gchar* _extract_json_string(const gchar* json, const gchar* field) { const gchar* val = _find_json_field(json, field); if (!val || *val != '"') return NULL; const gchar* start = val + 1; for (const gchar* p = start; *p; p++) { if (*p == '\\' && *(p + 1) != '\0') { p++; /* skip the escaped character; loop ++ advances past it */ continue; } if (*p == '"') { return _json_unescape_substring(start, p); } } return NULL; } /* ======================================================================== * Provider Management * ======================================================================== */ static AIProvider* ai_provider_new(const gchar* name, const gchar* api_url) { AIProvider* provider = g_new0(AIProvider, 1); provider->name = g_strdup(name); provider->api_url = g_strdup(api_url ? api_url : ""); provider->project_id = NULL; provider->default_model = NULL; provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); provider->models = NULL; provider->models_fresh = FALSE; provider->ref_count = 1; pthread_mutex_init(&provider->settings_lock, NULL); return provider; } AIProvider* ai_provider_ref(AIProvider* provider) { if (provider) { g_atomic_int_inc(&provider->ref_count); } return provider; } void ai_provider_unref(AIProvider* provider) { if (!provider) return; if (!g_atomic_int_dec_and_test(&provider->ref_count)) { return; } g_free(provider->name); g_free(provider->api_url); g_free(provider->project_id); g_free(provider->default_model); g_hash_table_destroy(provider->settings); pthread_mutex_destroy(&provider->settings_lock); GList* curr = provider->models; while (curr) { g_free(curr->data); curr = g_list_next(curr); } g_list_free(provider->models); g_free(provider); } static void ai_load_keys(void) { if (!provider_keys) { provider_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); } GList* tokens = prefs_ai_list_tokens(); if (!tokens) { return; } GList* curr = tokens; while (curr) { gchar* provider = (gchar*)curr->data; gchar* key = prefs_ai_get_token(provider); if (key && strlen(key) > 0) { g_hash_table_insert(provider_keys, g_strdup(provider), g_strdup(key)); } g_free(key); curr = g_list_next(curr); } prefs_free_ai_tokens(tokens); log_info("Loaded %d saved API keys from config", g_hash_table_size(provider_keys)); } void ai_client_init(void) { if (providers) return; /* Already initialized */ curl_global_init(CURL_GLOBAL_ALL); /* Create hash tables */ providers = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, (GDestroyNotify)ai_provider_unref); provider_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); /* Create autocomplete for provider names */ providers_ac = autocomplete_new(); /* Get all providers (defaults if nothing configured, otherwise config values) */ GList* all_providers = prefs_ai_get_providers(); for (GList* curr = all_providers; curr; curr = g_list_next(curr)) { /* List contains pairs: name, url, name, url, ... */ gchar* name = (gchar*)curr->data; curr = g_list_next(curr); if (!curr) break; gchar* url = (gchar*)curr->data; _ai_add_provider_nopersist(name, url); } prefs_free_ai_providers(all_providers); /* Load saved API keys from config */ ai_load_keys(); /* Load cached models, persisted default-model and custom settings for * all providers. */ GList* provider_list = ai_list_providers(); for (GList* curr = provider_list; curr; curr = g_list_next(curr)) { AIProvider* provider = (AIProvider*)curr->data; _ai_load_models_for_provider(provider); auto_gchar gchar* dm = prefs_ai_get_default_model(provider->name); if (dm && dm[0] != '\0') { g_free(provider->default_model); provider->default_model = g_strdup(dm); } GList* settings = prefs_ai_list_settings(provider->name); for (GList* s = settings; s; s = g_list_next(s)) { const gchar* name = (const gchar*)s->data; auto_gchar gchar* val = prefs_ai_get_setting(provider->name, name); if (val) { if (!provider->settings) { provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); } g_hash_table_insert(provider->settings, g_strdup(name), g_strdup(val)); } } prefs_free_ai_settings(settings); } g_list_free(provider_list); log_info("AI client initialized with providers from preferences"); } void ai_client_shutdown(void) { if (!providers) return; g_hash_table_destroy(providers); g_hash_table_destroy(provider_keys); providers = NULL; provider_keys = NULL; if (providers_ac) { autocomplete_free(providers_ac); providers_ac = NULL; } curl_global_cleanup(); log_info("AI client shutdown"); } AIProvider* ai_get_provider(const gchar* name) { if (!name || !providers) return NULL; return g_hash_table_lookup(providers, name); } /* Internal helper to add provider without persisting to config. * Used for default providers and loading from config on startup. */ static AIProvider* _ai_add_provider_nopersist(const gchar* name, const gchar* api_url) { /* Check if provider already exists */ AIProvider* existing = g_hash_table_lookup(providers, name); if (existing) { g_free(existing->api_url); existing->api_url = g_strdup(api_url); return ai_provider_ref(existing); } /* Create new provider (ref_count=1 owned by hash table) */ AIProvider* provider = ai_provider_new(name, api_url); g_hash_table_insert(providers, g_strdup(name), provider); /* Sync autocomplete */ autocomplete_add(providers_ac, name); return provider; } AIProvider* ai_add_provider(const gchar* name, const gchar* api_url) { if (!name || !api_url) return NULL; if (!providers) { ai_client_init(); } /* Check if provider already exists */ AIProvider* existing = g_hash_table_lookup(providers, name); if (existing) { /* Update existing provider */ g_free(existing->api_url); existing->api_url = g_strdup(api_url); /* Persist the updated URL to config */ prefs_ai_set_provider(name, api_url); log_info("Updated provider: %s", name); return ai_provider_ref(existing); } /* Persist the new provider to config */ prefs_ai_set_provider(name, api_url); /* Add to hash table (reuse internal helper) */ AIProvider* provider = _ai_add_provider_nopersist(name, api_url); log_info("Added provider: %s (URL: %s)", name, api_url); return provider; /* Caller gets non-owning pointer; hash table owns ref */ } gboolean ai_remove_provider(const gchar* name) { if (!name || !providers) return FALSE; /* Wipe the whole [ai/] subsection from config — url, key, * default_model, models, custom settings — in one call. */ prefs_ai_remove_section(name); /* Sync autocomplete before removing */ autocomplete_remove(providers_ac, name); return g_hash_table_remove(providers, name); } GList* ai_list_providers(void) { if (!providers) return NULL; GList* result = NULL; GHashTableIter iter; gpointer key, value; g_hash_table_iter_init(&iter, providers); while (g_hash_table_iter_next(&iter, &key, &value)) { result = g_list_append(result, value); } return result; } /* ======================================================================== * Provider autocomplete state * ======================================================================== */ gchar* ai_providers_find(const char* const search_str, gboolean previous, void* context) { if (!providers_ac) { log_debug("[AI-AC] Uninitialized call to ai_providers_find"); return NULL; } return autocomplete_complete(providers_ac, search_str, FALSE, previous); } /* Reset the provider autocomplete state. * Called from cmd_ac_reset() to clear autocomplete state when the user presses Ctrl+C. */ void ai_providers_reset_ac(void) { if (!providers_ac) { return; } autocomplete_reset(providers_ac); } gchar* ai_get_provider_key(const gchar* provider_name) { if (!provider_name || !provider_keys) return NULL; gchar* key = g_hash_table_lookup(provider_keys, provider_name); return g_strdup(key); } void ai_set_provider_key(const gchar* provider_name, const gchar* api_key) { if (!provider_name) return; if (!provider_keys) { provider_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); } if (api_key) { g_hash_table_insert(provider_keys, g_strdup(provider_name), g_strdup(api_key)); /* Persist to config file */ prefs_ai_set_token(provider_name, api_key); log_info("API key set for provider: %s", provider_name); } else { g_hash_table_remove(provider_keys, provider_name); /* Remove from config file */ prefs_ai_remove_token(provider_name); log_info("API key removed for provider: %s", provider_name); } } /* ======================================================================== * Provider default model and settings * ======================================================================== */ void ai_set_provider_default_model(const gchar* provider_name, const gchar* model) { if (!provider_name || !model) return; AIProvider* provider = ai_get_provider(provider_name); if (!provider) { log_warning("Provider '%s' not found for default model setting", provider_name); return; } g_free(provider->default_model); provider->default_model = g_strdup(model); prefs_ai_set_default_model(provider_name, model); log_info("Default model for provider '%s' set to: %s", provider_name, model); } const gchar* ai_get_provider_default_model(const gchar* provider_name) { if (!provider_name) return NULL; AIProvider* provider = ai_get_provider(provider_name); if (!provider) return NULL; return provider->default_model; } gboolean ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value) { if (!provider_name || !setting) return FALSE; if (_is_reserved_payload_key(setting)) { log_warning("Setting '%s' for provider '%s' rejected: reserved payload key", setting, provider_name); return FALSE; } AIProvider* provider = ai_get_provider(provider_name); if (!provider) { log_warning("Provider '%s' not found for setting '%s'", provider_name, setting); return FALSE; } /* settings is iterated by the request thread; mutate under settings_lock */ pthread_mutex_lock(&provider->settings_lock); if (!provider->settings) { provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); } if (value) { g_hash_table_insert(provider->settings, g_strdup(setting), g_strdup(value)); } else { g_hash_table_remove(provider->settings, setting); } pthread_mutex_unlock(&provider->settings_lock); if (value) { prefs_ai_set_setting(provider_name, setting, value); log_info("Setting '%s' for provider '%s' set to: %s", setting, provider_name, value); } else { prefs_ai_remove_setting(provider_name, setting); log_info("Setting '%s' removed for provider '%s'", setting, provider_name); } return TRUE; } gchar* ai_get_provider_setting(const gchar* provider_name, const gchar* setting) { if (!provider_name || !setting) return NULL; AIProvider* provider = ai_get_provider(provider_name); if (!provider || !provider->settings) return NULL; pthread_mutex_lock(&provider->settings_lock); gchar* value = g_strdup(g_hash_table_lookup(provider->settings, setting)); pthread_mutex_unlock(&provider->settings_lock); return value; } /* ======================================================================== * Generic HTTP request handling * ======================================================================== */ typedef void (*ai_response_handler_t)(AIProvider* provider, const gchar* response, gpointer user_data); typedef struct { gchar* provider_name; gpointer user_data; gchar* request_url; ai_response_handler_t handler; } ai_request_t; /** * Build curl headers with auth. * Returns headers list (caller must free with curl_slist_free_all). */ static struct curl_slist* _build_curl_headers(AIProvider* provider, const gchar* api_key) { struct curl_slist* headers = NULL; auto_gchar gchar* auth_header = g_strdup_printf("Authorization: Bearer %s", api_key); headers = curl_slist_append(headers, "Content-Type: application/json"); headers = curl_slist_append(headers, auth_header); return headers; } /** * Execute a curl request and call handler on success. * Returns CURLcode. */ static CURLcode _curl_exec_and_handle(CURL* curl, const gchar* url, struct curl_slist* headers, struct curl_response_t* response, ai_response_handler_t handler, AIProvider* provider, gpointer user_data) { curl_easy_setopt(curl, CURLOPT_URL, url); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, _write_callback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, response); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); CURLcode res = curl_easy_perform(curl); long http_code = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); if (res != CURLE_OK) { auto_gchar gchar* error_msg = g_strdup(curl_easy_strerror(res)); log_error("Request failed for '%s': %s", provider->name, error_msg); _aiwin_display_error(user_data, error_msg); } else if (http_code >= 400) { auto_gchar gchar* parsed = ai_parse_error_message(response->data); auto_gchar gchar* error_msg = parsed ? g_strdup_printf("HTTP %ld: %s", http_code, parsed) : g_strdup_printf("HTTP %ld: %s", http_code, response->data && strlen(response->data) > 0 ? response->data : "Unknown error"); log_error("Request HTTP error for '%s': %s", provider->name, error_msg); _aiwin_display_error(user_data, error_msg); } else { handler(provider, response->data, user_data); } return res; } static gpointer _ai_generic_request_thread(gpointer data) { ai_request_t* req = (ai_request_t*)data; AIProvider* provider = ai_get_provider(req->provider_name); if (!provider) { log_error("Provider '%s' not found for request", req->provider_name); g_free(req->provider_name); g_free(req->request_url); g_free(req); return NULL; } /* Keep the provider alive for the duration of this request. Without this, /ai remove provider X could free the provider * struct (via the hash table's destroy-notify) while curl_easy_perform is still using it — classic UAF. */ ai_provider_ref(provider); CURL* curl = curl_easy_init(); if (!curl) { log_error("Failed to initialize curl for %s", req->provider_name); g_free(req->provider_name); g_free(req->request_url); g_free(req); return NULL; } auto_gchar gchar* api_key = ai_get_provider_key(req->provider_name); if (!api_key || strlen(api_key) == 0) { auto_gchar gchar* error_msg = g_strdup_printf("No API key set for provider '%s'.", req->provider_name); _aiwin_display_error(req->user_data, error_msg); curl_easy_cleanup(curl); g_free(req->provider_name); g_free(req->request_url); g_free(req); return NULL; } struct curl_slist* headers = _build_curl_headers(provider, api_key); struct curl_response_t response; response.data = g_new0(gchar, 1); response.size = 0; _curl_exec_and_handle(curl, req->request_url, headers, &response, req->handler, provider, req->user_data); curl_slist_free_all(headers); curl_easy_cleanup(curl); g_free(response.data); /* Release the reference taken after lookup — provider may now be freed. */ ai_provider_unref(provider); g_free(req->provider_name); g_free(req->request_url); g_free(req); return NULL; } /* ======================================================================== * Model fetching * ======================================================================== */ gboolean ai_models_are_fresh(const gchar* provider_name) { if (!provider_name) return FALSE; AIProvider* provider = ai_get_provider(provider_name); if (!provider) return FALSE; return provider->models_fresh; } static gboolean _model_exists(AIProvider* provider, const gchar* model) { GList* curr = provider->models; while (curr) { if (g_strcmp0(curr->data, model) == 0) { return TRUE; } curr = g_list_next(curr); } return FALSE; } static void _add_model_if_new(AIProvider* provider, const gchar* model) { if (!_model_exists(provider, model)) { provider->models = g_list_append(provider->models, g_strdup(model)); } } /* ======================================================================== * Model cache persistence * ======================================================================== */ /** * Load cached models for a provider from preferences. */ static void _ai_load_models_for_provider(AIProvider* provider) { if (!provider) return; GList* models = prefs_ai_get_models(provider->name); if (!models) { log_info("No cached models for provider '%s'", provider->name); return; } /* Add loaded models to provider */ GList* curr = models; while (curr) { _add_model_if_new(provider, curr->data); curr = g_list_next(curr); } prefs_free_ai_models(models); if (provider->models) { provider->models_fresh = TRUE; log_info("Loaded %d cached models for provider '%s'", g_list_length(provider->models), provider->name); } } /** * Save models for a provider to preferences. */ static void _ai_save_models_for_provider(AIProvider* provider) { if (!provider || !provider->models) return; /* Build array of model strings */ gint count = g_list_length(provider->models); gchar** models = g_new0(gchar*, count); GList* curr = provider->models; gint i = 0; while (curr) { models[i++] = curr->data; curr = g_list_next(curr); } prefs_ai_set_models(provider->name, (const gchar* const*)models, count); g_free(models); log_debug("Saved %d models for provider '%s' to prefs", count, provider->name); } /** * Find a JSON field value, handling both string and non-string values. * For string values: "field":"value" -> returns pointer to "value" * For array values: "field":[ -> returns pointer to [ * For object values: "field":{ -> returns pointer to { * Handles optional whitespace after colon. * Returns pointer to the value start, or NULL if not found. */ static const gchar* _find_json_field(const gchar* json, const gchar* field) { if (!json || !field) { return NULL; } auto_gchar gchar* key = g_strdup_printf("\"%s\"", field); const gchar* pos = strstr(json, key); if (!pos) { return NULL; } /* Skip past the key */ pos += strlen(key); /* Skip whitespace and find the colon */ while (*pos == ' ' || *pos == '\t' || *pos == '\n' || *pos == '\r') { pos++; } if (*pos != ':') { return NULL; } pos++; /* skip colon */ /* Skip whitespace after colon */ while (*pos == ' ' || *pos == '\t' || *pos == '\n' || *pos == '\r') { pos++; } return pos; } /** * Parse models from OpenAI-compatible API response format. * Expected format: {"object":"list","data":[{"id":"model1","object":"model",...},...]} * Extracts only the "id" field from each model object. */ static void _parse_openai_models(AIProvider* provider, const gchar* json) { if (!json || !provider) { return; } /* Find the "data" array start */ const gchar* data_start = _find_json_field(json, "data"); if (!data_start || *data_start != '[') { log_debug("No data array found in API response"); return; } /* Find all {"id":"..."} objects within the data array */ const gchar* obj_start = data_start; while (obj_start) { /* Find next "id" field (handle both {"id": and { "id": formats) */ obj_start = strstr(obj_start, "\"id\""); if (!obj_start) { break; } /* Verify this "id" is within an object (preceded by { or ,) */ const gchar* before_id = obj_start - 1; while (before_id >= data_start && (*before_id == ' ' || *before_id == '\t' || *before_id == '\n' || *before_id == '\r')) { before_id--; } if (before_id < data_start || (*before_id != '{' && *before_id != ',')) { /* This "id" is not at the start of an object in the array, skip it */ obj_start = obj_start + 2; /* move past this "id" */ continue; } /* Find the colon after "id" */ const gchar* id_key = obj_start + 4; /* skip "id" */ /* Skip whitespace */ while (*id_key == ' ' || *id_key == '\t' || *id_key == '\n' || *id_key == '\r') { id_key++; } if (*id_key != ':') { obj_start = id_key; continue; } id_key++; /* skip : */ /* Skip whitespace */ while (*id_key == ' ' || *id_key == '\t' || *id_key == '\n' || *id_key == '\r') { id_key++; } if (*id_key != '"') { /* "id" value is not a string, skip */ obj_start = id_key; continue; } id_key++; /* skip opening quote */ /* Find the closing quote (handling escaped quotes) */ const gchar* id_end = id_key; while (*id_end && *id_end != '"') { if (*id_end == '\\' && *(id_end + 1)) { id_end += 2; /* skip escaped character */ } else { id_end++; } } if (!*id_end) { break; /* No closing quote found */ } /* Extract the model ID */ auto_gchar gchar* model = g_strndup(id_key, id_end - id_key); _add_model_if_new(provider, model); /* Move past this object to find the next one */ obj_start = id_end + 1; /* Safety: stop if we've gone past the data array */ if (obj_start - json > 5 * 1024 * 1024) { break; /* 5MB safety limit */ } } } static void _parse_array_models(AIProvider* provider, const gchar* json) { /* Format: ["model1", "model2", ...] */ const gchar* arr_start = strchr(json, '['); if (!arr_start) { return; } const gchar* model_start = strchr(arr_start, '"'); while (model_start) { const gchar* model_end = strchr(model_start + 1, '"'); if (!model_end) { break; } auto_gchar gchar* model = g_strndup(model_start + 1, model_end - model_start - 1); _add_model_if_new(provider, model); model_start = strchr(model_end + 1, '"'); } } static void _parse_and_cache_models(AIProvider* provider, const gchar* response_json) { if (!provider || !response_json) { return; } /* Try OpenAI format first: {"data": [{"id": "model1"}, ...]} */ _parse_openai_models(provider, response_json); /* If no models found, try simple array format: ["model1", ...] */ if (!provider->models) { _parse_array_models(provider, response_json); } provider->models_fresh = TRUE; log_info("Cached %d models for provider '%s'", g_list_length(provider->models), provider->name); /* Persist models to preferences */ _ai_save_models_for_provider(provider); } /** * Public API: Parse model IDs from JSON response. * Used by tests and internal model fetching. */ void ai_parse_models_from_json(AIProvider* provider, const gchar* json) { if (!provider || !json) { return; } /* Clear existing models before parsing */ GList* curr = provider->models; while (curr) { g_free(curr->data); curr = g_list_next(curr); } g_list_free(provider->models); provider->models = NULL; /* Parse using the same logic as the internal handler */ _parse_and_cache_models(provider, json); } static void _models_response_handler(AIProvider* provider, const gchar* response, gpointer user_data) { _parse_and_cache_models(provider, response); gint count = g_list_length(provider->models); GString* msg = g_string_new(NULL); g_string_append_printf(msg, "Cached %d models for provider '%s':", count, provider->name); for (GList* curr = provider->models; curr; curr = g_list_next(curr)) { g_string_append_printf(msg, "\n %s", (const gchar*)curr->data); } if (count > 0) { g_string_append_printf(msg, "\nUse '/ai start %s ' to start a chat.", provider->name); } auto_gchar gchar* out = g_string_free(msg, FALSE); _aiwin_display_response(user_data, out); } static gpointer _models_fetch_thread(gpointer data) { ai_request_t* req = (ai_request_t*)data; AIProvider* provider = ai_get_provider(req->provider_name); if (!provider) { g_free(req->provider_name); g_free(req); return NULL; } /* Build URL for listing models */ auto_gchar gchar* base_url = g_strdup_printf("%s%s", provider->api_url, g_str_has_suffix(provider->api_url, "/") ? "" : "/"); auto_gchar gchar* request_url = g_strdup_printf("%sv1/models", base_url); req->request_url = g_strdup(request_url); req->handler = _models_response_handler; return _ai_generic_request_thread(req); } gboolean ai_fetch_models(const gchar* provider_name, gpointer user_data) { if (!provider_name) return FALSE; AIProvider* provider = ai_get_provider(provider_name); if (!provider) { log_error("Provider '%s' not found for model fetch", provider_name); return FALSE; } /* Always fetch fresh models from API */ ai_request_t* req = g_new0(ai_request_t, 1); req->provider_name = g_strdup(provider_name); req->user_data = user_data; GThread* thread = g_thread_new("ai_models_fetch", _models_fetch_thread, req); if (!thread) { g_free(req->provider_name); g_free(req); log_error("Failed to create thread for model fetch"); return FALSE; } g_thread_unref(thread); cons_show("Fetching fresh models for provider '%s'...", provider_name); return TRUE; } /* ======================================================================== * Session Management * ======================================================================== */ AISession* ai_session_create(const gchar* provider_name, const gchar* model) { if (!provider_name || !model) return NULL; AIProvider* provider = ai_get_provider(provider_name); if (!provider) { log_error("Provider not found: %s", provider_name); return NULL; } AISession* session = g_new0(AISession, 1); if (pthread_mutex_init(&session->lock, NULL) != 0) { log_error("Failed to initialize session mutex"); g_free(session); return NULL; } session->provider_name = g_strdup(provider_name); session->provider = ai_provider_ref(provider); session->model = g_strdup(model); session->api_key = ai_get_provider_key(provider_name); session->history = NULL; session->ref_count = 1; log_info("AI session created: %s/%s", provider_name, model); return session; } AISession* ai_session_ref(AISession* session) { if (session) { g_atomic_int_inc(&session->ref_count); } return session; } void ai_session_unref(AISession* session) { if (!session) return; if (!g_atomic_int_dec_and_test(&session->ref_count)) { return; } g_free(session->provider_name); ai_provider_unref(session->provider); g_free(session->model); g_free(session->api_key); GList* curr = session->history; while (curr) { AIMessage* msg = curr->data; g_free(msg->role); g_free(msg->content); g_free(msg); curr = g_list_next(curr); } g_list_free(session->history); pthread_mutex_destroy(&session->lock); g_free(session); log_debug("AI session destroyed"); } void ai_session_add_message(AISession* session, const gchar* role, const gchar* content) { if (!session || !role || !content) return; pthread_mutex_lock(&session->lock); AIMessage* msg = g_new0(AIMessage, 1); msg->role = g_strdup(role); msg->content = g_strdup(content); session->history = g_list_append(session->history, msg); pthread_mutex_unlock(&session->lock); log_debug("Added %s message to session (total: %d)", role, g_list_length(session->history)); } void ai_session_clear_history(AISession* session) { if (!session) return; pthread_mutex_lock(&session->lock); GList* curr = session->history; while (curr) { AIMessage* msg = curr->data; g_free(msg->role); g_free(msg->content); g_free(msg); curr = g_list_next(curr); } g_list_free(session->history); session->history = NULL; pthread_mutex_unlock(&session->lock); log_info("AI session history cleared"); } const gchar* ai_session_get_model(AISession* session) { if (!session) return NULL; return session->model; } void ai_session_set_model(AISession* session, const gchar* model) { if (!session || !model) return; pthread_mutex_lock(&session->lock); g_free(session->model); session->model = g_strdup(model); pthread_mutex_unlock(&session->lock); log_info("Session model changed to: %s", model); } void ai_session_switch(AISession* session, const gchar* provider_name, const gchar* model, gchar* api_key) { if (!session || !provider_name || !model || !api_key) return; AIProvider* provider = ai_get_provider(provider_name); if (!provider) { log_warning("Provider '%s' not found for session switch", provider_name); g_free(api_key); return; } pthread_mutex_lock(&session->lock); g_free(session->provider_name); session->provider_name = g_strdup(provider_name); ai_provider_unref(session->provider); session->provider = ai_provider_ref(provider); g_free(session->model); session->model = g_strdup(model); g_free(session->api_key); session->api_key = g_strdup(api_key); pthread_mutex_unlock(&session->lock); g_free(api_key); log_info("Session switched to %s/%s", provider_name, model); } /* ======================================================================== * API Request Handling * ======================================================================== */ /** * Build JSON payload from a GList of AIMessage nodes and a prompt string. * This is the thread-safe variant used by _ai_request_thread() which holds * the session lock while calling it. * * Custom provider settings are merged into the payload as additional JSON * key-value pairs alongside "model" and "messages": scalar values * (number/true/false/null) are emitted bare, anything else as a JSON string; * reserved keys (model/messages/stream) are skipped. * * @param provider The AI provider (for custom settings) * @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(AIProvider* provider, const gchar* model, GList* history, const gchar* prompt) { GString* messages_json = g_string_new(""); GList* curr = history; while (curr) { AIMessage* msg = curr->data; auto_gchar gchar* escaped_content = ai_json_escape(msg->content); auto_gchar gchar* escaped_role = ai_json_escape(msg->role); if (messages_json->len > 0) { g_string_append_c(messages_json, ','); } g_string_append_printf(messages_json, "{\"role\":\"%s\",\"content\":\"%s\"}", escaped_role, escaped_content); curr = g_list_next(curr); } /* Add the new user message */ auto_gchar gchar* escaped_prompt = ai_json_escape(prompt); if (messages_json->len > 0) { g_string_append_c(messages_json, ','); } g_string_append_printf(messages_json, "{\"role\":\"user\",\"content\":\"%s\"}", escaped_prompt); auto_gchar gchar* escaped_model = ai_json_escape(model); /* Build custom settings JSON fragment; settings is mutated on the main * thread (/ai set custom), so iterate under settings_lock */ GString* custom_json = g_string_new(""); if (provider) { pthread_mutex_lock(&provider->settings_lock); if (provider->settings) { GHashTableIter iter; gpointer key, value; g_hash_table_iter_init(&iter, provider->settings); while (g_hash_table_iter_next(&iter, &key, &value)) { if (_is_reserved_payload_key((gchar*)key)) { continue; /* fixed payload fields stay authoritative */ } auto_gchar gchar* escaped_key = ai_json_escape((gchar*)key); if (custom_json->len > 0) { g_string_append_c(custom_json, ','); } if (_is_json_bare_token((gchar*)value)) { g_string_append_printf(custom_json, "\"%s\": %s", escaped_key, (gchar*)value); } else { /* non-scalar values must be quoted, else the payload is invalid JSON */ auto_gchar gchar* escaped_val = ai_json_escape((gchar*)value); g_string_append_printf(custom_json, "\"%s\": \"%s\"", escaped_key, escaped_val); } } } pthread_mutex_unlock(&provider->settings_lock); } /* Assemble final payload: model, messages, custom settings, then fixed keys */ gchar* json_payload; if (custom_json->len > 0) { json_payload = g_strdup_printf( "{\"model\":\"%s\",\"messages\":[%s],%s,\"stream\":false}", escaped_model, messages_json->str, custom_json->str); } else { json_payload = g_strdup_printf( "{\"model\":\"%s\",\"messages\":[%s],\"stream\":false}", escaped_model, messages_json->str); } g_string_free(messages_json, TRUE); g_string_free(custom_json, TRUE); return json_payload; } gchar* ai_parse_response(const gchar* response_json) { if (!response_json || strlen(response_json) == 0) return NULL; /* OpenAI /v1/chat/completions: {"choices":[{"message":{"content":"..."}}]} */ return _extract_json_string(response_json, "content"); } /* Extract the human-readable error.message from a provider error envelope: * {"error":{"message":"...","type":"...","code":...}} * Scoped to the substring after "error": so unrelated top-level "message" * fields do not get picked up. */ gchar* ai_parse_error_message(const gchar* error_json) { if (!error_json || strlen(error_json) == 0) return NULL; const gchar* error_obj = strstr(error_json, "\"error\":"); if (!error_obj) return NULL; return _extract_json_string(error_obj, "message"); } static gpointer _ai_request_thread(gpointer data) { log_debug("[AI-THREAD] Starting AI request thread"); /* Data is an array: [0]=session, [1]=prompt, [2]=user_data */ gpointer* args = (gpointer*)data; AISession* session = (AISession*)args[0]; auto_gchar gchar* prompt = args[1]; gpointer user_data = args[2]; /* ===================================================================== * CRITICAL: Copy all session state under lock to prevent UAF races with * cmd_ai_switch() and ai_session_clear_history() which mutate session * fields on the main thread without holding a session ref. * * The worker holds a ref to the session (via ai_session_ref in * ai_send_prompt), which prevents the session struct from being freed, * but does NOT prevent field mutations (g_free/g_strdup of strings, * list modifications). We must snapshot all fields atomically. * ===================================================================== */ pthread_mutex_lock(&session->lock); auto_gchar gchar* local_provider_name = g_strdup(session->provider_name); AIProvider* local_provider = ai_provider_ref(session->provider); auto_gchar gchar* local_model = g_strdup(session->model); auto_gchar gchar* local_api_key = g_strdup(session->api_key ? session->api_key : ""); pthread_mutex_unlock(&session->lock); log_debug("[AI-THREAD] Session: %s/%s", local_provider_name, local_model); log_debug("[AI-THREAD] API key length: %zu", strlen(local_api_key)); /* Check for API key first */ if (!local_api_key || strlen(local_api_key) == 0) { auto_gchar gchar* error_msg = g_strdup_printf("No API key set for provider '%s'. Use '/ai set token %s ' to configure.", local_provider_name, local_provider_name); log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg); _aiwin_display_error(user_data, error_msg); ai_provider_unref(local_provider); ai_session_unref(session); g_free(args); return NULL; } CURL* curl = curl_easy_init(); log_debug("[AI-THREAD] Curl initialized: %s", curl ? "OK" : "FAILED"); if (!curl) { log_error("AI request failed for %s/%s: Failed to initialize curl", local_provider_name, local_model); _aiwin_display_error(user_data, "Failed to initialize curl."); ai_provider_unref(local_provider); ai_session_unref(session); g_free(args); return NULL; } /* Build JSON payload from history + prompt (under lock), then add user message to history */ log_debug("[AI-THREAD] Building JSON payload..."); pthread_mutex_lock(&session->lock); auto_gchar gchar* json_payload = _build_json_payload_from_list(local_provider, local_model, session->history, prompt); /* Add user message to history now (it's in JSON but not yet in history) */ AIMessage* user_msg = g_new0(AIMessage, 1); user_msg->role = g_strdup("user"); user_msg->content = g_strdup(prompt); session->history = g_list_append(session->history, user_msg); pthread_mutex_unlock(&session->lock); log_debug("[AI-THREAD] Added user message to history"); log_debug("[AI-THREAD] JSON payload: %s", json_payload); /* Build headers using shared helper */ struct curl_slist* headers = _build_curl_headers(local_provider, local_api_key); /* Response buffer */ struct curl_response_t response; response.data = g_new0(gchar, 1); response.size = 0; /* Build request URL */ const gchar* api_url = local_provider->api_url; auto_gchar gchar* request_url = g_strdup_printf("%s%sv1/chat/completions", api_url, g_str_has_suffix(api_url, "/") ? "" : "/"); log_debug("[AI-THREAD] API URL: %s", api_url); log_debug("[AI-THREAD] API Request URL: %s", request_url); log_debug("[AI-THREAD] Model: %s", local_model); /* Set URL and execute request */ curl_easy_setopt(curl, CURLOPT_URL, request_url); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, _write_callback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_payload); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L); CURLcode res = curl_easy_perform(curl); log_debug("[AI-THREAD] curl_easy_perform completed, res: %d", res); /* Get HTTP response code */ long http_code = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); log_debug("[AI-THREAD] HTTP response code: %ld", http_code); auto_gchar gchar* response_data = g_steal_pointer(&response.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); _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); /* Try to extract the actual error message from the JSON response */ auto_gchar gchar* parsed_error = ai_parse_error_message(response_data); auto_gchar gchar* error_msg = parsed_error ? g_strdup_printf("HTTP %ld: %s", http_code, parsed_error) : g_strdup_printf("HTTP %ld: %s", http_code, response_data && strlen(response_data) > 0 ? response_data : "Unknown error"); log_error("AI request failed for %s/%s: %s", local_provider_name, local_model, error_msg); _aiwin_display_error(user_data, error_msg); } else { log_debug("[AI-THREAD] Raw API response (%zu bytes): %s", response.size, 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); _aiwin_display_response(user_data, content); } else { log_error("AI response parse failed for %s/%s: %.200s...", local_provider_name, local_model, response_data); _aiwin_display_error(user_data, "Failed to parse AI response."); } } curl_slist_free_all(headers); curl_easy_cleanup(curl); ai_provider_unref(local_provider); ai_session_unref(session); g_free(args); return NULL; } gboolean ai_send_prompt(AISession* session, const gchar* prompt, gpointer user_data) { log_debug("[AI-PROMPT] ENTER: session=%p, prompt='%s', user_data=%p", (void*)session, prompt, user_data); if (!session || !prompt) { log_error("[AI-PROMPT] FAIL: invalid session or prompt"); return FALSE; } /* Prepare thread arguments: [0]=session, [1]=prompt, [2]=user_data */ gpointer* args = g_new0(gpointer, 3); args[0] = ai_session_ref(session); args[1] = g_strdup(prompt); args[2] = user_data; log_debug("[AI-PROMPT] Prepared args, creating thread..."); GThread* thread = g_thread_new("ai_request", _ai_request_thread, args); if (!thread) { g_free(args[1]); ai_session_unref(session); g_free(args); log_error("[AI-PROMPT] FAIL: g_thread_new returned NULL"); return FALSE; } log_debug("[AI-PROMPT] Thread created successfully: %p", (void*)thread); g_thread_unref(thread); return TRUE; }