Compare commits

..

13 Commits

Author SHA1 Message Date
37218d2e07 fix(ai): bound response parsing and harden AUTO fallback errors
All checks were successful
CI Code / Check spelling (pull_request) Successful in 16s
CI Code / Check coding style (pull_request) Successful in 26s
CI Code / Code Coverage (pull_request) Successful in 9m12s
CI Code / Linux (ubuntu) (pull_request) Successful in 9m18s
CI Code / Linux (debian) (pull_request) Successful in 9m29s
CI Code / Linux (arch) (pull_request) Successful in 13m13s
- 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
- match model errors case-insensitively and recognize the
  'model "x" is not supported' wording, so a wrong-model 400 (seen live
  on Perplexity /v1/responses) fails fast instead of probing the other
  flavour with a doomed request
- say "empty response body" instead of "Unknown error" when an HTTP
  error arrives without a body (e.g. Perplexity's bare 404)
- 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-16 13:00:56 +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
5d7b7bb23d test(ai): align parse_response tests with chat completions API
All checks were successful
CI Code / Check coding style (pull_request) Successful in 29s
CI Code / Check spelling (pull_request) Successful in 15s
CI Code / Code Coverage (pull_request) Successful in 3m37s
CI Code / Linux (debian) (pull_request) Successful in 7m36s
CI Code / Linux (ubuntu) (pull_request) Successful in 7m52s
CI Code / Linux (arch) (pull_request) Successful in 11m26s
CI Code / Check spelling (push) Successful in 15s
CI Code / Check coding style (push) Successful in 28s
CI Code / Code Coverage (push) Successful in 3m30s
CI Code / Linux (debian) (push) Successful in 5m19s
CI Code / Linux (ubuntu) (push) Successful in 5m25s
CI Code / Linux (arch) (push) Successful in 7m11s
The chat-completions refactor removed the legacy Perplexity "text"
extraction but left two tests asserting the old behavior, breaking
make check on every CI flavor:

- test_ai_parse_response_perplexity_text expected the legacy /v1/agent
  nested format to parse; it now yields NULL — renamed to
  test_ai_parse_response_legacy_text_format_unsupported.
- test_ai_parse_response_text_preferred_over_content expected "text"
  to win; "content" is authoritative now — renamed to
  test_ai_parse_response_content_preferred_over_text.

Add test_ai_set_provider_setting_reserved_key covering the new
reserved-key rejection.
2026-07-04 14:07:22 +03:00
fa6857f4c8 fix(ai): harden custom settings payload against invalid JSON and races
Custom setting values were JSON-escaped but emitted unquoted, so any
non-numeric value (e.g. "high") produced an invalid JSON payload and
failed the whole request; quoting the value manually could not work
either, since the escaper turns quotes into \". Emit RFC 8259 scalars
(number/true/false/null) bare and everything else as a quoted JSON
string.

Reserved payload keys (model, messages, stream) would duplicate the
fixed fields with parser-dependent precedence; reject them in
ai_set_provider_setting (surfaced as an error by /ai set custom) and
skip them at payload-build time for settings loaded from hand-edited
prefs.

provider->settings was mutated on the main thread while the request
thread iterates it when building the payload; guard both sides with a
new per-provider settings_lock.

Also refresh stale docs: ai_parse_response no longer mentions the
dropped Perplexity "text" path, the payload docstring says "messages"
instead of "input", and the /ai help example uses a realistic scalar
setting.
2026-07-04 14:07:22 +03:00
9913344bbf refactor(ai): align AI client with OpenAI chat completions API
Some checks failed
CI Code / Check spelling (push) Successful in 16s
CI Code / Check coding style (push) Successful in 29s
CI Code / Linux (debian) (push) Failing after 3m38s
CI Code / Code Coverage (push) Failing after 5m19s
CI Code / Linux (ubuntu) (push) Failing after 6m15s
CI Code / Linux (arch) (push) Failing after 9m31s
Update request endpoint to /v1/chat/completions and switch payload key
from input to messages. Remove store flag and legacy Perplexity response
parsing to standardize on OpenAI's content extraction.
2026-07-04 09:58:17 +00:00
91631aa91a feat(ai): add suport for /ai set custom parameters
Modify _build_json_payload_from_list to accept an AIProvider parameter
and dynamically merge its custom settings into the JSON payload. The
settings are serialized as additional key-value pairs alongside the
standard model and input fields, enabling per-provider configuration
options without hardcoding them.
2026-07-04 09:47:28 +00:00
622054fc6d fix(pgp): prevent plaintext fallback in PGP/OX encryption
All checks were successful
CI Code / Check spelling (pull_request) Successful in 17s
CI Code / Check coding style (pull_request) Successful in 32s
CI Code / Code Coverage (pull_request) Successful in 3m41s
CI Code / Linux (debian) (pull_request) Successful in 5m15s
CI Code / Linux (ubuntu) (pull_request) Successful in 5m21s
CI Code / Linux (arch) (pull_request) Successful in 13m55s
CI Code / Check spelling (push) Successful in 1m24s
CI Code / Check coding style (push) Successful in 1m38s
CI Code / Linux (arch) (push) Successful in 7m14s
CI Code / Linux (debian) (push) Successful in 8m27s
CI Code / Linux (ubuntu) (push) Successful in 8m29s
CI Code / Code Coverage (push) Successful in 7m50s
Block all three code paths in message_send_chat_pgp that previously
fell back to sending unencrypted messages: encryption failure, missing
PGP key, and missing LibGPGME. Each path now shows a specific error
in the chat window and returns NULL so the caller skips logging and
display.

Also replace the generic cons_show for OX signcrypt failure with a
win_println on the current window, ensuring the user sees the error
even when the chat window is not focused.

Improve p_gpg_encrypt error reporting by adding a gchar** err
out-parameter. Each failure path now sets a descriptive message
(e.g. missing recipient key, missing sender key, GPGME context
failure, encryption failure) so the caller can display the exact
reason to the user.

Fix a memory leak: gpgme_key_unref(receiver_key) was missing from
the sender_key failure path.
2026-06-29 14:08:40 +00:00
ca92d29179 fix(ui): measure dead pad space from cursor instead of absolute height
All checks were successful
CI Code / Check spelling (push) Successful in 20s
CI Code / Check coding style (push) Successful in 38s
CI Code / Code Coverage (push) Successful in 4m2s
CI Code / Linux (ubuntu) (push) Successful in 5m22s
CI Code / Linux (debian) (push) Successful in 7m21s
CI Code / Linux (arch) (push) Successful in 7m39s
Remove the /autoping warning subcommand and PREF_AUTOPING_WARNING
preference entirely. The warning was shown at connect time when
autoping was disabled but the server supported XEP-0199 ping.

The pad threshold logic previously used an absolute height
(PAD_THRESHOLD=12000) which was above the buffer cap and never
fired during normal scrolling. Replace with a dead-space measurement
(cursor position minus live buffer lines) that only triggers a redraw
when dead space exceeds PAD_DEAD_SPACE_LIMIT (2000 lines).

Author: jabber.developer2 <jabber.developer2@jabber.space>
2026-06-24 16:22:52 +00:00
72aa603147 build: untrack generated src/gitversion.h.in
All checks were successful
CI Code / Check coding style (push) Successful in 1m33s
CI Code / Check spelling (push) Successful in 1m39s
CI Code / Linux (debian) (push) Successful in 5m20s
CI Code / Code Coverage (push) Successful in 4m30s
CI Code / Linux (arch) (push) Successful in 7m4s
CI Code / Linux (ubuntu) (push) Successful in 7m9s
The file is gitignored and regenerated by the build recipe from
.git/HEAD/.git/index; it was committed by mistake during the upstream
sync. Untrack it so builds no longer dirty the working tree. The
version string behaviour is unchanged.
2026-06-23 12:33:49 +00:00
15dfc2bdb4 fix(xmpp): guard NULL domain in autoping disco warning gate
Some checks failed
CI Code / Check coding style (pull_request) Successful in 29s
CI Code / Check spelling (pull_request) Successful in 19s
CI Code / Linux (debian) (pull_request) Successful in 5m16s
CI Code / Linux (ubuntu) (pull_request) Successful in 5m25s
CI Code / Linux (arch) (pull_request) Successful in 7m37s
CI Code / Code Coverage (pull_request) Successful in 7m59s
CI Code / Check spelling (push) Successful in 15s
CI Code / Check coding style (push) Successful in 33s
CI Code / Linux (ubuntu) (push) Has been cancelled
CI Code / Code Coverage (push) Has been cancelled
CI Code / Linux (debian) (push) Has been cancelled
CI Code / Linux (arch) (push) Has been cancelled
g_ascii_strcasecmp() is not NULL-safe (unlike the g_strcmp0 it replaced), and connection_get_domain() can be NULL (init NULL, FREE_SET_NULL on teardown). A disco#info response racing a disconnect would dereference NULL. Capture the domain and skip the check when it is NULL.
2026-06-23 14:23:11 +03:00
02e679c277 feat(autoping): autoping availability warning
All checks were successful
CI Code / Check spelling (push) Successful in 16s
CI Code / Check coding style (push) Successful in 30s
CI Code / Linux (debian) (push) Successful in 5m25s
CI Code / Linux (ubuntu) (push) Successful in 5m33s
CI Code / Code Coverage (push) Successful in 7m54s
CI Code / Linux (arch) (push) Successful in 12m7s
## Introduced change

A new warning that notifies users when the connected XMPP server
advertises XEP-0199 (urn:xmpp:ping) support but the autoping feature
is disabled in settings. The warning can be toggled with
`/autoping warning on|off`.

The warning fires during the on-connect disco#info exchange, only for
responses from the server's own domain — responses from user JIDs or
subdomain services (e.g., conference servers) are excluded.

### Capabilities

- **On-connect detection**: Warning is emitted automatically when the
  server's disco#info response indicates `urn:xmpp:ping` support while
  autoping is disabled and the preference is enabled (default: on)
- **`/autoping warning on|off`**: Toggle the warning via the existing
  autoping command
- **Domain-scoped**: Only triggers for disco#info from the bound server
  domain; user JIDs and subdomain service JIDs are skipped
- **Case-insensitive domain matching**: Uses `g_ascii_strcasecmp` instead
  of `g_strcmp0` to handle case differences between the stanza `from`
  field and the bound domain
- **Display in settings**: Shown in both `/notify` and `/autoping`
  settings dumps, with consistent alignment and `(/autoping warning)`
  reference in each line
- **Autocomplete**: Dedicated `_autoping_autocomplete` function handles
  the `warning` subcommand with `on|off` completion

## Reasoning behind the change

Users connecting to servers that support XEP-0199 ping but have autoping
disabled may experience poorer connection stability. The warning draws
attention to this configuration mismatch without requiring users to read
documentation or dig into settings.

The warning is scoped to the server domain (not user JIDs or subdomain
services) because autoping is a connection-level feature — it only makes
sense in the context of the server's keepalive capabilities.

The warning preference defaults to `on` so users are informed by default,
but can be disabled with `/autoping warning off` if they prefer not to
see it.

## Implementation details

### Warning logic (`src/xmpp/iq.c`)

`_disco_autoping_warning_message()` is called from the on-connect
disco#info handler when the `from` field matches the bound domain. It
checks three conditions:

1. Server features contain `urn:xmpp:ping`
2. Autoping interval is 0 (disabled)
3. `PREF_AUTOPING_WARNING` is true

All three must be true for the warning to display.

### Domain matching

`g_ascii_strcasecmp(from, connection_get_domain())` is used instead of
`g_strcmp0` to handle case differences. This prevents the warning from
silently skipping if a server echoes the `from` field in a different
case than the bound domain.

### Command integration (`src/command/cmd_*.c`)

- `cmd_defs.c`: Added `/autoping warning on|off` syntax and argument
  description
- `cmd_funcs.c`: Added `warning` subcommand handler that calls
  `_cmd_set_boolean_preference` with `PREF_AUTOPING_WARNING`
- `cmd_ac.c`: Registered dedicated `_autoping_autocomplete` that handles
  the `warning` subcommand with `on|off` boolean completion

### Preference storage (`src/config/preferences.c`)

- Group: `PREF_GROUP_NOTIFICATIONS`
- Key: `autoping.warning`
- Default: `TRUE`

### Settings display (`src/ui/console.c`)

The autoping warning preference is shown in both `cons_notify_setting()`
and `cons_autoping_setting()` with consistent column alignment and a
`(/autoping warning)` reference in each line.

### Tests (`tests/functionaltests/test_autoping.c`)

Six functional tests cover all condition combinations:

| Test | Server ping | Autoping | Warning pref | Expected |
|---|---|---|---|---|
| `autoping_warning_shown_when_disabled` | yes | off | on | warning shown |
| `autoping_warning_not_shown_when_server_unsupported` | no | off | on | no warning |
| `autoping_warning_not_shown_when_autoping_enabled` | yes | on | on | no warning |
| `autoping_warning_not_shown_when_user_disabled` | yes | off | off | no warning |
| `autoping_warning_not_shown_for_user_jid` | yes (from user JID) | off | on | no warning |
| `autoping_warning_not_shown_for_subdomain_service` | yes (from subdomain) | off | on | no warning |

Co-authored-by: Jabber Developer2 <jabber.developer2@jabber.space>
2026-06-23 11:11:25 +00:00
18 changed files with 1379 additions and 203 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,19 +12,38 @@ 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.
*/
typedef struct ai_provider_t
{
gchar* name; /* Provider name (e.g., "openai", "perplexity") */
gchar* api_url; /* API endpoint URL */
gchar* project_id; /* Optional project ID (for some providers) */
gchar* default_model; /* Default model for this provider */
GHashTable* settings; /* Extensible per-provider settings (e.g., tools=enabled, search=disabled) */
GList* models; /* Cached models (gchar*) */
gboolean models_fresh; /* Whether models cache is current */
gint32 ref_count; /* Reference count (atomic) */
gchar* name; /* Provider name (e.g., "openai", "perplexity") */
gchar* api_url; /* API endpoint URL */
gchar* project_id; /* Optional project ID (for some providers) */
gchar* default_model; /* Default model for this provider */
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 */
gint32 ref_count; /* Reference count (atomic) */
} AIProvider;
/**
@@ -66,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,12 +160,44 @@ 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 a custom setting for a provider.
* Set the API type for a provider.
* @param provider_name The provider name
* @param setting The setting key (e.g., "tools", "search")
* @param value The setting value
* @param api_type "responses", "chat-completions", or "auto" (detect by probing)
* @return TRUE on success, FALSE on unknown provider or invalid type string
*/
void ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value);
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, 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
* @return TRUE on success, FALSE on unknown provider or reserved key
*/
gboolean ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value);
/**
* Get a custom setting for a provider.
@@ -156,6 +207,14 @@ void ai_set_provider_setting(const gchar* provider_name, const gchar* setting, c
*/
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
@@ -184,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 LLM response body. Tries Perplexity
* /v1/responses "text" first, falls back to OpenAI "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,10 +2831,11 @@ 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 custom <provider> <setting> <value>", "Set provider-level setting (e.g., tools, search)" },
{ "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" },
{ "start <provider> [<model>]", "Start new AI chat (space-separated, uses defaults if omitted)" },
@@ -2844,9 +2846,10 @@ 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 custom perplexity tools enabled",
"/ai set api-type perplexity responses",
"/ai set custom perplexity temperature 0.7",
"/ai remove provider custom",
"/ai start",
"/ai start perplexity",

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,14 +10795,30 @@ 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) {
cons_bad_cmd_usage(command);
return TRUE;
}
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]);
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, input, stream).", args[3], args[2]);
}
cons_show("");
return TRUE;
}
@@ -11102,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

