refactor(ai): unify response and error JSON parsing
Both the chat response parser and the error envelope parser were doing
their own ad-hoc strstr scans and inline unescape loops, with different
sets of supported escape sequences (chat: \" \n; error: \" \n \\ \t).
Models endpoint errors didn't try to extract error.message at all and
surfaced raw response bodies to the user — inconsistent with the chat
path which already did parse them.
Consolidate around two helpers:
_json_unescape_substring: decode \" \\ \/ \n \t \r \b \f into a fresh
buffer; pass \uXXXX through verbatim (out of scope here).
_extract_json_string: locate "field":"..." via _find_json_field
(already handles JSON whitespace around the colon) and return the
unescaped value, treating any \X inside the string body as opaque
so embedded \" is never misread as the closing quote.
_parse_ai_response and _parse_error_response now each collapse to a
small wrapper around these helpers. Wire _parse_error_response into the
shared _curl_exec_and_handle 4xx branch so models requests also surface
the provider's error.message instead of the raw body.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user