diff --git a/src/ai/ai_client.c b/src/ai/ai_client.c index 00f8d007..1187ebc1 100644 --- a/src/ai/ai_client.c +++ b/src/ai/ai_client.c @@ -35,6 +35,9 @@ static Autocomplete providers_ac = NULL; 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 gchar* _parse_error_response(const gchar* error_json); /* ======================================================================== * Curl helpers @@ -184,6 +187,59 @@ ai_json_escape(const gchar* str) return g_string_free(result, FALSE); } +/* Decode JSON string escapes in [start, end) into a freshly allocated buffer. + * Handles \" \\ \/ \n \t \r \b \f. \uXXXX sequences are passed through verbatim + * since the rest of the AI pipeline already deals in UTF-8 byte streams. */ +static gchar* +_json_unescape_substring(const gchar* start, const gchar* end) +{ + gchar* out = g_new0(gchar, (end - start) + 1); + 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; + default: *w++ = *in++; break; + } + } else { + *w++ = *in++; + } + } + *w = '\0'; + return out; +} + +/* Locate "field":"...value..." in json and return the unescaped value. + * Skips JSON whitespace around the colon and treats any \X (X != \0) inside + * the string body as an opaque escape so an embedded \" cannot be misread as + * the closing quote. */ +static gchar* +_extract_json_string(const gchar* json, const gchar* field) +{ + const gchar* val = _find_json_field(json, field); + if (!val || *val != '"') + return NULL; + + const gchar* start = val + 1; + for (const gchar* p = start; *p; p++) { + if (*p == '\\' && *(p + 1) != '\0') { + p++; /* skip the escaped character; loop ++ advances past it */ + continue; + } + if (*p == '"') { + return _json_unescape_substring(start, p); + } + } + return NULL; +} + /* ======================================================================== * Provider Management * ======================================================================== */ @@ -616,8 +672,11 @@ _curl_exec_and_handle(CURL* curl, const gchar* url, struct curl_slist* headers, 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"); + auto_gchar gchar* parsed = _parse_error_response(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 { @@ -1272,139 +1331,30 @@ _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++; - } - } + /* Perplexity /v1/agent: {"output":[{"content":[{"text":"...","type":"output_text"}]}]} + * Legacy OpenAI: {"choices":[{"message":{"content":"..."}}]} */ + gchar* text = _extract_json_string(response_json, "text"); + if (text) + return text; - /* 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; + return _extract_json_string(response_json, "content"); } -/** - * Extract error message from an API error JSON response. - * Handles format: {"error":{"message":"...","type":"...","code":...}} - */ +/* 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. */ static gchar* _parse_error_response(const gchar* error_json) { if (!error_json || strlen(error_json) == 0) return NULL; - /* Look for "message":"..." inside "error" object */ const gchar* error_obj = strstr(error_json, "\"error\":"); if (!error_obj) return NULL; - const gchar* message_key = strstr(error_obj, "\"message\":\""); - if (!message_key) - return NULL; - - message_key += strlen("\"message\":\""); - const gchar* msg_start = message_key; - const gchar* p = msg_start; - while (*p) { - if (*p == '\\' && *(p + 1) == '"') { - p += 2; - continue; - } - if (*p == '"') { - gsize len = p - msg_start; - gchar* result = g_new0(gchar, len + 1); - gchar* out = result; - const gchar* in = msg_start; - while (in < p) { - if (*in == '\\' && *(in + 1) == '"') { - *out++ = '"'; - in += 2; - } else if (*in == '\\' && *(in + 1) == 'n') { - *out++ = '\n'; - in += 2; - } else if (*in == '\\' && *(in + 1) == '\\') { - *out++ = '\\'; - in += 2; - } else if (*in == '\\' && *(in + 1) == 't') { - *out++ = '\t'; - in += 2; - } else { - *out++ = *in++; - } - } - *out = '\0'; - return result; - } - p++; - } - - return NULL; + return _extract_json_string(error_obj, "message"); } static gpointer