Compare commits

...

4 Commits

Author SHA1 Message Date
3f430e01d2 fix(ai): bound response parsing and harden AUTO fallback errors
Some checks failed
CI Code / Check spelling (pull_request) Successful in 16s
CI Code / Check coding style (pull_request) Failing after 27s
CI Code / Linux (debian) (pull_request) Successful in 4m57s
CI Code / Linux (ubuntu) (pull_request) Successful in 5m13s
CI Code / Code Coverage (pull_request) Successful in 3m10s
CI Code / Linux (arch) (pull_request) Successful in 6m36s
- bound _parse_responses scans to the output_text part's object and to
  the content array, so a later sibling item's "text" (e.g. a reasoning
  summary) can never be returned as the assistant reply
- do not retry the other flavour on a curl timeout: the request likely
  reached the server and may still be generating, so a re-POST of the
  conversation could trigger a second billed generation
- remember an unparseable 2xx from the first AUTO attempt and surface
  stashed first-attempt errors in both error paths, instead of showing
  only the final attempt's transport or HTTP error
- recognize Ollama's model-not-found wording in _names_model so a model
  typo is not misread as a missing endpoint
- drop the dead, racy provider-lookup fallback in the generic request
  thread: a missing provider ref is a caller bug and now fails loudly
- fix ai_providers_lists_defaults to expect the header the command
  actually prints ("Configured providers:"); the test was broken since
  its introduction but CI never ran it
- add functional test group 5 (AI command surface) to FUNC_TEST_GROUPS
  so the CI parallel target runs it; proftest.c port ranges already
  account for five groups
2026-07-10 12:55:09 +03:00
3a96d0dba1 fix(ai): close parser and AUTO fallback gaps
Parser:
- Never return reasoning text as the reply: the no-output_text path
  anchors strictly on the message "content" array; a reasoning-only body
  (truncated by max_output_tokens, carries only "summary") now fails the
  parse instead of leaking the chain-of-thought. Regression test added.
- _find_json_field skips field-name occurrences that are string values
  (no ':' after), so {"type":"text","text":"hi"} parses instead of
  failing on the value of "type".
- _same_json_object tracks brace depth, so a nested object between the
  "text" key and the "output_text" tag (e.g. "annotations") no longer
  rejects the part's own text.
- Chat-completions extraction is bounded to the choices array (new
  _json_array_end helper), so "message"/"content" in a sibling object
  (e.g. a top-level "warning") is not returned as the reply.

AUTO fallback:
- Model errors are classified only from a structured JSON error
  envelope; a route-missing 404 page merely mentioning models (e.g.
  listing /v1/models) no longer suppresses the fallback. Trade-off: a
  model error in a non-JSON body costs one extra fallback attempt.
- Fall back on a curl-level failure: an endpoint that accepts the
  connection but never responds no longer burns the 60s timeout without
  trying the flavour that works.
- Fall back on an unparseable 2xx: a gateway answering unknown paths
  with a 200 error page previously reset the hint and re-probed the same
  dead flavour on every request. The body is parsed inside the attempt
  loop and no longer re-parsed after it.
- The second attempt's 401/403/429/5xx now wins over the stashed 400/422
  payload rejection, so an invalid key or rate limit is not hidden
  behind a payload-shape error; a second 404 still defers to the first.
- When the first flavour's endpoint is missing but the other answers
  (even unsuccessfully, e.g. 401), the resolved hint moves to the
  surviving flavour, so the dead endpoint is not probed first forever.

Settings:
- Reserved payload keys are rejected only when setting a value; removal
  is allowed again, so a setting persisted before its key became
  reserved (e.g. "input") is no longer stuck in the config.
2026-07-08 20:44:02 +03:00
b3a269342d fix(ai): repair custom setting name autocompletion
All checks were successful
CI Code / Check spelling (pull_request) Successful in 13s
CI Code / Check coding style (pull_request) Successful in 23s
CI Code / Code Coverage (pull_request) Successful in 3m45s
CI Code / Linux (ubuntu) (pull_request) Successful in 5m24s
CI Code / Linux (arch) (pull_request) Successful in 7m7s
CI Code / Linux (debian) (pull_request) Successful in 8m41s
The setting-name branch for /ai set custom never fired: it passed the
prefix "/ai set custom " with a trailing space to
autocomplete_param_with_ac(), which appends a space itself, so the
matched prefix contained a double space no real input ever has. Even
without that, the primitive completes the first token after the command
while the setting name is the second, and the num_args guard only held
before the name was started. The suggestion list (tools/search/memory/
plugins) predated the /ai set custom backend and matched no real
payload parameter.

