/* * 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); static const gchar* _json_array_end(const gchar* arr); /* ======================================================================== * 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 (in either API flavour); 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, "input") == 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; /* Compile the number grammar once; this runs per custom setting per request */ static gsize once = 0; static GRegex* number_re = NULL; if (g_once_init_enter(&once)) { number_re = g_regex_new("^-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?$", G_REGEX_OPTIMIZE, 0, NULL); g_once_init_leave(&once, 1); } return number_re && g_regex_match(number_re, value, 0, NULL); } /** * 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); } /* Unescape the string value starting at @val (a pointer to its opening quote). * 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_at(const gchar* val) { 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; } /* Locate "field":"...value..." in json and return the unescaped value. * Skips JSON whitespace around the colon. @end (optional) bounds the search: * a value starting at or past it is not extracted. */ static gchar* _extract_json_string_within(const gchar* json, const gchar* field, const gchar* end) { const gchar* val = _find_json_field(json, field); if (!val || (end && val >= end)) return NULL; return _extract_json_string_at(val); } static gchar* _extract_json_string(const gchar* json, const gchar* field) { return _extract_json_string_within(json, field, NULL); } /* ======================================================================== * API type helpers * ======================================================================== */ gboolean ai_api_type_from_string(const gchar* str, AIApiType* type) { if (!str || !type) return FALSE; if (g_strcmp0(str, "auto") == 0) { *type = AI_API_TYPE_AUTO; } else if (g_strcmp0(str, "responses") == 0) { *type = AI_API_TYPE_RESPONSES; } else if (g_strcmp0(str, "chat-completions") == 0) { *type = AI_API_TYPE_CHAT_COMPLETIONS; } else { return FALSE; } return TRUE; } const gchar* ai_api_type_to_string(AIApiType type) { switch (type) { case AI_API_TYPE_RESPONSES: return "responses"; case AI_API_TYPE_CHAT_COMPLETIONS: return "chat-completions"; default: return "auto"; } } static const gchar* _api_type_endpoint(AIApiType type) { return type == AI_API_TYPE_CHAT_COMPLETIONS ? "v1/chat/completions" : "v1/responses"; } static AIApiType _other_flavour(AIApiType type) { return type == AI_API_TYPE_CHAT_COMPLETIONS ? AI_API_TYPE_RESPONSES : AI_API_TYPE_CHAT_COMPLETIONS; } static gboolean _names_model(const gchar* s) { if (!s) return FALSE; /* "does not exist"/"not found" alone also appear in route-missing bodies; * require them to talk about a model (covers Ollama's 'model "x" not * found, try pulling it first') */ return strstr(s, "model_not_found") || strstr(s, "invalid_model") || strstr(s, "unknown_model") || (strstr(s, "model") && (strstr(s, "does not exist") || strstr(s, "not found"))); } /* A missing endpoint answers 404/405/501. A wrong model can also 404/405, but * the error names the model (OpenAI code model_not_found, vLLM message "The * model `x` does not exist"), so treat the endpoint as present then. Prefer * the structured error fields; fall back to the raw body for servers that * send no JSON error envelope. */ static gboolean _body_names_model_error(const gchar* body) { if (!body) return FALSE; /* Only trust a structured error envelope. Matching the raw body also * fires on route-missing 404 pages that merely mention models (e.g. an * HTML 404 listing /v1/models), which would wrongly suppress the fallback */ const gchar* err = strstr(body, "\"error\""); if (!err) return FALSE; auto_gchar gchar* code = _extract_json_string(err, "code"); auto_gchar gchar* type = _extract_json_string(err, "type"); auto_gchar gchar* message = _extract_json_string(err, "message"); return _names_model(code) || _names_model(type) || _names_model(message); } static gboolean _is_endpoint_missing(long http_code, const gchar* body) { if (http_code != 404 && http_code != 405 && http_code != 501) return FALSE; return !_body_names_model_error(body); } /* During an AUTO probe, move on to the other flavour when this endpoint is * missing or rejects the payload shape (400/422) — but not on auth/rate-limit * or model errors, which the other endpoint would fail the same way. */ static gboolean _should_try_other_flavour(long http_code, const gchar* body) { if (_is_endpoint_missing(http_code, body)) return TRUE; if (http_code != 400 && http_code != 422) return FALSE; return !_body_names_model_error(body); } /* Copy the provider URL under settings_lock, slash-terminated; @epoch_out * (optional) captures api_epoch in the same critical section so a flavour * stamped later can be tied to the URL it was probed against */ static gchar* _provider_base_url(AIProvider* provider, gint* epoch_out) { pthread_mutex_lock(&provider->settings_lock); gchar* base = g_strdup_printf("%s%s", provider->api_url, g_str_has_suffix(provider->api_url, "/") ? "" : "/"); if (epoch_out) *epoch_out = g_atomic_int_get(&provider->api_epoch); pthread_mutex_unlock(&provider->settings_lock); return base; } /* Single writer for resolved_api_type: takes settings_lock and, when * @expected_epoch >= 0, drops the write if the epoch moved (the URL or * api-type changed while the request was in flight) */ static void _provider_set_resolved(AIProvider* provider, AIApiType type, gint expected_epoch) { pthread_mutex_lock(&provider->settings_lock); if (expected_epoch < 0 || g_atomic_int_get(&provider->api_epoch) == expected_epoch) g_atomic_int_set(&provider->resolved_api_type, type); pthread_mutex_unlock(&provider->settings_lock); } /* ======================================================================== * 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->api_type = AI_API_TYPE_AUTO; provider->resolved_api_type = AI_API_TYPE_AUTO; provider->api_epoch = 0; 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); } auto_gchar gchar* at = prefs_ai_get_api_type(provider->name); AIApiType api_type; if (at && ai_api_type_from_string(at, &api_type)) { provider->api_type = api_type; } 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) { pthread_mutex_lock(&existing->settings_lock); g_free(existing->api_url); existing->api_url = g_strdup(api_url); g_atomic_int_inc(&existing->api_epoch); /* New URL may serve a different API flavour; reset under the lock so a * request thread cannot re-stamp the stale flavour for the new URL */ g_atomic_int_set(&existing->resolved_api_type, AI_API_TYPE_AUTO); pthread_mutex_unlock(&existing->settings_lock); return existing; /* borrowed, same as the create path below */ } /* 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(); } gboolean existed = g_hash_table_lookup(providers, name) != NULL; /* Persist first, then reuse the internal helper for both the update and * create paths (it handles the locked URL swap or the fresh insert) */ prefs_ai_set_provider(name, api_url); AIProvider* provider = _ai_add_provider_nopersist(name, api_url); if (existed) { log_info("Updated provider: %s", name); } else { 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_api_type(const gchar* provider_name, const gchar* api_type) { AIApiType type; if (!provider_name || !ai_api_type_from_string(api_type, &type)) return FALSE; AIProvider* provider = ai_get_provider(provider_name); if (!provider) { log_warning("Provider '%s' not found for API type setting", provider_name); return FALSE; } /* Same protocol as a URL swap: bump the epoch under the lock so an * in-flight request cannot re-stamp resolved_api_type after this reset */ pthread_mutex_lock(&provider->settings_lock); g_atomic_int_set(&provider->api_type, type); g_atomic_int_inc(&provider->api_epoch); g_atomic_int_set(&provider->resolved_api_type, AI_API_TYPE_AUTO); pthread_mutex_unlock(&provider->settings_lock); if (type == AI_API_TYPE_AUTO) { prefs_ai_remove_api_type(provider_name); } else { prefs_ai_set_api_type(provider_name, api_type); } log_info("API type for provider '%s' set to: %s", provider_name, ai_api_type_to_string(type)); return TRUE; } AIApiType ai_get_provider_api_type(const gchar* provider_name) { AIProvider* provider = provider_name ? ai_get_provider(provider_name) : NULL; if (!provider) return AI_API_TYPE_AUTO; return g_atomic_int_get(&provider->api_type); } gboolean ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value) { if (!provider_name || !setting) return FALSE; /* Reject setting a reserved key, but still allow removing one so a stale * entry persisted before the key was reserved can be cleared */ if (value && _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; } GList* ai_get_provider_setting_keys(const gchar* provider_name) { AIProvider* provider = provider_name ? ai_get_provider(provider_name) : NULL; if (!provider || !provider->settings) return NULL; GList* keys = NULL; pthread_mutex_lock(&provider->settings_lock); GHashTableIter iter; gpointer key, value; g_hash_table_iter_init(&iter, provider->settings); while (g_hash_table_iter_next(&iter, &key, &value)) { keys = g_list_prepend(keys, g_strdup((gchar*)key)); } pthread_mutex_unlock(&provider->settings_lock); return keys; } /* ======================================================================== * Generic HTTP request handling * ======================================================================== */ typedef void (*ai_response_handler_t)(AIProvider* provider, const gchar* response, gpointer user_data); typedef struct { gchar* provider_name; AIProvider* provider; /* owned ref taken on the main thread; required */ 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; /* The ref taken on the main thread keeps the provider alive for the * duration of this request; a thread-side lookup instead would race * against /ai remove provider, so its absence is a caller bug */ AIProvider* provider = g_steal_pointer(&req->provider); if (!provider) { log_error("BUG: request for '%s' spawned without a provider ref", 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); ai_provider_unref(provider); 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); ai_provider_unref(provider); 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 main-thread ref — 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); gsize keylen = strlen(key); /* An occurrence not followed by ':' is a string value equal to the field * name (e.g. "text" as the value of "type"), not the key — skip it and * look for the next one instead of giving up */ for (const gchar* search = json; (search = strstr(search, key)) != NULL; search += keylen) { const gchar* pos = search + keylen; while (*pos == ' ' || *pos == '\t' || *pos == '\n' || *pos == '\r') { pos++; } if (*pos != ':') { continue; } pos++; /* skip colon */ while (*pos == ' ' || *pos == '\t' || *pos == '\n' || *pos == '\r') { pos++; } return pos; } return NULL; } /** * 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; /* req->provider carries a ref taken on the main thread, so the provider * cannot be freed under us; the locked snapshot guards the URL swap */ auto_gchar gchar* base_url = _provider_base_url(req->provider, NULL); req->request_url = g_strdup_printf("%sv1/models", base_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. Ref the provider on this thread, * while it is guaranteed alive, so /ai remove provider cannot free it * under the worker; the worker's request struct owns the ref. */ ai_request_t* req = g_new0(ai_request_t, 1); req->provider_name = g_strdup(provider_name); req->provider = ai_provider_ref(provider); req->user_data = user_data; GThread* thread = g_thread_new("ai_models_fetch", _models_fetch_thread, req); if (!thread) { ai_provider_unref(req->provider); 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 * ======================================================================== */ /** * Serialize history plus the new user prompt as a JSON message-object list * (without the enclosing brackets); the fragment is shared by both API * flavours. Must be called with the session lock held. * * @param history GList of AIMessage* (must be held under session lock) * @param prompt The new user prompt to append * @return Newly allocated fragment (caller must free) */ static gchar* _serialize_message_list(GList* history, const gchar* prompt) { GString* messages_json = g_string_new(""); for (GList* curr = history; curr; curr = g_list_next(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); } /* 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); return g_string_free(messages_json, FALSE); } /** * Serialize custom provider settings as a JSON key-value fragment: scalar * values (number/true/false/null) are emitted bare, anything else as a JSON * string; reserved keys (model/messages/input/stream) are skipped. * * @param provider The AI provider (may be NULL) * @param store_set Output: set TRUE when a custom "store" value is present * @return Newly allocated fragment, empty string when there are no settings */ static gchar* _serialize_custom_settings(AIProvider* provider, gboolean* store_set) { GString* custom_json = g_string_new(""); *store_set = FALSE; 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 */ } if (g_strcmp0((gchar*)key, "store") == 0) { *store_set = TRUE; /* suppresses the fixed "store":false */ } 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); } return g_string_free(custom_json, FALSE); } /** * Assemble the payload for one API flavour from pre-serialized fragments: * model, message list, custom settings, then fixed keys. Chat completions * uses the "messages" list key; responses uses "input" plus "store":false * unless a custom "store" setting overrides it. * * @param model The model identifier * @param messages_json Fragment from _serialize_message_list() * @param custom_json Fragment from _serialize_custom_settings() * @param store_set Whether custom_json already carries a "store" value * @param api_type The API flavour to build for (must not be AUTO) * @return Newly allocated JSON string (caller must free) */ static gchar* _assemble_json_payload(const gchar* model, const gchar* messages_json, const gchar* custom_json, gboolean store_set, AIApiType api_type) { auto_gchar gchar* escaped_model = ai_json_escape(model); const gchar* list_key = api_type == AI_API_TYPE_CHAT_COMPLETIONS ? "messages" : "input"; const gchar* fixed_tail = (api_type == AI_API_TYPE_CHAT_COMPLETIONS || store_set) ? "\"stream\":false" : "\"stream\":false,\"store\":false"; return g_strdup_printf("{\"model\":\"%s\",\"%s\":[%s],%s%s%s}", escaped_model, list_key, messages_json, custom_json, *custom_json ? "," : "", fixed_tail); } /* Chat completions: {"choices":[{"message":{"content":"..."}}]}. Anchor on * "message" so a non-string "content" ordered earlier in the choice (e.g. * "logprobs":{"content":[...]}) is not mistaken for the reply. */ static gchar* _parse_chat_completions(const gchar* json) { const gchar* choices = strstr(json, "\"choices\""); if (!choices) return NULL; /* Bound the search to the choices array so a "message"/"content" in a * sibling object (e.g. a top-level "warning") is not taken as the reply */ const gchar* arr = strchr(choices, '['); const gchar* end = arr ? _json_array_end(arr) : NULL; const gchar* message = strstr(choices, "\"message\""); if (message && end && message >= end) message = NULL; const gchar* anchor = message ? message : choices; const gchar* cpos = _find_json_field(anchor, "content"); if (!cpos || (end && cpos >= end)) return NULL; return _extract_json_string(anchor, "content"); } /* TRUE when @from and @to belong to the same JSON object: a balanced nested * sub-object between them is fine, but a brace that exits the enclosing object * (net depth < 0) or leaves us inside a deeper one (net depth != 0) is not */ static gboolean _same_json_object(const gchar* from, const gchar* to) { gboolean in_string = FALSE; gint depth = 0; for (const gchar* p = from; p < to && *p; p++) { if (in_string) { if (*p == '\\' && *(p + 1) != '\0') p++; else if (*p == '"') in_string = FALSE; } else if (*p == '"') { in_string = TRUE; } else if (*p == '{') { depth++; } else if (*p == '}') { if (depth == 0) return FALSE; depth--; } } return depth == 0; } /* Pointer to the '}' closing the object that encloses @pos (which must sit * outside a string literal, e.g. at a key's opening quote), or NULL if * unterminated. Braces inside string literals are ignored. */ static const gchar* _json_object_end(const gchar* pos) { gboolean in_string = FALSE; gint depth = 0; for (const gchar* p = pos; *p; p++) { if (in_string) { if (*p == '\\' && *(p + 1) != '\0') p++; else if (*p == '"') in_string = FALSE; } else if (*p == '"') { in_string = TRUE; } else if (*p == '{') { depth++; } else if (*p == '}') { if (depth == 0) return p; depth--; } } return NULL; } /* Pointer just past the ']' matching the '[' at @arr, or NULL if unterminated. * Brackets inside string literals are ignored. */ static const gchar* _json_array_end(const gchar* arr) { gboolean in_string = FALSE; gint depth = 0; for (const gchar* p = arr; *p; p++) { if (in_string) { if (*p == '\\' && *(p + 1) != '\0') p++; else if (*p == '"') in_string = FALSE; } else if (*p == '"') { in_string = TRUE; } else if (*p == '[') { depth++; } else if (*p == ']') { if (--depth == 0) return p + 1; } } return NULL; } /* Responses: {"output":[...,{"content":[{"type":"output_text","text":"..."}]}]}. * "content" is an array here, so extract the inner "text" of the output_text * part; reasoning items carry their own "text" (summary) ahead of it. */ static gchar* _parse_responses(const gchar* json) { const gchar* output = strstr(json, "\"output\""); if (!output) return NULL; const gchar* part = strstr(output, "\"output_text\""); if (part) { /* Bound the forward scan to the part's own object so a "text" from a * later sibling item (e.g. a following reasoning summary) can never * be returned as the reply */ const gchar* part_end = _json_object_end(part); gchar* text = _extract_json_string_within(part, "text", part_end); if (text) return text; /* "text" may precede "type":"output_text" in the part: take the * nearest preceding "text" that shares the part's object, so an * earlier item's text (a reasoning summary) is never returned; skip * matches where extraction fails (e.g. a string value equal to "text") */ gsize window = part - output; while (window > 0) { const gchar* key = g_strrstr_len(output, window, "\"text\""); if (!key) break; if (_same_json_object(key, part)) { text = _extract_json_string_within(key, "text", part_end); if (text) return text; } window = key - output; } return NULL; } /* No output_text tag (truncated reply, or a server tagging the part * "type":"text"): anchor strictly on the message "content" array. A * reasoning-only body carries "summary", not "content", so returning NULL * here surfaces a parse error instead of leaking the reasoning text */ const gchar* content_arr = strstr(output, "\"content\""); if (!content_arr) return NULL; /* Bound the scan to the content array itself: an empty or text-less array * must fail the parse rather than pick up a "text" from a later item */ const gchar* cval = _find_json_field(content_arr, "content"); if (!cval || *cval != '[') return NULL; return _extract_json_string_within(cval, "text", _json_array_end(cval)); } /* Extract assistant content, trying the known request flavour's extractor * first (AUTO means chat-completions first), then the other flavour, then a * bare top-level "content" string. Each extractor runs at most once. */ gchar* ai_parse_response_typed(AIApiType api_type, const gchar* response_json) { if (!response_json || strlen(response_json) == 0) return NULL; gchar* out; if (api_type == AI_API_TYPE_RESPONSES) { out = _parse_responses(response_json); if (!out) out = _parse_chat_completions(response_json); } else { out = _parse_chat_completions(response_json); if (!out) out = _parse_responses(response_json); } if (out) return out; /* No recognizable envelope: accept a bare top-level "content" string */ return _extract_json_string(response_json, "content"); } gchar* ai_parse_response(const gchar* response_json) { return ai_parse_response_typed(AI_API_TYPE_AUTO, response_json); } /* 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; } /* Serialize the payload fragments once from a single history snapshot * (under lock); per-flavour envelopes are assembled cheaply per attempt */ log_debug("[AI-THREAD] Building JSON payload..."); pthread_mutex_lock(&session->lock); auto_gchar gchar* messages_json = _serialize_message_list(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"); gboolean store_set = FALSE; auto_gchar gchar* custom_json = _serialize_custom_settings(local_provider, &store_set); /* Build headers using shared helper */ struct curl_slist* headers = _build_curl_headers(local_provider, local_api_key); /* Response buffer (allocated per attempt inside the loop) */ struct curl_response_t response; response.data = NULL; response.size = 0; /* Snapshot the URL together with the epoch, so a flavour stamped after * this request cannot be attributed to a URL it was not probed against */ gint api_epoch = 0; auto_gchar gchar* base_url = _provider_base_url(local_provider, &api_epoch); /* Flavour(s) to try: explicit setting wins; a previously detected flavour * is a hint that keeps the other flavour as a fallback attempt (so a * backend change self-corrects in-request); else probe responses first. */ AIApiType configured = g_atomic_int_get(&local_provider->api_type); AIApiType attempts[2]; gint n_attempts = 0; if (configured != AI_API_TYPE_AUTO) { attempts[n_attempts++] = configured; } else { AIApiType resolved = g_atomic_int_get(&local_provider->resolved_api_type); AIApiType first = resolved != AI_API_TYPE_AUTO ? resolved : AI_API_TYPE_RESPONSES; attempts[n_attempts++] = first; attempts[n_attempts++] = _other_flavour(first); } log_debug("[AI-THREAD] API URL: %s", base_url); log_debug("[AI-THREAD] Model: %s", local_model); /* Per-request options shared by all attempts */ 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, 60L); CURLcode res = CURLE_OK; long http_code = 0; AIApiType used_type = attempts[0]; /* A payload rejection (400/422) that triggered the fallback is the * actionable error; keep it in case the other flavour also fails */ auto_gchar gchar* first_reject_body = NULL; long first_reject_code = 0; /* Same for a 2xx we could not parse: the user must learn their endpoint * answered, even when the fallback attempt fails with its own error */ auto_gchar gchar* first_unparsable_note = NULL; auto_gchar gchar* content = NULL; gboolean first_endpoint_missing = FALSE; for (gint i = 0; i < n_attempts; i++) { used_type = attempts[i]; auto_gchar gchar* request_url = g_strdup_printf("%s%s", base_url, _api_type_endpoint(used_type)); auto_gchar gchar* json_payload = _assemble_json_payload(local_model, messages_json, custom_json, store_set, used_type); log_debug("[AI-THREAD] API Request URL: %s", request_url); log_debug("[AI-THREAD] JSON payload: %s", json_payload); /* Reset the response buffer for this attempt */ g_free(response.data); response.data = g_new0(gchar, 1); response.size = 0; curl_easy_setopt(curl, CURLOPT_URL, request_url); curl_easy_setopt(curl, CURLOPT_COPYPOSTFIELDS, json_payload); res = curl_easy_perform(curl); log_debug("[AI-THREAD] curl_easy_perform completed, res: %d", res); http_code = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); log_debug("[AI-THREAD] HTTP response code: %ld", http_code); if (i == 0 && res == CURLE_OK && _is_endpoint_missing(http_code, response.data)) first_endpoint_missing = TRUE; gboolean more = (i + 1 < n_attempts); /* Endpoint missing or payload-shape rejection: fall back to the other flavour */ if (res == CURLE_OK && more && _should_try_other_flavour(http_code, response.data)) { log_info("[AI-THREAD] %s endpoint rejected request for '%s' (HTTP %ld), falling back to %s", ai_api_type_to_string(used_type), local_provider_name, http_code, ai_api_type_to_string(attempts[i + 1])); log_debug("[AI-THREAD] Rejected %s response body: %s", ai_api_type_to_string(used_type), response.data); if (!first_reject_body && !_is_endpoint_missing(http_code, response.data)) { first_reject_body = g_strdup(response.data); first_reject_code = http_code; } continue; } /* Connectivity failure (reset/DNS/refused) on this flavour's endpoint: * the other flavour may still be reachable, so try it before failing. * A timeout is excluded: the request likely reached the server and may * still be generating (and billing) — re-POSTing the conversation to * the other endpoint could trigger a second generation */ if (res != CURLE_OK && res != CURLE_OPERATION_TIMEDOUT && more) { log_info("[AI-THREAD] %s request for '%s' failed (%s), trying %s", ai_api_type_to_string(used_type), local_provider_name, curl_easy_strerror(res), ai_api_type_to_string(attempts[i + 1])); continue; } /* Parse a success body here so a 2xx we cannot parse during an AUTO * probe (e.g. a proxy 200 page) falls back to the other flavour * instead of wedging on this endpoint */ if (res == CURLE_OK && http_code < 400) { g_free(content); content = ai_parse_response_typed(used_type, response.data); if (!content && more && configured == AI_API_TYPE_AUTO && http_code >= 200 && http_code < 300) { log_info("[AI-THREAD] %s returned an unparsable 2xx for '%s', trying %s", ai_api_type_to_string(used_type), local_provider_name, ai_api_type_to_string(attempts[i + 1])); if (!first_unparsable_note) first_unparsable_note = g_strdup_printf("%s endpoint answered HTTP %ld but the body could not be parsed", ai_api_type_to_string(used_type), http_code); continue; } } break; } auto_gchar gchar* response_data = g_steal_pointer(&response.data); if (configured == AI_API_TYPE_AUTO && res == CURLE_OK) { if (_is_endpoint_missing(http_code, response_data)) { /* The flavour we ended on is not served; re-probe on the next request */ _provider_set_resolved(local_provider, AI_API_TYPE_AUTO, api_epoch); } else if (first_endpoint_missing) { /* The first (hint/default) flavour's endpoint is gone but this one * answered; prefer it as the hint so later requests stop probing * the dead endpoint, even if this request itself failed (e.g. auth) */ _provider_set_resolved(local_provider, used_type, api_epoch); } } if (res != CURLE_OK) { /* The stashed first-attempt failure is usually the actionable part: * that endpoint did answer, this one did not */ auto_gchar gchar* first_note = NULL; if (first_reject_body) { auto_gchar gchar* parsed = ai_parse_error_message(first_reject_body); first_note = g_strdup_printf("%s endpoint rejected the request with HTTP %ld: %s", ai_api_type_to_string(attempts[0]), first_reject_code, parsed ? parsed : first_reject_body); } else if (first_unparsable_note) { first_note = g_strdup(first_unparsable_note); } auto_gchar gchar* error_msg = first_note ? g_strdup_printf("%s (first attempt: %s)", curl_easy_strerror(res), first_note) : 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) { /* The first attempt's 400/422 is usually the actionable error, but an * auth/rate-limit/server failure on the second attempt (401/403/429/5xx) * is more actionable — surface that rather than the stashed payload error */ gboolean second_more_actionable = http_code == 401 || http_code == 403 || http_code == 429 || http_code >= 500; gboolean use_first = first_reject_body && !second_more_actionable; const gchar* err_body = use_first ? first_reject_body : response_data; long err_code = use_first ? first_reject_code : http_code; 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(err_body); auto_gchar gchar* error_msg = parsed_error ? g_strdup_printf("HTTP %ld: %s", err_code, parsed_error) : g_strdup_printf("HTTP %ld: %s", err_code, err_body && strlen(err_body) > 0 ? err_body : "Unknown error"); if (first_unparsable_note) { gchar* with_note = g_strdup_printf("%s (first attempt: %s)", error_msg, first_unparsable_note); g_free(error_msg); error_msg = with_note; } 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); if (content) { if (configured == AI_API_TYPE_AUTO && http_code >= 200 && http_code < 300) { /* Remember the flavour that worked so later requests try it * first; the epoch check discards the stamp if the URL or * api-type changed while this request was in flight */ _provider_set_resolved(local_provider, used_type, api_epoch); } /* 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 { /* A 2xx we cannot parse means the cached flavour may be wrong; drop * it so the next request re-probes instead of wedging on it */ if (configured == AI_API_TYPE_AUTO) _provider_set_resolved(local_provider, AI_API_TYPE_AUTO, api_epoch); 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; }