|
|
|
|
@@ -380,14 +380,12 @@ _json_unescape_substring(const gchar* start, const gchar* end)
|
|
|
|
|
return g_realloc(out, w - out + 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Locate "field":"...value..." in json and return the unescaped value.
|
|
|
|
|
* Skips JSON whitespace around the colon and treats any \X (X != \0) inside
|
|
|
|
|
* the string body as an opaque escape so an embedded \" cannot be misread as
|
|
|
|
|
* the closing quote. */
|
|
|
|
|
/* 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(const gchar* json, const gchar* field)
|
|
|
|
|
_extract_json_string_at(const gchar* val)
|
|
|
|
|
{
|
|
|
|
|
const gchar* val = _find_json_field(json, field);
|
|
|
|
|
if (!val || *val != '"')
|
|
|
|
|
return NULL;
|
|
|
|
|
|
|
|
|
|
@@ -404,6 +402,24 @@ _extract_json_string(const gchar* json, const gchar* field)
|
|
|
|
|
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
|
|
|
|
|
* ======================================================================== */
|
|
|
|
|
@@ -456,11 +472,16 @@ _names_model(const gchar* s)
|
|
|
|
|
{
|
|
|
|
|
if (!s)
|
|
|
|
|
return FALSE;
|
|
|
|
|
/* "does not exist" alone also appears in route-missing bodies; require it
|
|
|
|
|
* to talk about a model */
|
|
|
|
|
return strstr(s, "model_not_found") || strstr(s, "invalid_model")
|
|
|
|
|
|| strstr(s, "unknown_model") || strstr(s, "model not found")
|
|
|
|
|
|| (strstr(s, "model") && strstr(s, "does not exist"));
|
|
|
|
|
/* "does not exist"/"not found"/"not supported" 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' and Perplexity's
|
|
|
|
|
* 'model "x" is not supported'). Gateways vary in casing, so compare
|
|
|
|
|
* case-insensitively. */
|
|
|
|
|
auto_gchar gchar* lower = g_ascii_strdown(s, -1);
|
|
|
|
|
return strstr(lower, "model_not_found") || strstr(lower, "invalid_model")
|
|
|
|
|
|| strstr(lower, "unknown_model")
|
|
|
|
|
|| (strstr(lower, "model")
|
|
|
|
|
&& (strstr(lower, "does not exist") || strstr(lower, "not found") || strstr(lower, "not supported")));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* A missing endpoint answers 404/405/501. A wrong model can also 404/405, but
|
|
|
|
|
@@ -1022,7 +1043,7 @@ typedef void (*ai_response_handler_t)(AIProvider* provider, const gchar* respons
|
|
|
|
|
typedef struct
|
|
|
|
|
{
|
|
|
|
|
gchar* provider_name;
|
|
|
|
|
AIProvider* provider; /* owned ref taken on the main thread, or NULL */
|
|
|
|
|
AIProvider* provider; /* owned ref taken on the main thread; required */
|
|
|
|
|
gpointer user_data;
|
|
|
|
|
gchar* request_url;
|
|
|
|
|
ai_response_handler_t handler;
|
|
|
|
|
@@ -1071,7 +1092,7 @@ _curl_exec_and_handle(CURL* curl, const gchar* url, struct curl_slist* headers,
|
|
|
|
|
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");
|
|
|
|
|
response->data && strlen(response->data) > 0 ? response->data : "empty response body");
|
|
|
|
|
log_error("Request HTTP error for '%s': %s", provider->name, error_msg);
|
|
|
|
|
_aiwin_display_error(user_data, error_msg);
|
|
|
|
|
} else {
|
|
|
|
|
@@ -1086,20 +1107,16 @@ _ai_generic_request_thread(gpointer data)
|
|
|
|
|
{
|
|
|
|
|
ai_request_t* req = (ai_request_t*)data;
|
|
|
|
|
|
|
|
|
|
/* Keep the provider alive for the duration of this request. Prefer the ref
|
|
|
|
|
* taken on the main thread (guaranteed pre-removal); the lookup fallback
|
|
|
|
|
* still has a small window against /ai remove provider. */
|
|
|
|
|
/* 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) {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
ai_provider_ref(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();
|
|
|
|
|
@@ -1135,7 +1152,7 @@ _ai_generic_request_thread(gpointer data)
|
|
|
|
|
curl_easy_cleanup(curl);
|
|
|
|
|
g_free(response.data);
|
|
|
|
|
|
|
|
|
|
/* Release the reference taken after lookup — provider may now be freed. */
|
|
|
|
|
/* Release the main-thread ref — provider may now be freed. */
|
|
|
|
|
ai_provider_unref(provider);
|
|
|
|
|
|
|
|
|
|
g_free(req->provider_name);
|
|
|
|
|
@@ -1833,6 +1850,33 @@ _same_json_object(const gchar* from, const gchar* to)
|
|
|
|
|
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*
|
|
|
|
|
@@ -1870,7 +1914,11 @@ _parse_responses(const gchar* json)
|
|
|
|
|
|
|
|
|
|
const gchar* part = strstr(output, "\"output_text\"");
|
|
|
|
|
if (part) {
|
|
|
|
|
gchar* text = _extract_json_string(part, "text");
|
|
|
|
|
/* 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
|
|
|
|
|
@@ -1883,7 +1931,7 @@ _parse_responses(const gchar* json)
|
|
|
|
|
if (!key)
|
|
|
|
|
break;
|
|
|
|
|
if (_same_json_object(key, part)) {
|
|
|
|
|
text = _extract_json_string(key, "text");
|
|
|
|
|
text = _extract_json_string_within(key, "text", part_end);
|
|
|
|
|
if (text)
|
|
|
|
|
return text;
|
|
|
|
|
}
|
|
|
|
|
@@ -1899,7 +1947,12 @@ _parse_responses(const gchar* json)
|
|
|
|
|
const gchar* content_arr = strstr(output, "\"content\"");
|
|
|
|
|
if (!content_arr)
|
|
|
|
|
return NULL;
|
|
|
|
|
return _extract_json_string(content_arr, "text");
|
|
|
|
|
/* 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
|
|
|
|
|
@@ -2066,6 +2119,9 @@ _ai_request_thread(gpointer data)
|
|
|
|
|
* 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++) {
|
|
|
|
|
@@ -2109,9 +2165,12 @@ _ai_request_thread(gpointer data)
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Connectivity failure (timeout/reset/DNS) on this flavour's endpoint:
|
|
|
|
|
* the other flavour may still be reachable, so try it before failing */
|
|
|
|
|
if (res != CURLE_OK && more) {
|
|
|
|
|
/* 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]));
|
|
|
|
|
@@ -2126,9 +2185,12 @@ _ai_request_thread(gpointer data)
|
|
|
|
|
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 unparseable 2xx for '%s', trying %s",
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@@ -2149,7 +2211,20 @@ _ai_request_thread(gpointer data)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (res != CURLE_OK) {
|
|
|
|
|
auto_gchar gchar* error_msg = g_strdup(curl_easy_strerror(res));
|
|
|
|
|
/* 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) {
|
|
|
|
|
@@ -2165,7 +2240,12 @@ _ai_request_thread(gpointer data)
|
|
|
|
|
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");
|
|
|
|
|
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 : "empty response body");
|
|
|
|
|
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 {
|
|
|
|
|
|