Replace the branch with token-position completion (arg 5 via
autocomplete_param_no_with_func) fed by a list rebuilt on demand from
common payload parameters plus the keys already set on the provider,
exposed through the new ai_get_provider_setting_keys() (snapshot taken
under settings_lock). Drop the now-unused parse_args() call whose NULL
result was fed to g_strv_length() unguarded, spamming a glib CRITICAL
on TAB at /ai with zero or five-plus arguments.
2026-07-06 16:28:36 +03:00
14f76ab1db feat(ai): support both responses and chat completions APIs
Drive both OpenAI-compatible flavours from one code path. Requests
default to /v1/responses and fall back once to /v1/chat/completions when
the provider reports the endpoint 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, with the other flavour kept as a
fallback so a backend change self-corrects in-request. A per-provider
override is available via
  /ai set api-type <provider> responses|chat-completions|auto
and persisted as api_type= in the provider section.

One payload builder serves both flavours (messages/stream vs
input/stream/store); history and custom settings are serialized once per
request and the per-flavour envelope is assembled per attempt under a
single lock acquisition, so the fallback retry cannot double-count the
prompt. ai_parse_response_typed() dispatches on the request flavour and
splits extraction per envelope: chat completions anchored on
choices[].message.content, responses on the output_text part with a
string-aware backward scan bounded to that part's own object so a
reasoning summary or a truncated body is never returned as the reply.

Endpoint detection classifies wrong-model errors from the structured
error code/type/message when present, so route-missing 404s from
gateways no longer suppress the fallback; it preserves and surfaces the
first payload-rejection error when the fallback also fails, and re-probes
on an unparseable 2xx. resolved_api_type is written only through a locked
helper guarded by a URL/api-type epoch, and api_url is snapshotted under
settings_lock in the request and models-fetch threads, closing
use-after-free and stale-cache races against /ai set provider. The
models-fetch provider ref is taken on the main thread and released on
every worker exit path. Reserved custom-setting keys are extended with
input (store stays writable as a legitimate chat-completions parameter).
2026-07-06 16:28:36 +03:00
12 changed files with 1224 additions and 173 deletions

View File

@@ -335,7 +335,7 @@ tests_functionaltests_functionaltests_LDADD = -lcmocka -lstabber @FORKPTY_LIB@
# Parallel functional tests target (~3x faster than sequential)
# Usage: make check-functional-parallel
# To add more groups: increase FUNC_TEST_GROUPS and add group in functionaltests.c
FUNC_TEST_GROUPS = 1 2 3 4
FUNC_TEST_GROUPS = 1 2 3 4 5
check-functional-parallel: tests/functionaltests/functionaltests
@echo "Running functional tests in parallel ($(words $(FUNC_TEST_GROUPS)) groups)..."

File diff suppressed because it is too large Load Diff

View File

