/* * 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 /* 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; static Autocomplete providers_ac = NULL; /* ======================================================================== * Forward declarations * ======================================================================== */ static void _ai_load_models_for_provider(AIProvider* provider); static void _ai_save_models_for_provider(AIProvider* provider); /* ======================================================================== * Curl helpers * ======================================================================== */ 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; } if (!wins_ai_exists((ProfAiWin*)user_data)) { 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) { 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); } /** * Display a response message in the AI window (thread-safe). * @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) { ProfAiWin* aiwin = _aiwin_validate(user_data); if (!aiwin) { log_warning("[AI-THREAD] Cannot display response: aiwin is invalid or window was closed"); return; } pthread_mutex_lock(&lock); 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); } /* ======================================================================== * Provider Management * ======================================================================== */ static AIProvider* 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); provider->models = NULL; provider->models_fresh = FALSE; provider->ref_count = 1; return provider; } static 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->org_id); g_free(provider->project_id); g_free(provider->default_model); g_hash_table_destroy(provider->settings); GList* curr = provider->models; while (curr) { g_free(curr->data); curr = g_list_next(curr); } g_list_free(provider->models); g_free(provider); } static void ai_load_keys(void) { if (!provider_keys) { provider_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); } GList* tokens = prefs_ai_list_tokens(); if (!tokens) { return; } GList* curr = tokens; while (curr) { gchar* provider = (gchar*)curr->data; gchar* key = prefs_ai_get_token(provider); if (key && strlen(key) > 0) { g_hash_table_insert(provider_keys, g_strdup(provider), g_strdup(key)); } g_free(key); curr = g_list_next(curr); } prefs_free_ai_tokens(tokens); log_info("Loaded %d saved API keys from config", g_hash_table_size(provider_keys)); } void ai_client_init(void) { if (providers) return; /* Already initialized */ curl_global_init(CURL_GLOBAL_ALL); /* Create hash tables */ providers = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, (GDestroyNotify)ai_provider_unref); provider_keys = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); /* Create autocomplete for provider names */ providers_ac = autocomplete_new(); /* 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* curr = provider_list; while (curr) { AIProvider* provider = (AIProvider*)curr->data; _ai_load_models_for_provider(provider); curr = g_list_next(curr); } g_list_free(provider_list); log_info("AI client initialized with default providers: openai, perplexity"); } 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); } AIProvider* ai_add_provider(const gchar* name, const gchar* api_url, const gchar* org_id) { 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); g_free(existing->org_id); existing->org_id = g_strdup(org_id); log_info("Updated provider: %s", name); return ai_provider_ref(existing); } /* 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); /* 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 */ } gboolean ai_remove_provider(const gchar* name) { if (!name || !providers) return FALSE; /* 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 * ======================================================================== */ /* 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) { 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; } AIProvider* provider = aiwin->session->provider; if (!provider || !provider->models) { return NULL; } /* Create a temporary autocomplete for models */ Autocomplete models_ac = autocomplete_new(); GList* curr = provider->models; while (curr) { autocomplete_add(models_ac, curr->data); curr = g_list_next(curr); } /* NULL search_str is treated as empty string for cycling */ const char* effective_search = (search_str != NULL) ? search_str : ""; gchar* result = autocomplete_complete(models_ac, effective_search, FALSE, previous); autocomplete_free(models_ac); return result; } gchar* ai_get_provider_key(const gchar* provider_name) { 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); log_info("Default model for provider '%s' set to: %s", provider_name, model); } const gchar* ai_get_provider_default_model(const gchar* provider_name) { if (!provider_name) return NULL; AIProvider* provider = ai_get_provider(provider_name); if (!provider) return NULL; return provider->default_model; } void ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value) { if (!provider_name || !setting) return; AIProvider* provider = ai_get_provider(provider_name); if (!provider) { log_warning("Provider '%s' not found for setting '%s'", provider_name, setting); return; } if (!provider->settings) { provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free); } if (value) { g_hash_table_insert(provider->settings, g_strdup(setting), g_strdup(value)); log_info("Setting '%s' for provider '%s' set to: %s", setting, provider_name, value); } else { g_hash_table_remove(provider->settings, setting); log_info("Setting '%s' removed for provider '%s'", setting, provider_name); } } gchar* ai_get_provider_setting(const gchar* provider_name, const gchar* setting) { if (!provider_name || !setting) return NULL; AIProvider* provider = ai_get_provider(provider_name); if (!provider || !provider->settings) return NULL; gchar* value = g_hash_table_lookup(provider->settings, setting); return g_strdup(value); } /* ======================================================================== * Generic HTTP request handling * ======================================================================== */ typedef void (*ai_response_handler_t)(AIProvider* provider, const gchar* response, gpointer user_data); typedef struct { gchar* provider_name; gpointer user_data; gchar* request_url; ai_response_handler_t handler; } ai_request_t; /** * Build curl headers with auth and optional org header. * Returns headers list (caller must free with curl_slist_free_all). */ static struct curl_slist* _build_curl_headers(AIProvider* provider, const gchar* api_key) { struct curl_slist* headers = NULL; auto_gchar gchar* auth_header = g_strdup_printf("Authorization: Bearer %s", api_key); headers = curl_slist_append(headers, "Content-Type: application/json"); headers = curl_slist_append(headers, auth_header); if (provider && provider->org_id && strlen(provider->org_id) > 0) { auto_gchar gchar* org_header = g_strdup_printf("OpenAI-Organization: %s", provider->org_id); headers = curl_slist_append(headers, org_header); } return headers; } /** * Execute a curl request and call handler on success. * Returns CURLcode. */ static CURLcode _curl_exec_and_handle(CURL* curl, const gchar* url, struct curl_slist* headers, struct curl_response_t* response, ai_response_handler_t handler, AIProvider* provider, gpointer user_data) { curl_easy_setopt(curl, CURLOPT_URL, url); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, _write_callback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, response); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); CURLcode res = curl_easy_perform(curl); long http_code = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); if (res != CURLE_OK) { auto_gchar gchar* error_msg = g_strdup(curl_easy_strerror(res)); log_error("Request failed for '%s': %s", provider->name, error_msg); _aiwin_display_error(user_data, error_msg); } else if (http_code >= 400) { auto_gchar gchar* error_msg = g_strdup_printf("HTTP %ld: %s", http_code, response->data ? response->data : "Unknown error"); log_error("Request HTTP error for '%s': %s", provider->name, error_msg); _aiwin_display_error(user_data, error_msg); } else { handler(provider, response->data, user_data); } return res; } static gpointer _ai_generic_request_thread(gpointer data) { ai_request_t* req = (ai_request_t*)data; AIProvider* provider = ai_get_provider(req->provider_name); if (!provider) { log_error("Provider '%s' not found for request", req->provider_name); g_free(req->provider_name); g_free(req->request_url); g_free(req); return NULL; } CURL* curl = curl_easy_init(); if (!curl) { log_error("Failed to initialize curl for %s", req->provider_name); g_free(req->provider_name); g_free(req->request_url); g_free(req); return NULL; } auto_gchar gchar* api_key = ai_get_provider_key(req->provider_name); if (!api_key || strlen(api_key) == 0) { auto_gchar gchar* error_msg = g_strdup_printf("No API key set for provider '%s'.", req->provider_name); _aiwin_display_error(req->user_data, error_msg); curl_easy_cleanup(curl); g_free(req->provider_name); g_free(req->request_url); g_free(req); return NULL; } struct curl_slist* headers = _build_curl_headers(provider, api_key); struct curl_response_t response; response.data = g_new0(gchar, 1); response.size = 0; _curl_exec_and_handle(curl, req->request_url, headers, &response, req->handler, provider, req->user_data); curl_slist_free_all(headers); curl_easy_cleanup(curl); g_free(response.data); g_free(req->provider_name); g_free(req->request_url); g_free(req); return NULL; } /* ======================================================================== * Model fetching * ======================================================================== */ gboolean ai_models_are_fresh(const gchar* provider_name) { if (!provider_name) return FALSE; AIProvider* provider = ai_get_provider(provider_name); if (!provider) return FALSE; return provider->models_fresh; } static gboolean _model_exists(AIProvider* provider, const gchar* model) { GList* curr = provider->models; while (curr) { if (g_strcmp0(curr->data, model) == 0) { return TRUE; } curr = g_list_next(curr); } return FALSE; } static void _add_model_if_new(AIProvider* provider, const gchar* model) { if (!_model_exists(provider, model)) { provider->models = g_list_append(provider->models, g_strdup(model)); } } /* ======================================================================== * Model cache persistence * ======================================================================== */ /** * Load cached models for a provider from preferences. */ static void _ai_load_models_for_provider(AIProvider* provider) { if (!provider) return; GList* models = prefs_ai_get_models(provider->name); if (!models) { log_info("No cached models for provider '%s'", provider->name); return; } /* Add loaded models to provider */ GList* curr = models; while (curr) { _add_model_if_new(provider, curr->data); curr = g_list_next(curr); } prefs_free_ai_models(models); if (provider->models) { provider->models_fresh = TRUE; log_info("Loaded %d cached models for provider '%s'", g_list_length(provider->models), provider->name); } } /** * Save models for a provider to preferences. */ static void _ai_save_models_for_provider(AIProvider* provider) { if (!provider || !provider->models) return; /* Build array of model strings */ gint count = g_list_length(provider->models); gchar** models = g_new0(gchar*, count); GList* curr = provider->models; gint i = 0; while (curr) { models[i++] = curr->data; curr = g_list_next(curr); } prefs_ai_set_models(provider->name, (const gchar* const*)models, count); g_free(models); log_debug("Saved %d models for provider '%s' to prefs", count, provider->name); } static const gchar* _find_json_field(const gchar* json, const gchar* field) { auto_gchar gchar* key = g_strdup_printf("\"%s\":\"", field); const gchar* pos = strstr(json, key); if (!pos) { return NULL; } return pos + strlen(key); } static void _parse_openai_models(AIProvider* provider, const gchar* json) { /* Format: {"data": [{"id": "model1"}, {"id": "model2"}, ...]} */ const gchar* data_start = _find_json_field(json, "data"); if (!data_start) { return; } const gchar* obj_start = strstr(data_start, "{\"id\":"); while (obj_start) { const gchar* id_start = _find_json_field(obj_start, "id"); if (!id_start) { break; } const gchar* id_end = strchr(id_start, '"'); if (!id_end) { break; } auto_gchar gchar* model = g_strndup(id_start, id_end - id_start); _add_model_if_new(provider, model); obj_start = strstr(id_end, "{\"id\":"); if (obj_start) { obj_start++; } } } static void _parse_array_models(AIProvider* provider, const gchar* json) { /* Format: ["model1", "model2", ...] */ const gchar* arr_start = strchr(json, '['); if (!arr_start) { return; } const gchar* model_start = strchr(arr_start, '"'); while (model_start) { const gchar* model_end = strchr(model_start + 1, '"'); if (!model_end) { break; } auto_gchar gchar* model = g_strndup(model_start + 1, model_end - model_start - 1); _add_model_if_new(provider, model); model_start = strchr(model_end + 1, '"'); } } static void _parse_and_cache_models(AIProvider* provider, const gchar* response_json) { if (!provider || !response_json) { return; } /* Try OpenAI format first: {"data": [{"id": "model1"}, ...]} */ _parse_openai_models(provider, response_json); /* If no models found, try simple array format: ["model1", ...] */ if (!provider->models) { _parse_array_models(provider, response_json); } provider->models_fresh = TRUE; log_info("Cached %d models for provider '%s'", g_list_length(provider->models), provider->name); /* Persist models to preferences */ _ai_save_models_for_provider(provider); } static void _models_response_handler(AIProvider* provider, const gchar* response, gpointer user_data) { _parse_and_cache_models(provider, response); auto_gchar gchar* msg = g_strdup_printf("Cached %d models for provider '%s'. Use '/ai start ' to start a chat.", g_list_length(provider->models), provider->name); _aiwin_display_response(user_data, msg); } static gpointer _models_fetch_thread(gpointer data) { ai_request_t* req = (ai_request_t*)data; AIProvider* provider = ai_get_provider(req->provider_name); if (!provider) { g_free(req->provider_name); g_free(req); return NULL; } /* Build URL for listing models */ auto_gchar gchar* base_url = g_strdup_printf("%s%s", provider->api_url, g_str_has_suffix(provider->api_url, "/") ? "" : "/"); auto_gchar gchar* request_url = g_strdup_printf("%sv1/models", base_url); req->request_url = g_strdup(request_url); req->handler = _models_response_handler; return _ai_generic_request_thread(req); } gboolean ai_fetch_models(const gchar* provider_name, gpointer user_data) { if (!provider_name) return FALSE; AIProvider* provider = ai_get_provider(provider_name); if (!provider) { log_error("Provider '%s' not found for model fetch", provider_name); return FALSE; } /* If models are fresh, just display them */ if (provider->models_fresh && provider->models) { cons_show("Cached %d models for provider '%s':", g_list_length(provider->models), provider_name); GList* curr = provider->models; while (curr) { cons_show(" %s", (gchar*)curr->data); curr = g_list_next(curr); } cons_show(""); cons_show("Use '/ai models %s --refresh' to fetch fresh models from the API.", provider_name); return TRUE; } /* Fetch models in a thread */ ai_request_t* req = g_new0(ai_request_t, 1); req->provider_name = g_strdup(provider_name); req->user_data = user_data; GThread* thread = g_thread_new("ai_models_fetch", _models_fetch_thread, req); if (!thread) { g_free(req->provider_name); g_free(req); log_error("Failed to create thread for model fetch"); return FALSE; } g_thread_unref(thread); cons_show("Fetching models for provider '%s'...", provider_name); return TRUE; } /* ======================================================================== * Session Management * ======================================================================== */ 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); 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); 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; AIMessage* msg = g_new0(AIMessage, 1); msg->role = g_strdup(role); msg->content = g_strdup(content); session->history = g_list_append(session->history, msg); 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; 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; 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; g_free(session->model); session->model = g_strdup(model); log_info("Session model changed to: %s", model); } /* ======================================================================== * API Request Handling * ======================================================================== */ static gchar* _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 = session->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(session->model); gchar* json_payload = g_strdup_printf( "{\"model\":\"%s\",\"input\":[%s],\"stream\":false,\"store\":false}", escaped_model, messages_json->str); g_string_free(messages_json, TRUE); return json_payload; } static gchar* _parse_ai_response(const gchar* response_json) { if (!response_json || strlen(response_json) == 0) return NULL; /* Try Perplexity /v1/agent format first: look for "text":"..." inside output array * Response: {"output":[{"content":[{"text":"...","type":"output_text"}]}]} */ const gchar* text_start = strstr(response_json, "\"text\":\""); if (text_start) { text_start += strlen("\"text\":\""); const gchar* p = text_start; while (*p) { if (*p == '\\' && *(p + 1) == '"') { p += 2; continue; } if (*p == '"') { gsize len = p - text_start; gchar* result = g_new0(gchar, len + 1); gchar* out = result; const gchar* in = text_start; while (in < p) { if (*in == '\\' && *(in + 1) == '"') { *out++ = '"'; in += 2; } else if (*in == '\\' && *(in + 1) == 'n') { *out++ = '\n'; in += 2; } else { *out++ = *in++; } } *out = '\0'; return result; } p++; } } /* Try legacy OpenAI format: "content":"..." * Response: {"choices":[{"message":{"content":"..."}}]} */ const gchar* content_start = strstr(response_json, "\"content\":\""); if (!content_start) return NULL; content_start += strlen("\"content\":\""); /* Find the closing quote, accounting for escaped quotes */ const gchar* p = content_start; while (*p) { if (*p == '\\' && *(p + 1) == '"') { /* Escaped quote, skip both characters */ p += 2; continue; } if (*p == '"') { /* Found unescaped closing quote */ gsize len = p - content_start; /* Unescape the content: convert \" back to " */ gchar* result = g_new0(gchar, len + 1); gchar* out = result; const gchar* in = content_start; while (in < p) { if (*in == '\\' && *(in + 1) == '"') { *out++ = '"'; in += 2; } else if (*in == '\\' && *(in + 1) == 'n') { *out++ = '\n'; in += 2; } else { *out++ = *in++; } } *out = '\0'; return result; } p++; } return NULL; } 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]; 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 (!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 ' to configure.", 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; } 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", 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 */ ai_session_add_message(session, "user", prompt); log_debug("[AI-THREAD] Added user message to history"); /* Build JSON payload (includes the message we just added) */ log_debug("[AI-THREAD] Building JSON payload..."); 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(session->provider, session->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 = session->provider ? session->provider->api_url : DEFAULT_OPENAI_URL; auto_gchar gchar* request_url = g_strdup_printf("%s%sv1/responses", api_url, g_str_has_suffix(api_url, "/") ? "" : "/"); log_debug("[AI-THREAD] API URL: %s", api_url); log_debug("[AI-THREAD] API Request URL: %s", request_url); log_debug("[AI-THREAD] Model: %s", session->model); /* Execute request with POST fields */ curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json_payload); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L); CURLcode res = curl_easy_perform(curl); log_debug("[AI-THREAD] curl_easy_perform completed, res: %d", res); /* Get HTTP response code */ long http_code = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); log_debug("[AI-THREAD] HTTP response code: %ld", http_code); if (res != CURLE_OK) { auto_gchar gchar* error_msg = g_strdup(curl_easy_strerror(res)); log_error("AI request failed for %s/%s: %s", 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"); auto_gchar gchar* error_msg = g_strdup_printf("HTTP %ld: %s", http_code, response.data ? response.data : "Unknown error"); 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); if (content) { /* 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...", session->provider_name, session->model, response_data); _aiwin_display_error(user_data, "Failed to parse AI response."); } } curl_slist_free_all(headers); curl_easy_cleanup(curl); 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; }