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).
This commit is contained in:
@@ -62,12 +62,10 @@ test_ai_add_provider(void** state)
|
||||
assert_string_equal("custom", provider->name);
|
||||
assert_string_equal("https://custom.api.com/v1", provider->api_url);
|
||||
|
||||
/* Update existing provider (returns ref; caller owns it) */
|
||||
/* Update existing provider (borrowed pointer, same as the create path) */
|
||||
AIProvider* updated = ai_add_provider("custom", "https://new.api.com/v1");
|
||||
assert_non_null(updated);
|
||||
assert_string_equal("https://new.api.com/v1", updated->api_url);
|
||||
|
||||
ai_provider_unref(updated);
|
||||
}
|
||||
|
||||
void
|
||||
@@ -329,11 +327,10 @@ test_ai_add_provider_update_existing(void** state)
|
||||
assert_non_null(provider);
|
||||
assert_string_equal("https://first.api.com/v1", provider->api_url);
|
||||
|
||||
/* Update the same provider (returns ref) */
|
||||
/* Update the same provider (borrowed pointer) */
|
||||
provider = ai_add_provider("custom", "https://second.api.com/v1");
|
||||
assert_non_null(provider);
|
||||
assert_string_equal("https://second.api.com/v1", provider->api_url);
|
||||
ai_provider_unref(provider);
|
||||
}
|
||||
|
||||
void
|
||||
@@ -622,9 +619,17 @@ test_ai_set_provider_setting_reserved_key(void** state)
|
||||
/* Reserved payload keys are rejected (would duplicate fixed fields) */
|
||||
assert_false(ai_set_provider_setting("p_reserved_keys", "model", "x"));
|
||||
assert_false(ai_set_provider_setting("p_reserved_keys", "messages", "[]"));
|
||||
assert_false(ai_set_provider_setting("p_reserved_keys", "input", "[]"));
|
||||
assert_false(ai_set_provider_setting("p_reserved_keys", "stream", "true"));
|
||||
assert_null(ai_get_provider_setting("p_reserved_keys", "model"));
|
||||
|
||||
/* "store" is a legitimate chat-completions parameter and overrides the
|
||||
* responses-flavour "store":false default, so it stays settable */
|
||||
assert_true(ai_set_provider_setting("p_reserved_keys", "store", "true"));
|
||||
auto_gchar gchar* store = ai_get_provider_setting("p_reserved_keys", "store");
|
||||
assert_non_null(store);
|
||||
assert_string_equal("true", store);
|
||||
|
||||
/* Ordinary keys still work */
|
||||
assert_true(ai_set_provider_setting("p_reserved_keys", "temperature", "0.7"));
|
||||
auto_gchar gchar* temp = ai_get_provider_setting("p_reserved_keys", "temperature");
|
||||
@@ -632,6 +637,58 @@ test_ai_set_provider_setting_reserved_key(void** state)
|
||||
assert_string_equal("0.7", temp);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_set_provider_api_type(void** state)
|
||||
{
|
||||
ai_add_provider("p_api_type", "https://example.test/at/");
|
||||
|
||||
/* Default is auto */
|
||||
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("p_api_type"));
|
||||
|
||||
/* Pin each flavour explicitly */
|
||||
assert_true(ai_set_provider_api_type("p_api_type", "chat-completions"));
|
||||
assert_int_equal(AI_API_TYPE_CHAT_COMPLETIONS, ai_get_provider_api_type("p_api_type"));
|
||||
|
||||
assert_true(ai_set_provider_api_type("p_api_type", "responses"));
|
||||
assert_int_equal(AI_API_TYPE_RESPONSES, ai_get_provider_api_type("p_api_type"));
|
||||
|
||||
/* auto resets to detection */
|
||||
assert_true(ai_set_provider_api_type("p_api_type", "auto"));
|
||||
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("p_api_type"));
|
||||
|
||||
/* Invalid values and unknown providers are rejected */
|
||||
assert_false(ai_set_provider_api_type("p_api_type", "v2"));
|
||||
assert_false(ai_set_provider_api_type("p_api_type", NULL));
|
||||
assert_false(ai_set_provider_api_type("nonexistent", "responses"));
|
||||
assert_false(ai_set_provider_api_type(NULL, "responses"));
|
||||
|
||||
/* Unknown provider reads as auto */
|
||||
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("nonexistent"));
|
||||
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type(NULL));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_api_type_string_round_trip(void** state)
|
||||
{
|
||||
AIApiType type = AI_API_TYPE_AUTO;
|
||||
|
||||
assert_true(ai_api_type_from_string("responses", &type));
|
||||
assert_int_equal(AI_API_TYPE_RESPONSES, type);
|
||||
assert_string_equal("responses", ai_api_type_to_string(type));
|
||||
|
||||
assert_true(ai_api_type_from_string("chat-completions", &type));
|
||||
assert_int_equal(AI_API_TYPE_CHAT_COMPLETIONS, type);
|
||||
assert_string_equal("chat-completions", ai_api_type_to_string(type));
|
||||
|
||||
assert_true(ai_api_type_from_string("auto", &type));
|
||||
assert_int_equal(AI_API_TYPE_AUTO, type);
|
||||
assert_string_equal("auto", ai_api_type_to_string(type));
|
||||
|
||||
assert_false(ai_api_type_from_string("completions", &type));
|
||||
assert_false(ai_api_type_from_string("", &type));
|
||||
assert_false(ai_api_type_from_string(NULL, &type));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_get_provider_setting(void** state)
|
||||
{
|
||||
@@ -937,12 +994,78 @@ test_ai_parse_response_openai_content(void** state)
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_legacy_text_format_unsupported(void** state)
|
||||
test_ai_parse_response_responses_output_text(void** state)
|
||||
{
|
||||
/* Legacy Perplexity /v1/agent format: "content" is an array, not a string.
|
||||
* Dropped with the chat-completions alignment, so parsing yields NULL. */
|
||||
/* Responses API: "content" is an array of typed parts; the inner "text"
|
||||
* of the output_text part is the assistant message. */
|
||||
const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"Search result\",\"type\":\"output_text\"}]}]}";
|
||||
assert_null(ai_parse_response(json));
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("Search result", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_responses_full_envelope(void** state)
|
||||
{
|
||||
/* Realistic responses body: a reasoning item precedes the message item;
|
||||
* the parser must still land on the message's output_text. */
|
||||
const gchar* json = "{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"completed\","
|
||||
"\"output\":["
|
||||
"{\"id\":\"rs_1\",\"type\":\"reasoning\",\"summary\":[]},"
|
||||
"{\"id\":\"msg_1\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\","
|
||||
"\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"text\":\"Hello!\"}]}"
|
||||
"]}";
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("Hello!", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_choices_win_over_output(void** state)
|
||||
{
|
||||
/* A chat-completions envelope is authoritative even if the content
|
||||
* happens to mention "output". */
|
||||
const gchar* json = "{\"choices\":[{\"message\":{\"content\":\"chat result\"}}],\"output\":[{\"content\":[{\"text\":\"wrong\"}]}]}";
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("chat result", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_responses_reasoning_summary_skipped(void** state)
|
||||
{
|
||||
/* A reasoning item with a non-empty summary precedes the message item;
|
||||
* the summary's "text" must not be mistaken for the assistant reply. */
|
||||
const gchar* json = "{\"output\":["
|
||||
"{\"id\":\"rs_1\",\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"User asks X\"}]},"
|
||||
"{\"id\":\"msg_1\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\","
|
||||
"\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"text\":\"Actual answer\"}]}"
|
||||
"]}";
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("Actual answer", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_choices_in_string_value(void** state)
|
||||
{
|
||||
/* "choices" occurring as a string value must not hijack a valid
|
||||
* responses envelope. */
|
||||
const gchar* json = "{\"kind\":\"choices\",\"output\":[{\"content\":[{\"type\":\"output_text\",\"text\":\"hi\"}]}]}";
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("hi", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_content_before_choices(void** state)
|
||||
{
|
||||
/* Nonstandard body: a top-level content string precedes an empty
|
||||
* choices array; the bare-content fallback must still find it. */
|
||||
const gchar* json = "{\"content\":\"hi\",\"choices\":[]}";
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("hi", out);
|
||||
}
|
||||
|
||||
void
|
||||
@@ -955,6 +1078,54 @@ test_ai_parse_response_content_preferred_over_text(void** state)
|
||||
assert_string_equal("from content field", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_responses_brace_in_text(void** state)
|
||||
{
|
||||
/* Responses part with "text" before "type":"output_text" and a '{' inside
|
||||
* the answer: the nearest preceding "text" key must be extracted, not a
|
||||
* brace inside the string value. */
|
||||
const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"use { here\",\"type\":\"output_text\"}]}]}";
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("use { here", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_choices_logprobs_before_content(void** state)
|
||||
{
|
||||
/* A non-string "content" (logprobs array) ordered before message.content
|
||||
* must not defeat extraction; anchoring on "message" finds the reply. */
|
||||
const gchar* json = "{\"choices\":[{\"logprobs\":{\"content\":[{\"token\":\"hi\"}]},"
|
||||
"\"message\":{\"role\":\"assistant\",\"content\":\"real answer\"}}]}";
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("real answer", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_reasoning_not_leaked_without_text(void** state)
|
||||
{
|
||||
/* An output_text part with no "text" at all (truncated body) must yield a
|
||||
* parse failure, not leak the preceding reasoning summary's text. */
|
||||
const gchar* json = "{\"output\":["
|
||||
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"secret\"}]},"
|
||||
"{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"annotations\":[]}]}"
|
||||
"]}";
|
||||
gchar* out = ai_parse_response(json);
|
||||
assert_null(out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_responses_text_value_text(void** state)
|
||||
{
|
||||
/* A reply that is exactly the string "text" must not confuse the
|
||||
* backscan (the value's own quoted bytes match the key needle). */
|
||||
const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"text\",\"type\":\"output_text\"}]}]}";
|
||||
auto_gchar gchar* out = ai_parse_response(json);
|
||||
assert_non_null(out);
|
||||
assert_string_equal("text", out);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_response_escaped_quote(void** state)
|
||||
{
|
||||
@@ -1492,6 +1663,26 @@ test_ai_prefs_round_trip_remove_key(void** state)
|
||||
assert_null(after);
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_prefs_round_trip_api_type(void** state)
|
||||
{
|
||||
ai_add_provider("p_persist_api_type", "https://example.test/pat/");
|
||||
assert_true(ai_set_provider_api_type("p_persist_api_type", "chat-completions"));
|
||||
|
||||
ai_client_shutdown();
|
||||
ai_client_init();
|
||||
|
||||
assert_int_equal(AI_API_TYPE_CHAT_COMPLETIONS, ai_get_provider_api_type("p_persist_api_type"));
|
||||
|
||||
/* Setting auto removes the persisted value */
|
||||
assert_true(ai_set_provider_api_type("p_persist_api_type", "auto"));
|
||||
|
||||
ai_client_shutdown();
|
||||
ai_client_init();
|
||||
|
||||
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("p_persist_api_type"));
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_prefs_multiple_providers_persist(void** state)
|
||||
{
|
||||
|
||||
@@ -44,6 +44,8 @@ void test_ai_set_provider_default_model(void** state);
|
||||
void test_ai_get_provider_default_model(void** state);
|
||||
void test_ai_set_provider_setting(void** state);
|
||||
void test_ai_set_provider_setting_reserved_key(void** state);
|
||||
void test_ai_set_provider_api_type(void** state);
|
||||
void test_ai_api_type_string_round_trip(void** state);
|
||||
void test_ai_get_provider_setting(void** state);
|
||||
/* Model caching tests */
|
||||
void test_ai_models_are_fresh_initial(void** state);
|
||||
@@ -65,8 +67,17 @@ int ai_client_teardown_with_prefs(void** state);
|
||||
|
||||
/* Chat response parser tests (ai_parse_response) */
|
||||
void test_ai_parse_response_openai_content(void** state);
|
||||
void test_ai_parse_response_legacy_text_format_unsupported(void** state);
|
||||
void test_ai_parse_response_responses_output_text(void** state);
|
||||
void test_ai_parse_response_responses_full_envelope(void** state);
|
||||
void test_ai_parse_response_responses_reasoning_summary_skipped(void** state);
|
||||
void test_ai_parse_response_choices_win_over_output(void** state);
|
||||
void test_ai_parse_response_choices_in_string_value(void** state);
|
||||
void test_ai_parse_response_content_before_choices(void** state);
|
||||
void test_ai_parse_response_content_preferred_over_text(void** state);
|
||||
void test_ai_parse_response_responses_brace_in_text(void** state);
|
||||
void test_ai_parse_response_choices_logprobs_before_content(void** state);
|
||||
void test_ai_parse_response_reasoning_not_leaked_without_text(void** state);
|
||||
void test_ai_parse_response_responses_text_value_text(void** state);
|
||||
void test_ai_parse_response_escaped_quote(void** state);
|
||||
void test_ai_parse_response_newline_escape(void** state);
|
||||
void test_ai_parse_response_empty_content(void** state);
|
||||
@@ -129,6 +140,7 @@ void test_ai_parse_models_multiple_models(void** state);
|
||||
/* Prefs round-trip tests */
|
||||
void test_ai_prefs_round_trip_api_key(void** state);
|
||||
void test_ai_prefs_round_trip_remove_key(void** state);
|
||||
void test_ai_prefs_round_trip_api_type(void** state);
|
||||
void test_ai_prefs_multiple_providers_persist(void** state);
|
||||
|
||||
/* Autocomplete deeper coverage */
|
||||
|
||||
@@ -753,6 +753,8 @@ main(int argc, char* argv[])
|
||||
cmocka_unit_test_setup_teardown(test_ai_get_provider_default_model, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_set_provider_setting, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_set_provider_setting_reserved_key, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test_setup_teardown(test_ai_set_provider_api_type, ai_client_setup, ai_client_teardown),
|
||||
cmocka_unit_test(test_ai_api_type_string_round_trip),
|
||||
cmocka_unit_test_setup_teardown(test_ai_get_provider_setting, ai_client_setup, ai_client_teardown),
|
||||
/* Model caching tests */
|
||||
cmocka_unit_test_setup_teardown(test_ai_models_are_fresh_initial, ai_client_setup, ai_client_teardown),
|
||||
@@ -775,8 +777,17 @@ main(int argc, char* argv[])
|
||||
cmocka_unit_test(test_ai_json_escape_backslash_quote),
|
||||
/* Chat response parser */
|
||||
cmocka_unit_test(test_ai_parse_response_openai_content),
|
||||
cmocka_unit_test(test_ai_parse_response_legacy_text_format_unsupported),
|
||||
cmocka_unit_test(test_ai_parse_response_responses_output_text),
|
||||
cmocka_unit_test(test_ai_parse_response_responses_full_envelope),
|
||||
cmocka_unit_test(test_ai_parse_response_responses_reasoning_summary_skipped),
|
||||
cmocka_unit_test(test_ai_parse_response_choices_win_over_output),
|
||||
cmocka_unit_test(test_ai_parse_response_choices_in_string_value),
|
||||
cmocka_unit_test(test_ai_parse_response_content_before_choices),
|
||||
cmocka_unit_test(test_ai_parse_response_content_preferred_over_text),
|
||||
cmocka_unit_test(test_ai_parse_response_responses_brace_in_text),
|
||||
cmocka_unit_test(test_ai_parse_response_choices_logprobs_before_content),
|
||||
cmocka_unit_test(test_ai_parse_response_reasoning_not_leaked_without_text),
|
||||
cmocka_unit_test(test_ai_parse_response_responses_text_value_text),
|
||||
cmocka_unit_test(test_ai_parse_response_escaped_quote),
|
||||
cmocka_unit_test(test_ai_parse_response_newline_escape),
|
||||
cmocka_unit_test(test_ai_parse_response_empty_content),
|
||||
@@ -831,6 +842,7 @@ main(int argc, char* argv[])
|
||||
/* Prefs round-trip (uses prefs+ai setup) */
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_api_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_remove_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_api_type, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
cmocka_unit_test_setup_teardown(test_ai_prefs_multiple_providers_persist, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
|
||||
/* Autocomplete deeper coverage */
|
||||
cmocka_unit_test_setup_teardown(test_ai_providers_find_after_remove_skips_removed, ai_client_setup, ai_client_teardown),
|
||||
|
||||
Reference in New Issue
Block a user