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.
This commit is contained in:
2026-07-04 13:53:18 +03:00
parent 9913344bbf
commit fa6857f4c8
4 changed files with 90 additions and 36 deletions

View File

@@ -17,14 +17,15 @@ typedef struct ai_message_t
*/
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 */
pthread_mutex_t settings_lock; /* Protects settings (iterated by the request thread) */
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;
/**
@@ -142,11 +143,13 @@ const gchar* ai_get_provider_default_model(const gchar* provider_name);
/**
* Set a custom setting for a provider.
* Reserved payload keys (model, messages, stream) are rejected.
* @param provider_name The provider name
* @param setting The setting key (e.g., "tools", "search")
* @param value The setting value
* @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
*/
void ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value);
gboolean ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value);
/**
* Get a custom setting for a provider.
@@ -184,9 +187,9 @@ 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 an OpenAI chat-completions response body
* ({"choices":[{"message":{"content":"..."}}]}). Decodes the \" and \n
* escape sequences only.
* @param response_json The JSON body from the provider
* @return Newly allocated content string, or NULL if not found (caller frees)
*/