diff --git a/src/ai/ai_client.c b/src/ai/ai_client.c index 4ca329d9..8bd48767 100644 --- a/src/ai/ai_client.c +++ b/src/ai/ai_client.c @@ -186,11 +186,13 @@ ai_json_escape(const gchar* str) return g_string_free(result, FALSE); } -/* Keys emitted as fixed payload fields; custom settings must not duplicate them */ +/* 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, "stream") == 0; + 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 */ @@ -199,7 +201,16 @@ _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; - return g_regex_match_simple("^-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?$", value, 0, 0); + + /* 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); } /** @@ -392,6 +403,135 @@ _extract_json_string(const gchar* json, const gchar* field) return 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" 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")); +} + +/* 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; + + const gchar* err = strstr(body, "\"error\""); + if (err) { + 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"); + if (code || type || message) + return _names_model(code) || _names_model(type) || _names_model(message); + } + return _names_model(body); +} + +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 * ======================================================================== */ @@ -404,6 +544,9 @@ ai_provider_new(const gchar* name, const gchar* api_url) 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; @@ -519,6 +662,12 @@ ai_client_init(void) 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; @@ -573,9 +722,15 @@ _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); - return ai_provider_ref(existing); + 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) */ @@ -598,25 +753,18 @@ ai_add_provider(const gchar* name, const gchar* api_url) ai_client_init(); } - /* Check if provider already exists */ - AIProvider* existing = g_hash_table_lookup(providers, name); - if (existing) { - /* Update existing provider */ - g_free(existing->api_url); - existing->api_url = g_strdup(api_url); - /* Persist the updated URL to config */ - prefs_ai_set_provider(name, api_url); - log_info("Updated provider: %s", name); - return ai_provider_ref(existing); - } + gboolean existed = g_hash_table_lookup(providers, name) != NULL; - /* Persist the new provider to config */ + /* 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); - - /* Add to hash table (reuse internal helper) */ AIProvider* provider = _ai_add_provider_nopersist(name, api_url); - log_info("Added provider: %s (URL: %s)", 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 */ } @@ -747,6 +895,45 @@ ai_get_provider_default_model(const gchar* provider_name) 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) { @@ -812,6 +999,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 */ gpointer user_data; gchar* request_url; ai_response_handler_t handler; @@ -874,22 +1062,27 @@ static gpointer _ai_generic_request_thread(gpointer data) { ai_request_t* req = (ai_request_t*)data; - AIProvider* provider = ai_get_provider(req->provider_name); - if (!provider) { - log_error("Provider '%s' not found for request", req->provider_name); - g_free(req->provider_name); - g_free(req->request_url); - g_free(req); - return NULL; - } - /* Keep the provider alive for the duration of this request. Without this, /ai remove provider X could free the provider - * struct (via the hash table's destroy-notify) while curl_easy_perform is still using it — classic UAF. */ - ai_provider_ref(provider); + /* 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. */ + 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); + } 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); @@ -901,6 +1094,7 @@ _ai_generic_request_thread(gpointer data) 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); @@ -1247,19 +1441,11 @@ static gpointer _models_fetch_thread(gpointer data) { ai_request_t* req = (ai_request_t*)data; - AIProvider* provider = ai_get_provider(req->provider_name); - if (!provider) { - g_free(req->provider_name); - g_free(req); - return NULL; - } - /* Build URL for listing models */ - auto_gchar gchar* base_url = g_strdup_printf("%s%s", provider->api_url, - g_str_has_suffix(provider->api_url, "/") ? "" : "/"); - auto_gchar gchar* request_url = g_strdup_printf("%sv1/models", base_url); - - req->request_url = g_strdup(request_url); + /* req->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); @@ -1277,13 +1463,17 @@ ai_fetch_models(const gchar* provider_name, gpointer user_data) return FALSE; } - /* Always fetch fresh models from API */ + /* 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"); @@ -1464,28 +1654,20 @@ ai_session_switch(AISession* session, const gchar* provider_name, * ======================================================================== */ /** - * Build JSON payload from a GList of AIMessage nodes and a prompt string. - * This is the thread-safe variant used by _ai_request_thread() which holds - * the session lock while calling it. + * 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. * - * Custom provider settings are merged into the payload as additional JSON - * key-value pairs alongside "model" and "messages": scalar values - * (number/true/false/null) are emitted bare, anything else as a JSON string; - * reserved keys (model/messages/stream) are skipped. - * - * @param provider The AI provider (for custom settings) - * @param model The model identifier * @param history GList of AIMessage* (must be held under session lock) * @param prompt The new user prompt to append - * @return Newly allocated JSON string (caller must free) + * @return Newly allocated fragment (caller must free) */ static gchar* -_build_json_payload_from_list(AIProvider* provider, const gchar* model, GList* history, const gchar* prompt) +_serialize_message_list(GList* history, const gchar* prompt) { GString* messages_json = g_string_new(""); - GList* curr = history; - while (curr) { + 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); @@ -1495,8 +1677,6 @@ _build_json_payload_from_list(AIProvider* provider, const gchar* model, GList* h } g_string_append_printf(messages_json, "{\"role\":\"%s\",\"content\":\"%s\"}", escaped_role, escaped_content); - - curr = g_list_next(curr); } /* Add the new user message */ @@ -1506,11 +1686,24 @@ _build_json_payload_from_list(AIProvider* provider, const gchar* model, GList* h } g_string_append_printf(messages_json, "{\"role\":\"user\",\"content\":\"%s\"}", escaped_prompt); - auto_gchar gchar* escaped_model = ai_json_escape(model); + return g_string_free(messages_json, FALSE); +} - /* Build custom settings JSON fragment; settings is mutated on the main - * thread (/ai set custom), so iterate under settings_lock */ +/** + * 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) { @@ -1521,6 +1714,9 @@ _build_json_payload_from_list(AIProvider* provider, const gchar* model, GList* h 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, ','); @@ -1537,31 +1733,141 @@ _build_json_payload_from_list(AIProvider* provider, const gchar* model, GList* h pthread_mutex_unlock(&provider->settings_lock); } - /* Assemble final payload: model, messages, custom settings, then fixed keys */ - gchar* json_payload; - if (custom_json->len > 0) { - json_payload = g_strdup_printf( - "{\"model\":\"%s\",\"messages\":[%s],%s,\"stream\":false}", - escaped_model, messages_json->str, custom_json->str); - } else { - json_payload = g_strdup_printf( - "{\"model\":\"%s\",\"messages\":[%s],\"stream\":false}", - escaped_model, messages_json->str); + 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; + const gchar* message = strstr(choices, "\"message\""); + return _extract_json_string(message ? message : choices, "content"); +} + +/* TRUE when no object boundary ({ or }) lies between @from and @to outside of + * string literals — i.e. both positions belong to the same JSON object */ +static gboolean +_same_json_object(const gchar* from, const gchar* to) +{ + gboolean in_string = FALSE; + 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 == '{' || *p == '}') { + return FALSE; + } + } + return TRUE; +} + +/* 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) { + gchar* text = _extract_json_string(part, "text"); + 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(key, "text"); + if (text) + return text; + } + window = key - output; + } + return NULL; } - g_string_free(messages_json, TRUE); - g_string_free(custom_json, TRUE); - return json_payload; + /* No output_text tag (truncated reply, or a server tagging the part + * "type":"text"): anchor on the message "content" array so a reasoning + * "summary" text is not returned as the reply */ + const gchar* content_arr = strstr(output, "\"content\""); + return _extract_json_string(content_arr ? content_arr : output, "text"); +} + +/* 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) { - if (!response_json || strlen(response_json) == 0) - return NULL; - - /* OpenAI /v1/chat/completions: {"choices":[{"message":{"content":"..."}}]} */ - return _extract_json_string(response_json, "content"); + return ai_parse_response_typed(AI_API_TYPE_AUTO, response_json); } /* Extract the human-readable error.message from a provider error envelope: @@ -1636,10 +1942,11 @@ _ai_request_thread(gpointer data) return NULL; } - /* Build JSON payload from history + prompt (under lock), then add user message to history */ + /* 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* json_payload = _build_json_payload_from_list(local_provider, local_model, session->history, prompt); + 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); @@ -1648,57 +1955,122 @@ _ai_request_thread(gpointer data) session->history = g_list_append(session->history, user_msg); pthread_mutex_unlock(&session->lock); log_debug("[AI-THREAD] Added user message to history"); - log_debug("[AI-THREAD] JSON payload: %s", json_payload); + + 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 */ + /* Response buffer (allocated per attempt inside the loop) */ struct curl_response_t response; - response.data = g_new0(gchar, 1); + response.data = NULL; response.size = 0; - /* Build request URL */ - const gchar* api_url = local_provider->api_url; - auto_gchar gchar* request_url = g_strdup_printf("%s%sv1/chat/completions", api_url, g_str_has_suffix(api_url, "/") ? "" : "/"); - log_debug("[AI-THREAD] API URL: %s", api_url); - log_debug("[AI-THREAD] API Request URL: %s", request_url); + /* 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); - /* Set URL and execute request */ - curl_easy_setopt(curl, CURLOPT_URL, request_url); + /* 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_POSTFIELDS, json_payload); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L); - CURLcode res = curl_easy_perform(curl); - log_debug("[AI-THREAD] curl_easy_perform completed, res: %d", res); - - /* Get HTTP response code */ + CURLcode res = CURLE_OK; long http_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); - log_debug("[AI-THREAD] HTTP response code: %ld", http_code); + 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; + 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 (res == CURLE_OK && i + 1 < n_attempts && _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; + } + break; + } auto_gchar gchar* response_data = g_steal_pointer(&response.data); + if (configured == AI_API_TYPE_AUTO && res == CURLE_OK && _is_endpoint_missing(http_code, response_data)) { + /* The flavour we tried is no longer served; re-probe on the next request */ + _provider_set_resolved(local_provider, AI_API_TYPE_AUTO, api_epoch); + } + if (res != CURLE_OK) { auto_gchar gchar* error_msg = 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) { - /* Handle HTTP errors */ + /* Handle HTTP errors; a payload rejection that triggered the fallback + * is more actionable than the other endpoint's follow-up failure */ + const gchar* err_body = first_reject_body ? first_reject_body : response_data; + long err_code = first_reject_body ? 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(response_data); - auto_gchar gchar* error_msg = parsed_error ? g_strdup_printf("HTTP %ld: %s", http_code, parsed_error) : g_strdup_printf("HTTP %ld: %s", http_code, response_data && strlen(response_data) > 0 ? response_data : "Unknown error"); + 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"); 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); - auto_gchar gchar* content = ai_parse_response(response_data); + auto_gchar gchar* content = ai_parse_response_typed(used_type, 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); @@ -1708,6 +2080,10 @@ _ai_request_thread(gpointer data) 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."); diff --git a/src/ai/ai_client.h b/src/ai/ai_client.h index 7898dbfc..f29dddd5 100644 --- a/src/ai/ai_client.h +++ b/src/ai/ai_client.h @@ -12,6 +12,21 @@ typedef struct ai_message_t gchar* content; /* Message content */ } AIMessage; +/** + * @brief API flavour used to talk to a provider. + * + * AUTO tries /v1/responses first and falls back to /v1/chat/completions when + * the endpoint is missing (404/405/501) or rejects the payload shape + * (400/422). The working flavour is cached for the rest of the run as a + * first-attempt hint (the other flavour stays available as a fallback). The + * other two values pin the flavour explicitly. + */ +typedef enum { + AI_API_TYPE_AUTO = 0, + AI_API_TYPE_RESPONSES, + AI_API_TYPE_CHAT_COMPLETIONS, +} AIApiType; + /** * @brief AI provider configuration. */ @@ -21,7 +36,10 @@ typedef struct ai_provider_t gchar* api_url; /* API endpoint URL */ gchar* project_id; /* Optional project ID (for some providers) */ gchar* default_model; /* Default model for this provider */ - pthread_mutex_t settings_lock; /* Protects settings (iterated by the request thread) */ + gint api_type; /* Configured AIApiType (atomic access; persisted) */ + gint resolved_api_type; /* AIApiType detected via 404 fallback (atomic access; runtime cache) */ + gint api_epoch; /* Bumped on URL change (atomic); a stale epoch discards resolved_api_type stamps */ + pthread_mutex_t settings_lock; /* Protects settings and api_url (read by request threads) */ GHashTable* settings; /* Extensible per-provider settings (e.g., temperature=0.7) */ GList* models; /* Cached models (gchar*) */ gboolean models_fresh; /* Whether models cache is current */ @@ -67,7 +85,7 @@ AIProvider* ai_get_provider(const gchar* name); * Add or update a provider configuration. * @param name The provider name * @param api_url The API endpoint URL - * @return New AIProvider* (caller must unref when done) + * @return Borrowed AIProvider* owned by the provider table (do not unref) */ AIProvider* ai_add_provider(const gchar* name, const gchar* api_url); @@ -141,9 +159,39 @@ void ai_set_provider_default_model(const gchar* provider_name, const gchar* mode */ const gchar* ai_get_provider_default_model(const gchar* provider_name); +/** + * Set the API type for a provider. + * @param provider_name The provider name + * @param api_type "responses", "chat-completions", or "auto" (detect by probing) + * @return TRUE on success, FALSE on unknown provider or invalid type string + */ +gboolean ai_set_provider_api_type(const gchar* provider_name, const gchar* api_type); + +/** + * Get the configured API type for a provider. + * @param provider_name The provider name + * @return The configured AIApiType (AI_API_TYPE_AUTO for unknown providers) + */ +AIApiType ai_get_provider_api_type(const gchar* provider_name); + +/** + * Parse an API type string ("auto", "responses", "chat-completions"). + * @param str The string to parse + * @param type Output for the parsed value (untouched on failure) + * @return TRUE if str is a valid API type + */ +gboolean ai_api_type_from_string(const gchar* str, AIApiType* type); + +/** + * String form of an AIApiType, e.g. for display. + * @param type The API type + * @return Static string, never NULL + */ +const gchar* ai_api_type_to_string(AIApiType type); + /** * Set a custom setting for a provider. - * Reserved payload keys (model, messages, stream) are rejected. + * Reserved payload keys (model, messages, input, stream) are rejected. * @param provider_name The provider name * @param setting The setting key (e.g., "temperature") * @param value The setting value, or NULL to remove @@ -187,14 +235,25 @@ gboolean ai_models_are_fresh(const gchar* provider_name); void ai_parse_models_from_json(AIProvider* provider, const gchar* json); /** - * Extract assistant content from an OpenAI chat-completions response body - * ({"choices":[{"message":{"content":"..."}}]}). Decodes the \" and \n - * escape sequences only. + * Extract assistant content from a provider response body. Detects the + * flavour from the envelope: chat completions + * ({"choices":[{"message":{"content":"..."}}]}) or responses + * ({"output":[{"content":[{"type":"output_text","text":"..."}]}]}). * @param response_json The JSON body from the provider * @return Newly allocated content string, or NULL if not found (caller frees) */ gchar* ai_parse_response(const gchar* response_json); +/** + * Extract assistant content trying the known request flavour's extractor + * first, then the other flavour, then a bare top-level "content" string. + * ai_parse_response is equivalent to passing AI_API_TYPE_AUTO. + * @param api_type The API flavour the response was requested with + * @param response_json The JSON body from the provider + * @return Newly allocated content string, or NULL if not found (caller frees) + */ +gchar* ai_parse_response_typed(AIApiType api_type, const gchar* response_json); + /** * Extract human-readable error message from an API error envelope. * Expected format: {"error":{"message":"...","type":"...","code":...}} diff --git a/src/command/cmd_ac.c b/src/command/cmd_ac.c index 99c2ac36..3bcb2022 100644 --- a/src/command/cmd_ac.c +++ b/src/command/cmd_ac.c @@ -264,6 +264,7 @@ static Autocomplete avatar_ac; static Autocomplete ai_subcommands_ac; static Autocomplete ai_set_subcommands_ac; static Autocomplete ai_set_custom_subcommands_ac; +static Autocomplete ai_api_types_ac; static Autocomplete ai_remove_subcommands_ac; static Autocomplete url_ac; static Autocomplete executable_ac; @@ -453,6 +454,7 @@ static Autocomplete* all_acs[] = { &ai_subcommands_ac, &ai_set_subcommands_ac, &ai_set_custom_subcommands_ac, + &ai_api_types_ac, &ai_remove_subcommands_ac, &ai_models_ac }; @@ -1183,8 +1185,13 @@ cmd_ac_init(void) autocomplete_add_unsorted(ai_set_subcommands_ac, "provider", FALSE); autocomplete_add_unsorted(ai_set_subcommands_ac, "token", FALSE); autocomplete_add_unsorted(ai_set_subcommands_ac, "default-model", FALSE); + autocomplete_add_unsorted(ai_set_subcommands_ac, "api-type", FALSE); autocomplete_add_unsorted(ai_set_subcommands_ac, "custom", FALSE); + autocomplete_add_unsorted(ai_api_types_ac, "chat-completions", FALSE); + autocomplete_add_unsorted(ai_api_types_ac, "responses", FALSE); + autocomplete_add_unsorted(ai_api_types_ac, "auto", FALSE); + autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "tools", FALSE); autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "search", FALSE); autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "memory", FALSE); @@ -4532,6 +4539,12 @@ _force_encryption_autocomplete(ProfWin* window, const char* const input, gboolea static char* _ai_provider_and_model_autocomplete(ProfWin* window, const char* const input, gboolean previous, const char* cmd); +static char* +_ai_api_type_find(const char* const search_str, gboolean previous, void* context) +{ + return autocomplete_complete(ai_api_types_ac, search_str, FALSE, previous); +} + static char* _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous) { @@ -4560,6 +4573,18 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous) return result; } + // /ai set api-type - autocomplete the type value + result = autocomplete_param_no_with_func(input, "/ai set api-type", 5, _ai_api_type_find, previous, NULL); + if (result) { + return result; + } + + // /ai set api-type - autocomplete provider names + result = autocomplete_param_with_func(input, "/ai set api-type", ai_providers_find, previous, NULL); + if (result) { + return result; + } + // /ai set custom - autocomplete provider names result = autocomplete_param_with_func(input, "/ai set custom", ai_providers_find, previous, NULL); if (result) { diff --git a/src/command/cmd_defs.c b/src/command/cmd_defs.c index 795d0547..304b4b8f 100644 --- a/src/command/cmd_defs.c +++ b/src/command/cmd_defs.c @@ -2815,6 +2815,7 @@ static const struct cmd_t command_defs[] = { "/ai set provider ", "/ai set token ", "/ai set default-model ", + "/ai set api-type chat-completions|responses|auto", "/ai set custom ", "/ai remove provider ", "/ai providers", @@ -2830,9 +2831,10 @@ static const struct cmd_t command_defs[] = { "Chat history is maintained per session and not persisted locally.") CMD_ARGS( { "", "Display current AI settings and configured providers" }, - { "set provider ", "Add or update a provider with custom API endpoint" }, + { "set provider ", "Add or update a provider with a custom API base URL (endpoint paths are appended automatically)" }, { "set token ", "Set API token for a provider (e.g., openai, perplexity)" }, { "set default-model ", "Set default model for a provider" }, + { "set api-type chat-completions|responses|auto", "Pin the provider API flavour; auto tries responses and falls back to chat completions when the endpoint is missing" }, { "set custom ", "Set provider-level setting (e.g., temperature, max_tokens)" }, { "remove provider ", "Remove a custom provider" }, { "providers", "List configured providers with full details" }, @@ -2844,8 +2846,9 @@ static const struct cmd_t command_defs[] = { "/ai", "/ai set token openai sk-xxx", "/ai set token perplexity pplx-xxx", - "/ai set provider custom https://my-api.com/v1/chat/completions", + "/ai set provider custom https://my-api.com/", "/ai set default-model perplexity sonar", + "/ai set api-type perplexity responses", "/ai set custom perplexity temperature 0.7", "/ai remove provider custom", "/ai start", diff --git a/src/command/cmd_funcs.c b/src/command/cmd_funcs.c index d9293e66..898925d3 100644 --- a/src/command/cmd_funcs.c +++ b/src/command/cmd_funcs.c @@ -10703,6 +10703,19 @@ cmd_vcard_save(ProfWin* window, const char* const command, gchar** args) return TRUE; } +/* Show the provider's API flavour: pinned, or auto with the detected one */ +static void +_cons_show_ai_api_type(AIProvider* provider) +{ + AIApiType api_type = g_atomic_int_get(&provider->api_type); + AIApiType resolved = g_atomic_int_get(&provider->resolved_api_type); + if (api_type != AI_API_TYPE_AUTO) { + cons_show(" API type: %s", ai_api_type_to_string(api_type)); + } else if (resolved != AI_API_TYPE_AUTO) { + cons_show(" API type: auto (detected: %s)", ai_api_type_to_string(resolved)); + } +} + gboolean cmd_ai(ProfWin* window, const char* const command, gchar** args) { @@ -10724,6 +10737,7 @@ cmd_ai(ProfWin* window, const char* const command, gchar** args) if (default_model) { cons_show(" Default model: %s", default_model); } + _cons_show_ai_api_type(provider); if (provider->models && g_list_length(provider->models) > 0) { cons_show(" Cached models: %d", g_list_length(provider->models)); } @@ -10781,6 +10795,19 @@ cmd_ai_set(ProfWin* window, const char* const command, gchar** args) cons_show("Default model for provider '%s' set to: %s", args[2], args[3]); cons_show(""); return TRUE; + } else if (g_strcmp0(args[1], "api-type") == 0) { + // /ai set api-type chat-completions|responses|auto + if (g_strv_length(args) < 4) { + cons_bad_cmd_usage(command); + return TRUE; + } + if (ai_set_provider_api_type(args[2], args[3])) { + cons_show("API type for provider '%s' set to: %s", args[2], args[3]); + } else { + cons_show_error("Cannot set API type for provider '%s': unknown provider or invalid type (use chat-completions, responses, or auto).", args[2]); + } + cons_show(""); + return TRUE; } else if (g_strcmp0(args[1], "custom") == 0) { // /ai set custom if (g_strv_length(args) < 5) { @@ -10790,7 +10817,7 @@ cmd_ai_set(ProfWin* window, const char* const command, gchar** args) if (ai_set_provider_setting(args[2], args[3], args[4])) { cons_show("Setting '%s' for provider '%s' set to: %s", args[3], args[2], args[4]); } else { - cons_show_error("Cannot set '%s' for provider '%s': unknown provider or reserved key (model, messages, stream).", args[3], args[2]); + cons_show_error("Cannot set '%s' for provider '%s': unknown provider or reserved key (model, messages, input, stream).", args[3], args[2]); } cons_show(""); return TRUE; @@ -11105,6 +11132,7 @@ cmd_ai_providers(ProfWin* window, const char* const command, gchar** args) cons_show(" %s", provider->name); cons_show(" URL: %s", provider->api_url); cons_show(" Key: %s", key ? "configured" : "NOT configured"); + _cons_show_ai_api_type(provider); cons_show(""); } g_list_free(providers); diff --git a/src/config/preferences.c b/src/config/preferences.c index 04d3dda5..708386c0 100644 --- a/src/config/preferences.c +++ b/src/config/preferences.c @@ -2002,6 +2002,30 @@ prefs_ai_get_default_model(const char* const provider) return _ai_get_string(provider, "default_model"); } +/* --- API type --- */ + +gboolean +prefs_ai_set_api_type(const char* const provider, const char* const api_type) +{ + if (!provider || !api_type || !prefs) + return FALSE; + if (api_type[0] == '\0') + return prefs_ai_remove_api_type(provider); + return _ai_set_string(provider, "api_type", api_type); +} + +gboolean +prefs_ai_remove_api_type(const char* const provider) +{ + return _ai_remove_string(provider, "api_type"); +} + +char* +prefs_ai_get_api_type(const char* const provider) +{ + return _ai_get_string(provider, "api_type"); +} + /* --- Provider URL --- */ gboolean diff --git a/src/config/preferences.h b/src/config/preferences.h index 4e422a05..3631d311 100644 --- a/src/config/preferences.h +++ b/src/config/preferences.h @@ -368,6 +368,11 @@ gboolean prefs_ai_set_default_model(const char* const provider, const char* cons gboolean prefs_ai_remove_default_model(const char* const provider); char* prefs_ai_get_default_model(const char* const provider); +/* AI API type per provider ("responses" or "chat-completions") */ +gboolean prefs_ai_set_api_type(const char* const provider, const char* const api_type); +gboolean prefs_ai_remove_api_type(const char* const provider); +char* prefs_ai_get_api_type(const char* const provider); + /* AI per-provider custom settings (e.g. tools, search) — persisted as * setting.= inside [ai/]. Pass NULL value to remove. */ gboolean prefs_ai_set_setting(const char* const provider, const char* const setting, const char* const value); diff --git a/tests/unittests/test_ai_client.c b/tests/unittests/test_ai_client.c index 516fcb7f..aa6205b4 100644 --- a/tests/unittests/test_ai_client.c +++ b/tests/unittests/test_ai_client.c @@ -62,12 +62,10 @@ test_ai_add_provider(void** state) assert_string_equal("custom", provider->name); assert_string_equal("https://custom.api.com/v1", provider->api_url); - /* Update existing provider (returns ref; caller owns it) */ + /* Update existing provider (borrowed pointer, same as the create path) */ AIProvider* updated = ai_add_provider("custom", "https://new.api.com/v1"); assert_non_null(updated); assert_string_equal("https://new.api.com/v1", updated->api_url); - - ai_provider_unref(updated); } void @@ -329,11 +327,10 @@ test_ai_add_provider_update_existing(void** state) assert_non_null(provider); assert_string_equal("https://first.api.com/v1", provider->api_url); - /* Update the same provider (returns ref) */ + /* Update the same provider (borrowed pointer) */ provider = ai_add_provider("custom", "https://second.api.com/v1"); assert_non_null(provider); assert_string_equal("https://second.api.com/v1", provider->api_url); - ai_provider_unref(provider); } void @@ -622,9 +619,17 @@ test_ai_set_provider_setting_reserved_key(void** state) /* Reserved payload keys are rejected (would duplicate fixed fields) */ assert_false(ai_set_provider_setting("p_reserved_keys", "model", "x")); assert_false(ai_set_provider_setting("p_reserved_keys", "messages", "[]")); + assert_false(ai_set_provider_setting("p_reserved_keys", "input", "[]")); assert_false(ai_set_provider_setting("p_reserved_keys", "stream", "true")); assert_null(ai_get_provider_setting("p_reserved_keys", "model")); + /* "store" is a legitimate chat-completions parameter and overrides the + * responses-flavour "store":false default, so it stays settable */ + assert_true(ai_set_provider_setting("p_reserved_keys", "store", "true")); + auto_gchar gchar* store = ai_get_provider_setting("p_reserved_keys", "store"); + assert_non_null(store); + assert_string_equal("true", store); + /* Ordinary keys still work */ assert_true(ai_set_provider_setting("p_reserved_keys", "temperature", "0.7")); auto_gchar gchar* temp = ai_get_provider_setting("p_reserved_keys", "temperature"); @@ -632,6 +637,58 @@ test_ai_set_provider_setting_reserved_key(void** state) assert_string_equal("0.7", temp); } +void +test_ai_set_provider_api_type(void** state) +{ + ai_add_provider("p_api_type", "https://example.test/at/"); + + /* Default is auto */ + assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("p_api_type")); + + /* Pin each flavour explicitly */ + assert_true(ai_set_provider_api_type("p_api_type", "chat-completions")); + assert_int_equal(AI_API_TYPE_CHAT_COMPLETIONS, ai_get_provider_api_type("p_api_type")); + + assert_true(ai_set_provider_api_type("p_api_type", "responses")); + assert_int_equal(AI_API_TYPE_RESPONSES, ai_get_provider_api_type("p_api_type")); + + /* auto resets to detection */ + assert_true(ai_set_provider_api_type("p_api_type", "auto")); + assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("p_api_type")); + + /* Invalid values and unknown providers are rejected */ + assert_false(ai_set_provider_api_type("p_api_type", "v2")); + assert_false(ai_set_provider_api_type("p_api_type", NULL)); + assert_false(ai_set_provider_api_type("nonexistent", "responses")); + assert_false(ai_set_provider_api_type(NULL, "responses")); + + /* Unknown provider reads as auto */ + assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("nonexistent")); + assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type(NULL)); +} + +void +test_ai_api_type_string_round_trip(void** state) +{ + AIApiType type = AI_API_TYPE_AUTO; + + assert_true(ai_api_type_from_string("responses", &type)); + assert_int_equal(AI_API_TYPE_RESPONSES, type); + assert_string_equal("responses", ai_api_type_to_string(type)); + + assert_true(ai_api_type_from_string("chat-completions", &type)); + assert_int_equal(AI_API_TYPE_CHAT_COMPLETIONS, type); + assert_string_equal("chat-completions", ai_api_type_to_string(type)); + + assert_true(ai_api_type_from_string("auto", &type)); + assert_int_equal(AI_API_TYPE_AUTO, type); + assert_string_equal("auto", ai_api_type_to_string(type)); + + assert_false(ai_api_type_from_string("completions", &type)); + assert_false(ai_api_type_from_string("", &type)); + assert_false(ai_api_type_from_string(NULL, &type)); +} + void test_ai_get_provider_setting(void** state) { @@ -937,12 +994,78 @@ test_ai_parse_response_openai_content(void** state) } void -test_ai_parse_response_legacy_text_format_unsupported(void** state) +test_ai_parse_response_responses_output_text(void** state) { - /* Legacy Perplexity /v1/agent format: "content" is an array, not a string. - * Dropped with the chat-completions alignment, so parsing yields NULL. */ + /* Responses API: "content" is an array of typed parts; the inner "text" + * of the output_text part is the assistant message. */ const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"Search result\",\"type\":\"output_text\"}]}]}"; - assert_null(ai_parse_response(json)); + auto_gchar gchar* out = ai_parse_response(json); + assert_non_null(out); + assert_string_equal("Search result", out); +} + +void +test_ai_parse_response_responses_full_envelope(void** state) +{ + /* Realistic responses body: a reasoning item precedes the message item; + * the parser must still land on the message's output_text. */ + const gchar* json = "{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"completed\"," + "\"output\":[" + "{\"id\":\"rs_1\",\"type\":\"reasoning\",\"summary\":[]}," + "{\"id\":\"msg_1\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\"," + "\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"text\":\"Hello!\"}]}" + "]}"; + auto_gchar gchar* out = ai_parse_response(json); + assert_non_null(out); + assert_string_equal("Hello!", out); +} + +void +test_ai_parse_response_choices_win_over_output(void** state) +{ + /* A chat-completions envelope is authoritative even if the content + * happens to mention "output". */ + const gchar* json = "{\"choices\":[{\"message\":{\"content\":\"chat result\"}}],\"output\":[{\"content\":[{\"text\":\"wrong\"}]}]}"; + auto_gchar gchar* out = ai_parse_response(json); + assert_non_null(out); + assert_string_equal("chat result", out); +} + +void +test_ai_parse_response_responses_reasoning_summary_skipped(void** state) +{ + /* A reasoning item with a non-empty summary precedes the message item; + * the summary's "text" must not be mistaken for the assistant reply. */ + const gchar* json = "{\"output\":[" + "{\"id\":\"rs_1\",\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"User asks X\"}]}," + "{\"id\":\"msg_1\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\"," + "\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"text\":\"Actual answer\"}]}" + "]}"; + auto_gchar gchar* out = ai_parse_response(json); + assert_non_null(out); + assert_string_equal("Actual answer", out); +} + +void +test_ai_parse_response_choices_in_string_value(void** state) +{ + /* "choices" occurring as a string value must not hijack a valid + * responses envelope. */ + const gchar* json = "{\"kind\":\"choices\",\"output\":[{\"content\":[{\"type\":\"output_text\",\"text\":\"hi\"}]}]}"; + auto_gchar gchar* out = ai_parse_response(json); + assert_non_null(out); + assert_string_equal("hi", out); +} + +void +test_ai_parse_response_content_before_choices(void** state) +{ + /* Nonstandard body: a top-level content string precedes an empty + * choices array; the bare-content fallback must still find it. */ + const gchar* json = "{\"content\":\"hi\",\"choices\":[]}"; + auto_gchar gchar* out = ai_parse_response(json); + assert_non_null(out); + assert_string_equal("hi", out); } void @@ -955,6 +1078,54 @@ test_ai_parse_response_content_preferred_over_text(void** state) assert_string_equal("from content field", out); } +void +test_ai_parse_response_responses_brace_in_text(void** state) +{ + /* Responses part with "text" before "type":"output_text" and a '{' inside + * the answer: the nearest preceding "text" key must be extracted, not a + * brace inside the string value. */ + const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"use { here\",\"type\":\"output_text\"}]}]}"; + auto_gchar gchar* out = ai_parse_response(json); + assert_non_null(out); + assert_string_equal("use { here", out); +} + +void +test_ai_parse_response_choices_logprobs_before_content(void** state) +{ + /* A non-string "content" (logprobs array) ordered before message.content + * must not defeat extraction; anchoring on "message" finds the reply. */ + const gchar* json = "{\"choices\":[{\"logprobs\":{\"content\":[{\"token\":\"hi\"}]}," + "\"message\":{\"role\":\"assistant\",\"content\":\"real answer\"}}]}"; + auto_gchar gchar* out = ai_parse_response(json); + assert_non_null(out); + assert_string_equal("real answer", out); +} + +void +test_ai_parse_response_reasoning_not_leaked_without_text(void** state) +{ + /* An output_text part with no "text" at all (truncated body) must yield a + * parse failure, not leak the preceding reasoning summary's text. */ + const gchar* json = "{\"output\":[" + "{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"secret\"}]}," + "{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"annotations\":[]}]}" + "]}"; + gchar* out = ai_parse_response(json); + assert_null(out); +} + +void +test_ai_parse_response_responses_text_value_text(void** state) +{ + /* A reply that is exactly the string "text" must not confuse the + * backscan (the value's own quoted bytes match the key needle). */ + const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"text\",\"type\":\"output_text\"}]}]}"; + auto_gchar gchar* out = ai_parse_response(json); + assert_non_null(out); + assert_string_equal("text", out); +} + void test_ai_parse_response_escaped_quote(void** state) { @@ -1492,6 +1663,26 @@ test_ai_prefs_round_trip_remove_key(void** state) assert_null(after); } +void +test_ai_prefs_round_trip_api_type(void** state) +{ + ai_add_provider("p_persist_api_type", "https://example.test/pat/"); + assert_true(ai_set_provider_api_type("p_persist_api_type", "chat-completions")); + + ai_client_shutdown(); + ai_client_init(); + + assert_int_equal(AI_API_TYPE_CHAT_COMPLETIONS, ai_get_provider_api_type("p_persist_api_type")); + + /* Setting auto removes the persisted value */ + assert_true(ai_set_provider_api_type("p_persist_api_type", "auto")); + + ai_client_shutdown(); + ai_client_init(); + + assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("p_persist_api_type")); +} + void test_ai_prefs_multiple_providers_persist(void** state) { diff --git a/tests/unittests/test_ai_client.h b/tests/unittests/test_ai_client.h index 6bf2062e..6b274475 100644 --- a/tests/unittests/test_ai_client.h +++ b/tests/unittests/test_ai_client.h @@ -44,6 +44,8 @@ void test_ai_set_provider_default_model(void** state); void test_ai_get_provider_default_model(void** state); void test_ai_set_provider_setting(void** state); void test_ai_set_provider_setting_reserved_key(void** state); +void test_ai_set_provider_api_type(void** state); +void test_ai_api_type_string_round_trip(void** state); void test_ai_get_provider_setting(void** state); /* Model caching tests */ void test_ai_models_are_fresh_initial(void** state); @@ -65,8 +67,17 @@ int ai_client_teardown_with_prefs(void** state); /* Chat response parser tests (ai_parse_response) */ void test_ai_parse_response_openai_content(void** state); -void test_ai_parse_response_legacy_text_format_unsupported(void** state); +void test_ai_parse_response_responses_output_text(void** state); +void test_ai_parse_response_responses_full_envelope(void** state); +void test_ai_parse_response_responses_reasoning_summary_skipped(void** state); +void test_ai_parse_response_choices_win_over_output(void** state); +void test_ai_parse_response_choices_in_string_value(void** state); +void test_ai_parse_response_content_before_choices(void** state); void test_ai_parse_response_content_preferred_over_text(void** state); +void test_ai_parse_response_responses_brace_in_text(void** state); +void test_ai_parse_response_choices_logprobs_before_content(void** state); +void test_ai_parse_response_reasoning_not_leaked_without_text(void** state); +void test_ai_parse_response_responses_text_value_text(void** state); void test_ai_parse_response_escaped_quote(void** state); void test_ai_parse_response_newline_escape(void** state); void test_ai_parse_response_empty_content(void** state); @@ -129,6 +140,7 @@ void test_ai_parse_models_multiple_models(void** state); /* Prefs round-trip tests */ void test_ai_prefs_round_trip_api_key(void** state); void test_ai_prefs_round_trip_remove_key(void** state); +void test_ai_prefs_round_trip_api_type(void** state); void test_ai_prefs_multiple_providers_persist(void** state); /* Autocomplete deeper coverage */ diff --git a/tests/unittests/unittests.c b/tests/unittests/unittests.c index cf01788e..523a2321 100644 --- a/tests/unittests/unittests.c +++ b/tests/unittests/unittests.c @@ -753,6 +753,8 @@ main(int argc, char* argv[]) cmocka_unit_test_setup_teardown(test_ai_get_provider_default_model, ai_client_setup, ai_client_teardown), cmocka_unit_test_setup_teardown(test_ai_set_provider_setting, ai_client_setup, ai_client_teardown), cmocka_unit_test_setup_teardown(test_ai_set_provider_setting_reserved_key, ai_client_setup, ai_client_teardown), + cmocka_unit_test_setup_teardown(test_ai_set_provider_api_type, ai_client_setup, ai_client_teardown), + cmocka_unit_test(test_ai_api_type_string_round_trip), cmocka_unit_test_setup_teardown(test_ai_get_provider_setting, ai_client_setup, ai_client_teardown), /* Model caching tests */ cmocka_unit_test_setup_teardown(test_ai_models_are_fresh_initial, ai_client_setup, ai_client_teardown), @@ -775,8 +777,17 @@ main(int argc, char* argv[]) cmocka_unit_test(test_ai_json_escape_backslash_quote), /* Chat response parser */ cmocka_unit_test(test_ai_parse_response_openai_content), - cmocka_unit_test(test_ai_parse_response_legacy_text_format_unsupported), + cmocka_unit_test(test_ai_parse_response_responses_output_text), + cmocka_unit_test(test_ai_parse_response_responses_full_envelope), + cmocka_unit_test(test_ai_parse_response_responses_reasoning_summary_skipped), + cmocka_unit_test(test_ai_parse_response_choices_win_over_output), + cmocka_unit_test(test_ai_parse_response_choices_in_string_value), + cmocka_unit_test(test_ai_parse_response_content_before_choices), cmocka_unit_test(test_ai_parse_response_content_preferred_over_text), + cmocka_unit_test(test_ai_parse_response_responses_brace_in_text), + cmocka_unit_test(test_ai_parse_response_choices_logprobs_before_content), + cmocka_unit_test(test_ai_parse_response_reasoning_not_leaked_without_text), + cmocka_unit_test(test_ai_parse_response_responses_text_value_text), cmocka_unit_test(test_ai_parse_response_escaped_quote), cmocka_unit_test(test_ai_parse_response_newline_escape), cmocka_unit_test(test_ai_parse_response_empty_content), @@ -831,6 +842,7 @@ main(int argc, char* argv[]) /* Prefs round-trip (uses prefs+ai setup) */ cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_api_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs), cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_remove_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs), + cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_api_type, ai_client_setup_with_prefs, ai_client_teardown_with_prefs), cmocka_unit_test_setup_teardown(test_ai_prefs_multiple_providers_persist, ai_client_setup_with_prefs, ai_client_teardown_with_prefs), /* Autocomplete deeper coverage */ cmocka_unit_test_setup_teardown(test_ai_providers_find_after_remove_skips_removed, ai_client_setup, ai_client_teardown),