Compare commits
5 Commits
fix/unencr
...
9a69ede91b
| Author | SHA1 | Date | |
|---|---|---|---|
|
9a69ede91b
|
|||
|
5d7b7bb23d
|
|||
|
fa6857f4c8
|
|||
|
9913344bbf
|
|||
|
91631aa91a
|
47
.github/workflows/docker-publish.yml
vendored
Normal file
47
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, ci/docker-hub-publishing]
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: cproofdev/cproof
|
||||
tags: |
|
||||
type=raw,value=dev,enable-if-ref=refs/heads/master
|
||||
type=sha,prefix=nightly-,enable-if-ref=refs/heads/master
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable-if-ref=refs/tags/*
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
||||
with:
|
||||
context: .
|
||||
file: ./ci/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
|
||||
100
ci/Dockerfile
Normal file
100
ci/Dockerfile
Normal file
@@ -0,0 +1,100 @@
|
||||
# =============================================================================
|
||||
# Stage 1: Build
|
||||
# =============================================================================
|
||||
FROM ubuntu:26.04 AS builder
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
autoconf \
|
||||
automake \
|
||||
autoconf-archive \
|
||||
ca-certificates \
|
||||
libtool \
|
||||
pkg-config \
|
||||
git \
|
||||
wget \
|
||||
libglib2.0-dev \
|
||||
libcurl4-openssl-dev \
|
||||
libsqlite3-dev \
|
||||
libncurses-dev \
|
||||
libreadline-dev \
|
||||
libnotify-dev \
|
||||
python3-dev \
|
||||
python3 \
|
||||
libgpgme-dev \
|
||||
libotr5-dev \
|
||||
libgtk-3-dev \
|
||||
libgdk-pixbuf-2.0-dev \
|
||||
libsignal-protocol-c-dev \
|
||||
libgcrypt20-dev \
|
||||
libqrencode-dev \
|
||||
libxss-dev \
|
||||
libcmocka-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Build libstrophe from source (not available as Ubuntu package)
|
||||
RUN git clone --depth 1 https://git.jabber.space/devs/libstrophe-gh-mirror.git /tmp/libstrophe \
|
||||
&& cd /tmp/libstrophe \
|
||||
&& autoreconf -i \
|
||||
&& ./configure --prefix=/usr \
|
||||
&& make -j$(nproc) \
|
||||
&& make install \
|
||||
&& rm -rf /tmp/libstrophe
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN ./bootstrap.sh \
|
||||
&& ./configure \
|
||||
--enable-notifications \
|
||||
--enable-icons-and-clipboard \
|
||||
--enable-otr \
|
||||
--enable-pgp \
|
||||
--enable-omemo \
|
||||
--enable-plugins \
|
||||
--enable-c-plugins \
|
||||
--enable-python-plugins \
|
||||
--with-xscreensaver \
|
||||
--enable-omemo-qrcode \
|
||||
--enable-gdk-pixbuf \
|
||||
&& make -j$(nproc) \
|
||||
&& make install
|
||||
|
||||
# =============================================================================
|
||||
# Stage 2: Runtime (minimal)
|
||||
# =============================================================================
|
||||
FROM ubuntu:26.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
libglib2.0-0t64 \
|
||||
libcurl4t64 \
|
||||
libsqlite3-0 \
|
||||
libncursesw6 \
|
||||
libreadline8t64 \
|
||||
libnotify4 \
|
||||
python3 \
|
||||
libgpgme45 \
|
||||
libotr5t64 \
|
||||
libgtk-3-0t64 \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
libsignal-protocol-c2.3.2 \
|
||||
libgcrypt20 \
|
||||
libqrencode4 \
|
||||
libxss1 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
COPY --from=builder /usr/local/bin/profanity /usr/local/bin/profanity
|
||||
COPY --from=builder /usr/local/lib/libprofanity* /usr/local/lib/
|
||||
COPY --from=builder /usr/local/include/profapi.h /usr/local/include/
|
||||
COPY --from=builder /usr/local/share/profanity /usr/local/share/profanity
|
||||
|
||||
RUN mkdir -p /root/.config/profanity
|
||||
|
||||
ENTRYPOINT ["profanity"]
|
||||
@@ -186,6 +186,22 @@ ai_json_escape(const gchar* str)
|
||||
return g_string_free(result, FALSE);
|
||||
}
|
||||
|
||||
/* Keys emitted as fixed payload fields; custom settings must not duplicate them */
|
||||
static gboolean
|
||||
_is_reserved_payload_key(const gchar* key)
|
||||
{
|
||||
return g_strcmp0(key, "model") == 0 || g_strcmp0(key, "messages") == 0 || g_strcmp0(key, "stream") == 0;
|
||||
}
|
||||
|
||||
/* RFC 8259 scalars (number/true/false/null) may be emitted bare in a JSON payload */
|
||||
static gboolean
|
||||
_is_json_bare_token(const gchar* value)
|
||||
{
|
||||
if (g_strcmp0(value, "true") == 0 || g_strcmp0(value, "false") == 0 || g_strcmp0(value, "null") == 0)
|
||||
return TRUE;
|
||||
return g_regex_match_simple("^-?(0|[1-9][0-9]*)(\\.[0-9]+)?([eE][+-]?[0-9]+)?$", value, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a 4-hex-digit sequence at @p to a 16-bit code point.
|
||||
* Returns -1 on invalid hex.
|
||||
@@ -392,6 +408,7 @@ ai_provider_new(const gchar* name, const gchar* api_url)
|
||||
provider->models = NULL;
|
||||
provider->models_fresh = FALSE;
|
||||
provider->ref_count = 1;
|
||||
pthread_mutex_init(&provider->settings_lock, NULL);
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -419,6 +436,7 @@ ai_provider_unref(AIProvider* provider)
|
||||
g_free(provider->project_id);
|
||||
g_free(provider->default_model);
|
||||
g_hash_table_destroy(provider->settings);
|
||||
pthread_mutex_destroy(&provider->settings_lock);
|
||||
|
||||
GList* curr = provider->models;
|
||||
while (curr) {
|
||||
@@ -729,31 +747,44 @@ ai_get_provider_default_model(const gchar* provider_name)
|
||||
return provider->default_model;
|
||||
}
|
||||
|
||||
void
|
||||
gboolean
|
||||
ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value)
|
||||
{
|
||||
if (!provider_name || !setting)
|
||||
return;
|
||||
return FALSE;
|
||||
|
||||
if (_is_reserved_payload_key(setting)) {
|
||||
log_warning("Setting '%s' for provider '%s' rejected: reserved payload key", setting, provider_name);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
AIProvider* provider = ai_get_provider(provider_name);
|
||||
if (!provider) {
|
||||
log_warning("Provider '%s' not found for setting '%s'", provider_name, setting);
|
||||
return;
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/* settings is iterated by the request thread; mutate under settings_lock */
|
||||
pthread_mutex_lock(&provider->settings_lock);
|
||||
if (!provider->settings) {
|
||||
provider->settings = g_hash_table_new_full(g_str_hash, g_str_equal, g_free, g_free);
|
||||
}
|
||||
|
||||
if (value) {
|
||||
g_hash_table_insert(provider->settings, g_strdup(setting), g_strdup(value));
|
||||
} else {
|
||||
g_hash_table_remove(provider->settings, setting);
|
||||
}
|
||||
pthread_mutex_unlock(&provider->settings_lock);
|
||||
|
||||
if (value) {
|
||||
prefs_ai_set_setting(provider_name, setting, value);
|
||||
log_info("Setting '%s' for provider '%s' set to: %s", setting, provider_name, value);
|
||||
} else {
|
||||
g_hash_table_remove(provider->settings, setting);
|
||||
prefs_ai_remove_setting(provider_name, setting);
|
||||
log_info("Setting '%s' removed for provider '%s'", setting, provider_name);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
gchar*
|
||||
@@ -766,8 +797,10 @@ ai_get_provider_setting(const gchar* provider_name, const gchar* setting)
|
||||
if (!provider || !provider->settings)
|
||||
return NULL;
|
||||
|
||||
gchar* value = g_hash_table_lookup(provider->settings, setting);
|
||||
return g_strdup(value);
|
||||
pthread_mutex_lock(&provider->settings_lock);
|
||||
gchar* value = g_strdup(g_hash_table_lookup(provider->settings, setting));
|
||||
pthread_mutex_unlock(&provider->settings_lock);
|
||||
return value;
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
@@ -1435,13 +1468,19 @@ ai_session_switch(AISession* session, const gchar* provider_name,
|
||||
* This is the thread-safe variant used by _ai_request_thread() which holds
|
||||
* the session lock while calling it.
|
||||
*
|
||||
* Custom provider settings are merged into the payload as additional JSON
|
||||
* key-value pairs alongside "model" and "messages": scalar values
|
||||
* (number/true/false/null) are emitted bare, anything else as a JSON string;
|
||||
* reserved keys (model/messages/stream) are skipped.
|
||||
*
|
||||
* @param provider The AI provider (for custom settings)
|
||||
* @param model The model identifier
|
||||
* @param history GList of AIMessage* (must be held under session lock)
|
||||
* @param prompt The new user prompt to append
|
||||
* @return Newly allocated JSON string (caller must free)
|
||||
*/
|
||||
static gchar*
|
||||
_build_json_payload_from_list(const gchar* model, GList* history, const gchar* prompt)
|
||||
_build_json_payload_from_list(AIProvider* provider, const gchar* model, GList* history, const gchar* prompt)
|
||||
{
|
||||
GString* messages_json = g_string_new("");
|
||||
GList* curr = history;
|
||||
@@ -1468,11 +1507,50 @@ _build_json_payload_from_list(const gchar* model, GList* history, const gchar* p
|
||||
g_string_append_printf(messages_json, "{\"role\":\"user\",\"content\":\"%s\"}", escaped_prompt);
|
||||
|
||||
auto_gchar gchar* escaped_model = ai_json_escape(model);
|
||||
gchar* json_payload = g_strdup_printf(
|
||||
"{\"model\":\"%s\",\"input\":[%s],\"stream\":false,\"store\":false}",
|
||||
escaped_model, messages_json->str);
|
||||
|
||||
/* Build custom settings JSON fragment; settings is mutated on the main
|
||||
* thread (/ai set custom), so iterate under settings_lock */
|
||||
GString* custom_json = g_string_new("");
|
||||
if (provider) {
|
||||
pthread_mutex_lock(&provider->settings_lock);
|
||||
if (provider->settings) {
|
||||
GHashTableIter iter;
|
||||
gpointer key, value;
|
||||
g_hash_table_iter_init(&iter, provider->settings);
|
||||
while (g_hash_table_iter_next(&iter, &key, &value)) {
|
||||
if (_is_reserved_payload_key((gchar*)key)) {
|
||||
continue; /* fixed payload fields stay authoritative */
|
||||
}
|
||||
auto_gchar gchar* escaped_key = ai_json_escape((gchar*)key);
|
||||
if (custom_json->len > 0) {
|
||||
g_string_append_c(custom_json, ',');
|
||||
}
|
||||
if (_is_json_bare_token((gchar*)value)) {
|
||||
g_string_append_printf(custom_json, "\"%s\": %s", escaped_key, (gchar*)value);
|
||||
} else {
|
||||
/* non-scalar values must be quoted, else the payload is invalid JSON */
|
||||
auto_gchar gchar* escaped_val = ai_json_escape((gchar*)value);
|
||||
g_string_append_printf(custom_json, "\"%s\": \"%s\"", escaped_key, escaped_val);
|
||||
}
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&provider->settings_lock);
|
||||
}
|
||||
|
||||
/* Assemble final payload: model, messages, custom settings, then fixed keys */
|
||||
gchar* json_payload;
|
||||
if (custom_json->len > 0) {
|
||||
json_payload = g_strdup_printf(
|
||||
"{\"model\":\"%s\",\"messages\":[%s],%s,\"stream\":false}",
|
||||
escaped_model, messages_json->str, custom_json->str);
|
||||
} else {
|
||||
json_payload = g_strdup_printf(
|
||||
"{\"model\":\"%s\",\"messages\":[%s],\"stream\":false}",
|
||||
escaped_model, messages_json->str);
|
||||
}
|
||||
|
||||
g_string_free(messages_json, TRUE);
|
||||
g_string_free(custom_json, TRUE);
|
||||
return json_payload;
|
||||
}
|
||||
|
||||
@@ -1482,12 +1560,7 @@ ai_parse_response(const gchar* response_json)
|
||||
if (!response_json || strlen(response_json) == 0)
|
||||
return NULL;
|
||||
|
||||
/* Perplexity /v1/agent: {"output":[{"content":[{"text":"...","type":"output_text"}]}]}
|
||||
* Legacy OpenAI: {"choices":[{"message":{"content":"..."}}]} */
|
||||
gchar* text = _extract_json_string(response_json, "text");
|
||||
if (text)
|
||||
return text;
|
||||
|
||||
/* OpenAI /v1/chat/completions: {"choices":[{"message":{"content":"..."}}]} */
|
||||
return _extract_json_string(response_json, "content");
|
||||
}
|
||||
|
||||
@@ -1566,7 +1639,7 @@ _ai_request_thread(gpointer data)
|
||||
/* Build JSON payload from history + prompt (under lock), then add user message to history */
|
||||
log_debug("[AI-THREAD] Building JSON payload...");
|
||||
pthread_mutex_lock(&session->lock);
|
||||
auto_gchar gchar* json_payload = _build_json_payload_from_list(local_model, session->history, prompt);
|
||||
auto_gchar gchar* json_payload = _build_json_payload_from_list(local_provider, local_model, session->history, prompt);
|
||||
|
||||
/* Add user message to history now (it's in JSON but not yet in history) */
|
||||
AIMessage* user_msg = g_new0(AIMessage, 1);
|
||||
@@ -1587,7 +1660,7 @@ _ai_request_thread(gpointer data)
|
||||
|
||||
/* Build request URL */
|
||||
const gchar* api_url = local_provider->api_url;
|
||||
auto_gchar gchar* request_url = g_strdup_printf("%s%sv1/responses", api_url, g_str_has_suffix(api_url, "/") ? "" : "/");
|
||||
auto_gchar gchar* request_url = g_strdup_printf("%s%sv1/chat/completions", api_url, g_str_has_suffix(api_url, "/") ? "" : "/");
|
||||
log_debug("[AI-THREAD] API URL: %s", api_url);
|
||||
log_debug("[AI-THREAD] API Request URL: %s", request_url);
|
||||
log_debug("[AI-THREAD] Model: %s", local_model);
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -2833,7 +2833,7 @@ static const struct cmd_t command_defs[] = {
|
||||
{ "set provider <name> <url>", "Add or update a provider with custom API endpoint" },
|
||||
{ "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 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)" },
|
||||
@@ -2846,7 +2846,7 @@ static const struct cmd_t command_defs[] = {
|
||||
"/ai set token perplexity pplx-xxx",
|
||||
"/ai set provider custom https://my-api.com/v1/chat/completions",
|
||||
"/ai set default-model perplexity sonar",
|
||||
"/ai set custom perplexity tools enabled",
|
||||
"/ai set custom perplexity temperature 0.7",
|
||||
"/ai remove provider custom",
|
||||
"/ai start",
|
||||
"/ai start perplexity",
|
||||
|
||||
@@ -10787,8 +10787,11 @@ cmd_ai_set(ProfWin* window, const char* const command, gchar** args)
|
||||
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, stream).", args[3], args[2]);
|
||||
}
|
||||
cons_show("");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
@@ -614,6 +614,24 @@ 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", "stream", "true"));
|
||||
assert_null(ai_get_provider_setting("p_reserved_keys", "model"));
|
||||
|
||||
/* 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(void** state)
|
||||
{
|
||||
@@ -919,22 +937,22 @@ test_ai_parse_response_openai_content(void** state)
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_perplexity_text(void** state)
|
||||
test_ai_parse_response_legacy_text_format_unsupported(void** state)
|
||||
{
|
||||
/* Legacy Perplexity /v1/agent format: "content" is an array, not a string.
|
||||
* Dropped with the chat-completions alignment, so parsing yields NULL. */
|
||||
const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"Search result\",\"type\":\"output_text\"}]}]}";
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("Search result", out);
|
||||
assert_null(ai_parse_response(json));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_text_preferred_over_content(void** state)
|
||||
test_ai_parse_response_content_preferred_over_text(void** state)
|
||||
{
|
||||
/* When both formats are present, "text" wins (Perplexity path tried first). */
|
||||
/* 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
|
||||
|
||||
@@ -43,6 +43,7 @@ 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(void** state);
|
||||
/* Model caching tests */
|
||||
void test_ai_models_are_fresh_initial(void** state);
|
||||
@@ -64,8 +65,8 @@ 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_legacy_text_format_unsupported(void** state);
|
||||
void test_ai_parse_response_content_preferred_over_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);
|
||||
|
||||
@@ -752,6 +752,7 @@ 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, 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 +775,8 @@ 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_legacy_text_format_unsupported),
|
||||
cmocka_unit_test(test_ai_parse_response_content_preferred_over_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),
|
||||
|
||||
Reference in New Issue
Block a user