@@ -1,6 +0,0 @@
#ifndef PROF_GIT_BRANCH
#define PROF_GIT_BRANCH @PROF_GIT_BRANCH@
#endif
#ifndef PROF_GIT_REVISION
#define PROF_GIT_REVISION @PROF_GIT_REVISION@
#endif

View File

@@ -519,13 +519,15 @@ p_gpg_sign(const gchar* const str, const gchar* const fp)
}
gchar*
p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gchar* const fp)
p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gchar* const fp, gchar** err)
{
ProfPGPPubKeyId* pubkeyid = g_hash_table_lookup(pubkeys, barejid);
if (!pubkeyid) {
*err = g_strdup("No PGP key found for recipient");
return NULL;
}
if (!pubkeyid->id) {
*err = g_strdup("No key ID found for recipient");
return NULL;
}
@@ -538,6 +540,7 @@ p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gcha
gpgme_ctx_t ctx;
gpgme_error_t error = gpgme_new(&ctx);
if (error) {
*err = g_strdup_printf("Failed to create GPGME context: %s %s", gpgme_strsource(error), gpgme_strerror(error));
log_error("GPG: Failed to create gpgme context. %s %s", gpgme_strsource(error), gpgme_strerror(error));
return NULL;
}
@@ -545,6 +548,7 @@ p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gcha
gpgme_key_t receiver_key;
error = gpgme_get_key(ctx, pubkeyid->id, &receiver_key, 0);
if (error || receiver_key == NULL) {
*err = g_strdup_printf("Failed to get receiver key '%s': %s %s", pubkeyid->id, gpgme_strsource(error), gpgme_strerror(error));
log_error("GPG: Failed to get receiver_key. %s %s", gpgme_strsource(error), gpgme_strerror(error));
gpgme_release(ctx);
return NULL;
@@ -554,8 +558,10 @@ p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gcha
gpgme_key_t sender_key = NULL;
error = gpgme_get_key(ctx, fp, &sender_key, 0);
if (error || sender_key == NULL) {
*err = g_strdup_printf("Failed to get sender key '%s': %s %s", fp, gpgme_strsource(error), gpgme_strerror(error));
log_error("GPG: Failed to get sender_key. %s %s", gpgme_strsource(error), gpgme_strerror(error));
gpgme_release(ctx);
gpgme_key_unref(receiver_key);
return NULL;
}
keys[1] = sender_key;
@@ -574,7 +580,8 @@ p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gcha
gpgme_key_unref(sender_key);
if (error) {
log_error("GPG: Failed to encrypt message. %s %s", gpgme_strsource(error), gpgme_strerror(error));
*err = g_strdup_printf("Encryption failed: (%s) %s", gpgme_strsource(error), gpgme_strerror(error));
log_error("GPG: Failed to encrypt message. (%s) %s", gpgme_strsource(error), gpgme_strerror(error));
return NULL;
}

View File

@@ -42,7 +42,7 @@ gboolean p_gpg_available(const gchar* const barejid);
const gchar* p_gpg_libver(void);
gchar* p_gpg_sign(const gchar* const str, const gchar* const fp);
void p_gpg_verify(const gchar* const barejid, const gchar* const sign);
gchar* p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gchar* const fp);
gchar* p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gchar* const fp, gchar** err);
gchar* p_gpg_decrypt(const gchar* const cipher);
void p_gpg_free_decrypted(gchar* decrypted);
gchar* p_gpg_autocomplete_key(const gchar* const search_str, gboolean previous, void* context);

