feat(ai): implement robust JSON model parsing
Some checks failed
CI Code / Check coding style (pull_request) Successful in 46s
CI Code / Linux (arch) (pull_request) Failing after 9m46s
CI Code / Code Coverage (pull_request) Failing after 14m8s
CI Code / Check spelling (pull_request) Failing after 14m16s
CI Code / Linux (ubuntu) (pull_request) Failing after 14m24s
CI Code / Linux (debian) (pull_request) Failing after 14m33s
Some checks failed
CI Code / Check coding style (pull_request) Successful in 46s
CI Code / Linux (arch) (pull_request) Failing after 9m46s
CI Code / Code Coverage (pull_request) Failing after 14m8s
CI Code / Check spelling (pull_request) Failing after 14m16s
CI Code / Linux (ubuntu) (pull_request) Failing after 14m24s
CI Code / Linux (debian) (pull_request) Failing after 14m33s
Rewrote internal JSON parsing to correctly handle whitespace, escaped quotes, and strict field context extraction. This prevents incorrect field names or provider names from being added to the model list. Added `ai_parse_models_from_json` public API to facilitate testing. Changed `/ai models` default behavior to always fetch fresh models. Replaced `--refresh` flag with `--cached` to display local cache. Added comprehensive unit tests covering OpenAI and Perplexity formats, array format, empty/null JSON, escaped quotes, and whitespace handling.
This commit is contained in:
@@ -644,3 +644,226 @@ test_ai_models_are_fresh_initial(void** state)
|
||||
gboolean fresh_perplexity = ai_models_are_fresh("perplexity");
|
||||
assert_true(fresh_perplexity == TRUE || fresh_perplexity == FALSE); /* Just verify no crash */
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
* Model Parsing Tests
|
||||
* ======================================================================== */
|
||||
|
||||
void
|
||||
test_ai_parse_models_openai_format(void** state)
|
||||
{
|
||||
/* Test parsing OpenAI-compatible API response format */
|
||||
/* Format: {"object":"list","data":[{"id":"model1",...},...]} */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
|
||||
/* JSON response from Perplexity/OpenAI API */
|
||||
const gchar* json = "{\"object\":\"list\","
|
||||
"\"data\":["
|
||||
"{\"id\":\"anthropic/claude-haiku-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
||||
"{\"id\":\"anthropic/claude-opus-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
||||
"{\"id\":\"openai/gpt-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
||||
"{\"id\":\"google/gemini-3-flash-preview\",\"object\":\"model\",\"created\":0,\"owned_by\":\"google\"}"
|
||||
"]}";
|
||||
|
||||
ai_parse_models_from_json(provider, json);
|
||||
|
||||
/* Verify models were parsed correctly */
|
||||
assert_int_equal(4, g_list_length(provider->models));
|
||||
|
||||
/* Check specific models */
|
||||
GList* models = provider->models;
|
||||
assert_string_equal("anthropic/claude-haiku-4-5", models->data);
|
||||
models = g_list_next(models);
|
||||
assert_string_equal("anthropic/claude-opus-4-5", models->data);
|
||||
models = g_list_next(models);
|
||||
assert_string_equal("openai/gpt-5", models->data);
|
||||
models = g_list_next(models);
|
||||
assert_string_equal("google/gemini-3-flash-preview", models->data);
|
||||
|
||||
ai_provider_unref(provider);
|
||||
ai_client_shutdown();
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_models_perplexity_format(void** state)
|
||||
{
|
||||
/* Test parsing actual Perplexity API response with all models from curl test */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("perplexity", "https://api.perplexity.ai/", NULL);
|
||||
|
||||
const gchar* json = "{\"object\":\"list\","
|
||||
"\"data\":["
|
||||
"{\"id\":\"anthropic/claude-haiku-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
||||
"{\"id\":\"anthropic/claude-opus-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
||||
"{\"id\":\"anthropic/claude-opus-4-6\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
||||
"{\"id\":\"anthropic/claude-opus-4-7\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
||||
"{\"id\":\"anthropic/claude-sonnet-4-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
||||
"{\"id\":\"anthropic/claude-sonnet-4-6\",\"object\":\"model\",\"created\":0,\"owned_by\":\"anthropic\"},"
|
||||
"{\"id\":\"google/gemini-3-flash-preview\",\"object\":\"model\",\"created\":0,\"owned_by\":\"google\"},"
|
||||
"{\"id\":\"google/gemini-3.1-pro-preview\",\"object\":\"model\",\"created\":0,\"owned_by\":\"google\"},"
|
||||
"{\"id\":\"nvidia/nemotron-3-super-120b-a12b\",\"object\":\"model\",\"created\":0,\"owned_by\":\"nvidia\"},"
|
||||
"{\"id\":\"openai/gpt-5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
||||
"{\"id\":\"openai/gpt-5-mini\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
||||
"{\"id\":\"openai/gpt-5.1\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
||||
"{\"id\":\"openai/gpt-5.2\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
||||
"{\"id\":\"openai/gpt-5.4\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
||||
"{\"id\":\"openai/gpt-5.4-mini\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
||||
"{\"id\":\"openai/gpt-5.4-nano\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
||||
"{\"id\":\"openai/gpt-5.5\",\"object\":\"model\",\"created\":0,\"owned_by\":\"openai\"},"
|
||||
"{\"id\":\"perplexity/sonar\",\"object\":\"model\",\"created\":0,\"owned_by\":\"perplexity\"},"
|
||||
"{\"id\":\"xai/grok-4-1-fast-non-reasoning\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"},"
|
||||
"{\"id\":\"xai/grok-4.20-multi-agent\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"},"
|
||||
"{\"id\":\"xai/grok-4.20-non-reasoning\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"},"
|
||||
"{\"id\":\"xai/grok-4.20-reasoning\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"},"
|
||||
"{\"id\":\"xai/grok-4.3\",\"object\":\"model\",\"created\":0,\"owned_by\":\"xai\"}"
|
||||
"]}";
|
||||
|
||||
ai_parse_models_from_json(provider, json);
|
||||
|
||||
/* Verify all 23 models were parsed correctly */
|
||||
assert_int_equal(23, g_list_length(provider->models));
|
||||
|
||||
/* Verify NO field names or owned_by values are in the model list */
|
||||
/* The bug was that "id", "object", "model", "created", "owned_by" and provider names
|
||||
* like "anthropic", "google", "openai", "nvidia", "perplexity", "xai" were extracted */
|
||||
GList* models = provider->models;
|
||||
while (models) {
|
||||
const gchar* model = (const gchar*)models->data;
|
||||
/* These should never be model IDs */
|
||||
assert_false(g_strcmp0(model, "id") == 0);
|
||||
assert_false(g_strcmp0(model, "object") == 0);
|
||||
assert_false(g_strcmp0(model, "model") == 0);
|
||||
assert_false(g_strcmp0(model, "created") == 0);
|
||||
assert_false(g_strcmp0(model, "owned_by") == 0);
|
||||
assert_false(g_strcmp0(model, "list") == 0);
|
||||
/* These are owned_by values, not model IDs */
|
||||
assert_false(g_strcmp0(model, "anthropic") == 0);
|
||||
assert_false(g_strcmp0(model, "google") == 0);
|
||||
assert_false(g_strcmp0(model, "openai") == 0);
|
||||
assert_false(g_strcmp0(model, "nvidia") == 0);
|
||||
assert_false(g_strcmp0(model, "perplexity") == 0);
|
||||
assert_false(g_strcmp0(model, "xai") == 0);
|
||||
models = g_list_next(models);
|
||||
}
|
||||
|
||||
/* Verify first and last model IDs */
|
||||
models = provider->models;
|
||||
assert_string_equal("anthropic/claude-haiku-4-5", models->data);
|
||||
models = g_list_last(models);
|
||||
assert_string_equal("xai/grok-4.3", models->data);
|
||||
|
||||
ai_provider_unref(provider);
|
||||
ai_client_shutdown();
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_models_array_format(void** state)
|
||||
{
|
||||
/* Test parsing simple array format: ["model1", "model2", ...] */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
|
||||
const gchar* json = "[\"model1\", \"model2\", \"model3\"]";
|
||||
|
||||
ai_parse_models_from_json(provider, json);
|
||||
|
||||
assert_int_equal(3, g_list_length(provider->models));
|
||||
|
||||
GList* models = provider->models;
|
||||
assert_string_equal("model1", models->data);
|
||||
models = g_list_next(models);
|
||||
assert_string_equal("model2", models->data);
|
||||
models = g_list_next(models);
|
||||
assert_string_equal("model3", models->data);
|
||||
|
||||
ai_provider_unref(provider);
|
||||
ai_client_shutdown();
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_models_empty_json(void** state)
|
||||
{
|
||||
/* Test parsing empty/invalid JSON */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
|
||||
ai_parse_models_from_json(provider, "");
|
||||
assert_null(provider->models);
|
||||
assert_int_equal(0, g_list_length(provider->models));
|
||||
|
||||
ai_parse_models_from_json(provider, "{\"invalid\": true}");
|
||||
assert_null(provider->models);
|
||||
assert_int_equal(0, g_list_length(provider->models));
|
||||
|
||||
ai_provider_unref(provider);
|
||||
ai_client_shutdown();
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_models_null_handling(void** state)
|
||||
{
|
||||
/* Test NULL handling */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
|
||||
/* NULL json should not crash */
|
||||
ai_parse_models_from_json(provider, NULL);
|
||||
assert_null(provider->models);
|
||||
|
||||
ai_provider_unref(provider);
|
||||
ai_client_shutdown();
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_models_escaped_quotes(void** state)
|
||||
{
|
||||
/* Test parsing model IDs with escaped quotes */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
|
||||
const gchar* json = "{\"object\":\"list\","
|
||||
"\"data\":["
|
||||
"{\"id\":\"test/model\\\"with\\\"quotes\",\"object\":\"model\",\"created\":0,\"owned_by\":\"test\"}"
|
||||
"]}";
|
||||
|
||||
ai_parse_models_from_json(provider, json);
|
||||
|
||||
assert_int_equal(1, g_list_length(provider->models));
|
||||
/* Model ID should have the escaped quotes handled */
|
||||
GList* models = provider->models;
|
||||
assert_non_null(models);
|
||||
|
||||
ai_provider_unref(provider);
|
||||
ai_client_shutdown();
|
||||
}
|
||||
|
||||
void
|
||||
test_ai_parse_models_with_whitespace(void** state)
|
||||
{
|
||||
/* Test parsing JSON with whitespace after colons */
|
||||
ai_client_init();
|
||||
|
||||
AIProvider* provider = ai_add_provider("test", "https://test.api.com/v1", NULL);
|
||||
|
||||
/* JSON with spaces after colons (common in formatted JSON) */
|
||||
const gchar* json = "{ \"object\": \"list\", "
|
||||
"\"data\": [ "
|
||||
"{ \"id\": \"model-with-spaces\", \"object\": \"model\", \"created\": 0, \"owned_by\": \"test\" }"
|
||||
"]}";
|
||||
|
||||
ai_parse_models_from_json(provider, json);
|
||||
|
||||
assert_int_equal(1, g_list_length(provider->models));
|
||||
GList* models = provider->models;
|
||||
assert_string_equal("model-with-spaces", models->data);
|
||||
|
||||
ai_provider_unref(provider);
|
||||
ai_client_shutdown();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user