@@ -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
@@ -159,6 +207,14 @@ gboolean ai_set_provider_setting(const gchar* provider_name, const gchar* settin
*/
gchar* ai_get_provider_setting(const gchar* provider_name, const gchar* setting);
/**
* List the custom setting keys currently set on a provider.
* @param provider_name The provider name
* @return GList of gchar* (free with g_list_free_full(keys, g_free)),
* or NULL if the provider is unknown or has no settings
*/
GList* ai_get_provider_setting_keys(const gchar* provider_name);
/**
* Fetch available models from a provider's API.
* @param provider_name The provider name
@@ -187,14 +243,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":...}}

View File

@@ -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,12 +1185,12 @@ 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_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);
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "plugins", 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_remove_subcommands_ac, "provider", FALSE);
@@ -4532,17 +4534,67 @@ _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_custom_setting_find(const char* const search_str, gboolean previous, void* context)
{
return autocomplete_complete(ai_set_custom_subcommands_ac, search_str, FALSE, previous);
}
/**
* Autocomplete the setting name for /ai set custom <provider> <setting>.
* Offers a few common payload parameters merged with the keys already set
* on the provider, so existing settings can be retyped or overridden.
*/
static char*
_ai_custom_setting_autocomplete(const char* const input, gboolean previous)
{
if (!g_str_has_prefix(input, "/ai set custom ")) {
return NULL;
}
/* Quote-aware parse: args[2] is the provider, args[3] the setting being
* typed; skip the rebuild when the caret is not at the setting position */
gboolean parse_result = FALSE;
auto_gcharv gchar** args = parse_args(input, 1, 5, &parse_result);
if (!parse_result) {
return NULL;
}
guint num_args = g_strv_length(args);
if (num_args < 3 || num_args > 4 || !ai_get_provider(args[2])) {
return NULL;
}
/* Rebuild the completion list: common parameters + the provider's keys */
gchar* suggestions[] = { "temperature", "max_tokens", "top_p" };
GList* keys = ai_get_provider_setting_keys(args[2]);
char** items = g_new0(char*, ARRAY_SIZE(suggestions) + g_list_length(keys) + 1);
guint i = 0;
for (; i < ARRAY_SIZE(suggestions); i++) {
items[i] = suggestions[i];
}
for (GList* curr = keys; curr; curr = g_list_next(curr)) {
items[i++] = curr->data;
}
autocomplete_update(ai_set_custom_subcommands_ac, items);
g_free(items);
g_list_free_full(keys, g_free);
/* /ai(1) set(2) custom(3) <provider>(4) <setting>(5) */
return autocomplete_param_no_with_func(input, "/ai set custom", 5, _ai_custom_setting_find, previous, NULL);
}
static char*
_ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
{
char* result = NULL;
/* Parse once for reuse - /ai <subcommand> [<arg1>] [<arg2>] */
gboolean parse_result = FALSE;
auto_gcharv gchar** args = parse_args(input, 1, 4, &parse_result);
int num_args = g_strv_length(args);
/* Top-level /ai <subcommand> autocomplete (e.g., /ai s<tab> -> /ai set) */
// /ai set provider <name> <url> - autocomplete provider names (for updating)
result = autocomplete_param_with_func(input, "/ai set provider", ai_providers_find, previous, NULL);
if (result) {
return result;
@@ -4560,24 +4612,30 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
return result;
}
// /ai set api-type <provider> <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 <provider> - 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 <provider> <setting> <value> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai set custom", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai set custom <provider> <setting> - autocomplete settings
/* args[0]="set", args[1]="custom", args[2]=provider (if typed), args[3]=setting (if typed) */
if (num_args == 3 && g_strcmp0(args[1], "custom") == 0) {
/* /ai set custom <provider> - check if provider is valid */
if (ai_get_provider(args[2])) {
/* Valid provider, try settings autocomplete */
result = autocomplete_param_with_ac(input, "/ai set custom ", ai_set_custom_subcommands_ac, TRUE, previous);
if (result) {
return result;
}
}
// /ai set custom <provider> <setting> - autocomplete setting names
result = _ai_custom_setting_autocomplete(input, previous);
if (result) {
return result;
}
// /ai set <subcommand> - autocomplete subcommands
result = autocomplete_param_with_ac(input, "/ai set", ai_set_subcommands_ac, TRUE, previous);
if (result) {

View File

@@ -2815,6 +2815,7 @@ static const struct cmd_t command_defs[] = {
"/ai set provider <name> <url>",
"/ai set token <provider> <token>",
"/ai set default-model <provider> <model>",
"/ai set api-type <provider> chat-completions|responses|auto",
"/ai set custom <provider> <setting> <value>",
"/ai remove provider <name>",
"/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 <name> <url>", "Add or update a provider with custom API endpoint" },
{ "set provider <name> <url>", "Add or update a provider with a custom API base URL (endpoint paths are appended automatically)" },
{ "set token <provider> <token>", "Set API token for a provider (e.g., openai, perplexity)" },
{ "set default-model <provider> <model>", "Set default model for a provider" },
{ "set api-type <provider> 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 <provider> <setting> <value>", "Set provider-level setting (e.g., temperature, max_tokens)" },
{ "remove provider <name>", "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",

View File

@@ -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 <provider> 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 <provider> <setting> <value>
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);

View File

@@ -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

View File

@@ -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.<name>=<value> inside [ai/<provider>]. Pass NULL value to remove. */
gboolean prefs_ai_set_setting(const char* const provider, const char* const setting, const char* const value);

View File

@@ -55,11 +55,11 @@ ai_no_args_shows_help(void** state)
void
ai_providers_lists_defaults(void** state)
{
/* `/ai providers` (no "list") shows the built-in list with URLs. */
/* `/ai providers` (no "list") shows the configured list with URLs. */
prof_input("/ai providers");
prof_timeout(5);
assert_true(prof_output_exact("Available AI providers:"));
assert_true(prof_output_exact("Configured providers:"));
/* At least one URL line is rendered — exact name agnostic. */
assert_true(prof_output_regex("https?://"));
prof_timeout_reset();

View File

@@ -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,86 @@ test_ai_set_provider_setting_reserved_key(void** state)
assert_string_equal("0.7", temp);
}
void
test_ai_get_provider_setting_keys(void** state)
{
ai_add_provider("p_setting_keys", "https://example.test/skeys/");
/* No settings yet, unknown provider, NULL — all yield NULL */
assert_null(ai_get_provider_setting_keys("p_setting_keys"));
assert_null(ai_get_provider_setting_keys("nonexistent"));
assert_null(ai_get_provider_setting_keys(NULL));
ai_set_provider_setting("p_setting_keys", "temperature", "0.7");
ai_set_provider_setting("p_setting_keys", "reasoning_effort", "high");
/* Both keys listed (order is hash-defined, assert membership) */
GList* keys = ai_get_provider_setting_keys("p_setting_keys");
assert_int_equal(2, g_list_length(keys));
assert_non_null(g_list_find_custom(keys, "temperature", (GCompareFunc)g_strcmp0));
assert_non_null(g_list_find_custom(keys, "reasoning_effort", (GCompareFunc)g_strcmp0));
g_list_free_full(keys, g_free);
/* Removing a setting removes its key */
ai_set_provider_setting("p_setting_keys", "temperature", NULL);
keys = ai_get_provider_setting_keys("p_setting_keys");
assert_int_equal(1, g_list_length(keys));
assert_string_equal("reasoning_effort", keys->data);
g_list_free_full(keys, g_free);
}
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 +1022,133 @@ 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_responses_reasoning_only_returns_null(void** state)
{
/* An incomplete/truncated body with only a reasoning item (no message
* "content" array at all) must yield a parse failure, not leak the
* reasoning summary's "text" as the assistant reply. */
const gchar* json = "{\"status\":\"incomplete\",\"output\":["
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"secret reasoning\"}]}"
"]}";
gchar* out = ai_parse_response(json);
assert_null(out);
}
void
test_ai_parse_response_responses_reasoning_after_message_skipped(void** state)
{
/* A reasoning item ordered after the message, with the part's "text"
* preceding "type":"output_text": the forward scan must stay inside the
* part's object and the backscan must find the real answer. */
const gchar* json = "{\"output\":["
"{\"type\":\"message\",\"content\":[{\"text\":\"answer\",\"type\":\"output_text\"}]},"
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"secret\"}]}"
"]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("answer", out);
}
void
test_ai_parse_response_responses_empty_content_not_leaked(void** state)
{
/* An empty content array followed by a reasoning item must yield a parse
* failure; the scan must not escape the array and return the summary. */
const gchar* json = "{\"output\":["
"{\"type\":\"message\",\"content\":[]},"
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"secret\"}]}"
"]}";
gchar* out = ai_parse_response(json);
assert_null(out);
}
void
test_ai_parse_response_responses_text_value_before_later_item(void** state)
{
/* A reply that is exactly "text" plus a reasoning item after the message:
* the backscan's own extraction must also stay inside the part's object. */
const gchar* json = "{\"output\":["
"{\"type\":\"message\",\"content\":[{\"text\":\"text\",\"type\":\"output_text\"}]},"
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"secret\"}]}"
"]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("text", 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 +1161,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 +1746,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)
{

View File

@@ -44,6 +44,9 @@ 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_get_provider_setting_keys(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 +68,21 @@ 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_reasoning_only_returns_null(void** state);
void test_ai_parse_response_responses_reasoning_after_message_skipped(void** state);
void test_ai_parse_response_responses_empty_content_not_leaked(void** state);
void test_ai_parse_response_responses_text_value_before_later_item(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 +145,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 */

View File

@@ -753,6 +753,9 @@ 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_get_provider_setting_keys, 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 +778,21 @@ 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_reasoning_only_returns_null),
cmocka_unit_test(test_ai_parse_response_responses_reasoning_after_message_skipped),
cmocka_unit_test(test_ai_parse_response_responses_empty_content_not_leaked),
cmocka_unit_test(test_ai_parse_response_responses_text_value_before_later_item),
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 +847,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),