View File

@@ -43,7 +43,7 @@
#include "ai/ai_client.h"
static const int PAD_MIN_HEIGHT = 100;
static const int PAD_THRESHOLD = 12000; // above buffer cap (~9000): reclaims dead pad space, never fires while scrolling
static const int PAD_DEAD_SPACE_LIMIT = 2000; // reclaim once the pad holds this much dead space (cursor past live buffer); size-independent, so it never fires while scrolling
static gboolean _in_redraw = FALSE;
static void
@@ -56,9 +56,10 @@ _win_ensure_pad_capacity(ProfWin* window, WINDOW* win, int lines_needed)
int cur_height = getmaxy(win);
int cur_width = getmaxx(win);
if (lines_needed >= cur_height - 1) {
// If we are getting too large, trigger a redraw to clean up old lines
// but only if we are not already in a redraw process.
if (window && cur_height >= PAD_THRESHOLD && !_in_redraw) {
// redraw to reclaim dead pad space (cursor far past live buffer, e.g. a long
// append-only session); dead space never accrues while scrolling, only here
int dead = window ? getcury(win) - window->layout->buffer->lines : 0;
if (window && dead > PAD_DEAD_SPACE_LIMIT && !_in_redraw) {
win_redraw(window);
} else {
// resize to required lines + some buffer for next messages

View File

@@ -2432,7 +2432,8 @@ _disco_info_response_id_handler_onconnect(xmpp_stanza_t* const stanza, void* con
}
// Prevent repetitions by avoiding checks of disco items (from connection_set_disco_items)
if (from && g_ascii_strcasecmp(from, connection_get_domain()) == 0) {
const char* domain = connection_get_domain();
if (from && domain && g_ascii_strcasecmp(from, domain) == 0) {
_disco_autoping_warning_message(features);
}
}

View File

@@ -467,12 +467,15 @@ message_send_chat_pgp(const char* const barejid, const char* const msg, gboolean
auto_char char* jid = chat_session_get_jid(barejid);
char* id = connection_create_stanza_id();
ProfWin* current = wins_get_current();
xmpp_stanza_t* message = NULL;
#ifdef HAVE_LIBGPGME
ProfAccount* account = accounts_get_account(session_get_account_name());
if (account->pgp_keyid) {
auto_jid Jid* jidp = jid_create(jid);
auto_gchar gchar* encrypted = p_gpg_encrypt(jidp->barejid, msg, account->pgp_keyid);
auto_gchar gchar* err = NULL;
auto_gchar gchar* encrypted = p_gpg_encrypt(jidp->barejid, msg, account->pgp_keyid, &err);
if (encrypted) {
message = xmpp_message_new(ctx, STANZA_TYPE_CHAT, jid, id);
xmpp_message_set_body(message, "This message is encrypted (XEP-0027).");
@@ -486,18 +489,31 @@ message_send_chat_pgp(const char* const barejid, const char* const msg, gboolean
xmpp_stanza_add_child(message, x);
xmpp_stanza_release(x);
} else {
message = xmpp_message_new(ctx, STANZA_TYPE_CHAT, jid, id);
xmpp_message_set_body(message, msg);
if (current) {
win_println(current, THEME_ERROR, "-", "Unable to encrypt message for %s: %s.", jid, err);
}
log_error("Message not encrypted for %s: %s.", jid, err);
account_free(account);
free(id);
return NULL;
}
} else {
message = xmpp_message_new(ctx, STANZA_TYPE_CHAT, jid, id);
xmpp_message_set_body(message, msg);
if (current) {
win_println(current, THEME_ERROR, "-", "No PGP key configured for this account.");
}
log_error("No PGP key configured, message not sent.");
account_free(account);
free(id);
return NULL;
}
account_free(account);
#else
// ?
message = xmpp_message_new(ctx, STANZA_TYPE_CHAT, jid, id);
xmpp_message_set_body(message, msg);
if (current) {
win_println(current, THEME_ERROR, "-", "LibGPGME not available, message not sent.");
}
log_error("LibGPGME not available, message not sent.");
free(id);
return NULL;
#endif
if (state) {
@@ -546,7 +562,10 @@ message_send_chat_ox(const char* const barejid, const char* const msg, gboolean
xmpp_stanza_to_text(signcrypt, &c, &s);
char* signcrypt_e = p_ox_gpg_signcrypt(account->jid, barejid, c);
if (signcrypt_e == NULL) {
cons_show("Unable to send OX message. Check log file and profanity-ox-setup man page for details.");
ProfWin* current = wins_get_current();
if (current) {
win_println(current, THEME_ERROR, "-", "Unable to send OX message. Check log file and profanity-ox-setup man page for details.");
}
log_error("Message not signcrypted.");
return NULL;
}

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
@@ -614,6 +611,112 @@ test_ai_set_provider_setting(void** state)
assert_null(ai_get_provider_setting("p_set_settings", "nonexistent_setting"));
}
void
test_ai_set_provider_setting_reserved_key(void** state)
{
ai_add_provider("p_reserved_keys", "https://example.test/rk/");
/* 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");
assert_non_null(temp);
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)
{
@@ -919,8 +1022,10 @@ test_ai_parse_response_openai_content(void** state)
}
void
test_ai_parse_response_perplexity_text(void** state)
test_ai_parse_response_responses_output_text(void** state)
{
/* 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\"}]}]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
@@ -928,13 +1033,180 @@ test_ai_parse_response_perplexity_text(void** state)
}
void
test_ai_parse_response_text_preferred_over_content(void** state)
test_ai_parse_response_responses_full_envelope(void** state)
{
/* When both formats are present, "text" wins (Perplexity path tried first). */
/* 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
test_ai_parse_response_content_preferred_over_text(void** state)
{
/* Chat-completions "content" is extracted; a stray "text" field is ignored. */
const gchar* json = "{\"text\":\"from text field\",\"content\":\"from content field\"}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("from text field", out);
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
@@ -1474,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

@@ -43,6 +43,10 @@ void test_ai_providers_find_case_insensitive(void** state);
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);
@@ -64,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_perplexity_text(void** state);
void test_ai_parse_response_text_preferred_over_content(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);
@@ -128,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

@@ -752,6 +752,10 @@ main(int argc, char* argv[])
cmocka_unit_test_setup_teardown(test_ai_set_provider_default_model, ai_client_setup, ai_client_teardown),
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),
@@ -774,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_perplexity_text),
cmocka_unit_test(test_ai_parse_response_text_preferred_over_content),
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),
@@ -830,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),