Files

1 line
13 KiB
JSON

{"repo": "9thlevelsoftware/hades-agent", "files": {"tests/hermes_cli/test_provider_config_validation.py": {"de43cf5ca542a7876f505f956b0bd0cfdd2d4b7b": {"t": 1785768184.8533564, "n": 11879, "h": "8a6d8626a7aacc1e", "c": "\"\"\"Tests for providers config entry validation and normalization.\n\nCovers Issue #9332: camelCase keys silently ignored, non-URL strings\naccepted as base_url, and unknown keys go unreported.\n\"\"\"\n\nimport logging\n\nimport pytest\n\nfrom hades_cli.config import (\n _PROVIDER_NORMALIZE_WARNED,\n _normalize_custom_provider_entry,\n)\n\n\nclass TestNormalizeCustomProviderEntry:\n \"\"\"Tests for _normalize_custom_provider_entry validation.\"\"\"\n\n @pytest.fixture(autouse=True)\n def _reset_warn_cache(self):\n \"\"\"The normalizer deduplicates its warnings via a process-lifetime\n cache; clear it around each test so warning assertions are independent\n of test order.\"\"\"\n _PROVIDER_NORMALIZE_WARNED.clear()\n yield\n _PROVIDER_NORMALIZE_WARNED.clear()\n\n def test_valid_entry_snake_case(self):\n \"\"\"Standard snake_case entry should normalize correctly.\"\"\"\n entry = {\n \"base_url\": \"https://api.example.com/v1\",\n \"api_key\": \"sk-test-key\",\n }\n result = _normalize_custom_provider_entry(entry, provider_key=\"myhost\")\n assert result is not None\n assert result[\"name\"] == \"myhost\"\n assert result[\"base_url\"] == \"https://api.example.com/v1\"\n assert result[\"api_key\"] == \"sk-test-key\"\n\n def test_camel_case_api_key_mapped(self):\n \"\"\"camelCase apiKey should be auto-mapped to api_key.\"\"\"\n entry = {\n \"base_url\": \"https://api.example.com/v1\",\n \"apiKey\": \"sk-test-key\",\n }\n result = _normalize_custom_provider_entry(entry, provider_key=\"myhost\")\n assert result is not None\n assert result[\"api_key\"] == \"sk-test-key\"\n\n def test_camel_case_base_url_mapped(self):\n \"\"\"camelCase baseUrl should be auto-mapped to base_url.\"\"\"\n entry = {\n \"baseUrl\": \"https://api.example.com/v1\",\n \"api_key\": \"sk-test-key\",\n }\n result = _normalize_custom_provider_entry(entry, provider_key=\"myhost\")\n assert result is not None\n assert result[\"base_url\"] == \"https://api.example.com/v1\"\n\n def test_non_url_api_field_rejected(self):\n \"\"\"Non-URL string in 'api' field should be skipped with a warning.\"\"\"\n entry = {\n \"api\": \"openai-reverse-proxy\",\n \"api_key\": \"sk-test-key\",\n }\n result = _normalize_custom_provider_entry(entry, provider_key=\"nvidia\")\n # Should return None because no valid URL was found\n assert result is None\n\n def test_valid_url_in_api_field_accepted(self):\n \"\"\"Valid URL in 'api' field should still be accepted.\"\"\"\n entry = {\n \"api\": \"https://integrate.api.nvidia.com/v1\",\n \"api_key\": \"sk-test-key\",\n }\n result = _normalize_custom_provider_entry(entry, provider_key=\"nvidia\")\n assert result is not None\n assert result[\"base_url\"] == \"https://integrate.api.nvidia.com/v1\"\n\n def test_base_url_preferred_over_api(self):\n \"\"\"base_url should be checked before api field.\"\"\"\n entry = {\n \"base_url\": \"https://correct.example.com/v1\",\n \"api\": \"https://wrong.example.com/v1\",\n \"api_key\": \"sk-test-key\",\n }\n result = _normalize_custom_provider_entry(entry, provider_key=\"test\")\n assert result is not None\n assert result[\"base_url\"] == \"https://correct.example.com/v1\"\n\n def test_unknown_keys_logged(self, caplog):\n \"\"\"Unknown config keys should produce a warning.\"\"\"\n entry = {\n \"base_url\": \"https://api.example.com/v1\",\n \"api_key\": \"***\",\n \"unknownField\": \"value\",\n \"anotherBad\": 42,\n }\n with caplog.at_level(logging.WARNING):\n result = _normalize_custom_provider_entry(entry, provider_key=\"test\")\n assert result is not None\n assert any(\"unknown config keys\" in r.message.lower() for r in caplog.records)\n\n def test_provider_key_not_flagged_unknown(self, caplog):\n \"\"\"A redundant ``provider`` key (written by Hades' own config writer)\n must be accepted silently — not reported as an unknown key. Regression\n for the config warn-storm that deadlocked Windows logging.\"\"\"\n entry = {\n \"provider\": \"\",\n \"base_url\": \"https://api.example.com/v1\",\n \"api_key\": \"***\",\n }\n with caplog.at_level(logging.WARNING):\n result = _normalize_custom_provider_entry(entry, provider_key=\"onyx-6000\")\n assert result is not None\n assert not any(\"unknown config keys\" in r.message.lower() for r in caplog.records)\n\n def test_unknown_keys_warned_once_per_signature(self, caplog):\n \"\"\"Repeated normalization of the same entry (as happens on every\n picker/inventory load) must warn only once — otherwise the warning\n storms the log handler. Fix B.\"\"\"\n entry = {\n \"base_url\": \"https://api.example.com/v1\",\n \"api_key\": \"***\",\n \"unknownField\": \"value\",\n }\n with caplog.at_level(logging.WARNING):\n for _ in range(5):\n _normalize_custom_provider_entry(\n dict(entry), provider_key=\"test\"\n )\n unknown_warnings = [\n r for r in caplog.records\n if \"unknown config keys\" in r.message.lower()\n ]\n assert len(unknown_warnings) == 1\n\n def test_timeout_keys_not_flagged_unknown(self, caplog):\n \"\"\"request_timeout_seconds and stale_timeout_seconds should not produce warnings.\"\"\"\n entry = {\n \"base_url\": \"https://api.example.com/v1\",\n \"api_key\": \"***\",\n \"request_timeout_seconds\": 300,\n \"stale_timeout_seconds\": 900,\n }\n with caplog.at_level(logging.WARNING):\n result = _normalize_custom_provider_entry(entry, provider_key=\"test\")\n assert result is not None\n assert not any(\"unknown config keys\" in r.message.lower() for r in caplog.records)\n\n def test_camel_case_warning_logged(self, caplog):\n \"\"\"camelCase alias mapping should produce a warning.\"\"\"\n entry = {\n \"baseUrl\": \"https://api.example.com/v1\",\n \"apiKey\": \"sk-test-key\",\n }\n with caplog.at_level(logging.WARNING):\n result = _normalize_custom_provider_entry(entry, provider_key=\"test\")\n assert result is not None\n camel_warnings = [r for r in caplog.records if \"camelcase\" in r.message.lower() or \"auto-mapped\" in r.message.lower()]\n assert len(camel_warnings) >= 1\n\n def test_snake_case_takes_precedence_over_camel(self):\n \"\"\"If both snake_case and camelCase exist, snake_case wins.\"\"\"\n entry = {\n \"api_key\": \"snake-key\",\n \"apiKey\": \"camel-key\",\n \"base_url\": \"https://api.example.com/v1\",\n }\n result = _normalize_custom_provider_entry(entry, provider_key=\"test\")\n assert result is not None\n assert result[\"api_key\"] == \"snake-key\"\n\n def test_non_dict_returns_none(self):\n \"\"\"Non-dict entry should return None.\"\"\"\n assert _normalize_custom_provider_entry(\"not-a-dict\") is None\n assert _normalize_custom_provider_entry(42) is None\n assert _normalize_custom_provider_entry(None) is None\n\n def test_no_url_returns_none(self):\n \"\"\"Entry with no valid URL in any field should return None.\"\"\"\n entry = {\n \"api_key\": \"sk-test-key\",\n }\n result = _normalize_custom_provider_entry(entry, provider_key=\"test\")\n assert result is None\n\n def test_no_name_returns_none(self):\n \"\"\"Entry with no name and no provider_key should return None.\"\"\"\n entry = {\n \"base_url\": \"https://api.example.com/v1\",\n }\n result = _normalize_custom_provider_entry(entry, provider_key=\"\")\n assert result is None\n\n def test_models_list_converted_to_dict(self):\n \"\"\"List-format models should be preserved as an empty-value dict so\n /model picks them up instead of showing the provider with (0) models.\"\"\"\n entry = {\n \"name\": \"tencent-coding-plan\",\n \"base_url\": \"https://api.lkeap.cloud.tencent.com/coding/v3\",\n \"models\": [\"glm-5\", \"kimi-k2.5\", \"minimax-m2.5\"],\n }\n result = _normalize_custom_provider_entry(entry)\n assert result is not None\n assert result[\"models\"] == {\"glm-5\": {}, \"kimi-k2.5\": {}, \"minimax-m2.5\": {}}\n\n def test_models_dict_preserved(self):\n \"\"\"Dict-format models should pass through unchanged.\"\"\"\n entry = {\n \"name\": \"acme\",\n \"base_url\": \"https://api.example.com/v1\",\n \"models\": {\"gpt-foo\": {\"context_length\": 32000}},\n }\n result = _normalize_custom_provider_entry(entry)\n assert result is not None\n assert result[\"models\"] == {\"gpt-foo\": {\"context_length\": 32000}}\n\n def test_models_list_filters_empty_and_non_string(self):\n \"\"\"List entries that are empty strings or non-strings are skipped.\"\"\"\n entry = {\n \"name\": \"acme\",\n \"base_url\": \"https://api.example.com/v1\",\n \"models\": [\"valid\", \"\", None, 42, \" \", \"also-valid\"],\n }\n result = _normalize_custom_provider_entry(entry)\n assert result is not None\n assert result[\"models\"] == {\"valid\": {}, \"also-valid\": {}}\n\n def test_models_empty_list_omitted(self):\n \"\"\"Empty list (falsy) should not produce a models key.\"\"\"\n entry = {\n \"name\": \"acme\",\n \"base_url\": \"https://api.example.com/v1\",\n \"models\": [],\n }\n result = _normalize_custom_provider_entry(entry)\n assert result is not None\n assert \"models\" not in result\n\n def test_env_var_placeholder_in_base_url_not_rejected(self):\n \"\"\"A base_url that is an un-expanded ${ENV_VAR} placeholder must not be\n rejected as an invalid URL — it is expanded at runtime, so a caller\n reaching this normalizer with raw config would otherwise see the\n provider silently dropped. Regression test for #14457.\"\"\"\n entry = {\n \"name\": \"PROVIDER_A\",\n \"base_url\": \"${PROVIDER_A_BASE_URL}\",\n \"key_env\": \"PROVIDER_A_API_KEY\",\n }\n result = _normalize_custom_provider_entry(entry, provider_key=\"PROVIDER_A\")\n assert result is not None\n assert result[\"base_url\"] == \"${PROVIDER_A_BASE_URL}\"\n\n def test_multiple_env_vars_in_base_url(self):\n \"\"\"base_url with multiple ${VAR} placeholders is accepted verbatim.\"\"\"\n entry = {\n \"name\": \"multi-var-provider\",\n \"base_url\": \"${SCHEME}://${HOST}:${PORT}/v1\",\n }\n result = _normalize_custom_provider_entry(entry)\n assert result is not None\n assert result[\"base_url\"] == \"${SCHEME}://${HOST}:${PORT}/v1\"\n\n def test_bare_brace_region_placeholder_accepted(self):\n \"\"\"A bare {region}-style template token (not an env-ref) is also\n accepted without validation, supporting region-substitution URLs.\"\"\"\n entry = {\n \"name\": \"regional\",\n \"base_url\": \"https://{region}.api.example.com/v1\",\n }\n result = _normalize_custom_provider_entry(entry, provider_key=\"regional\")\n assert result is not None\n assert result[\"base_url\"] == \"https://{region}.api.example.com/v1\"\n\n def test_invalid_url_without_placeholder_still_rejected(self):\n \"\"\"A malformed URL with no scheme/host AND no placeholder token is\n still rejected — the placeholder bypass must not weaken validation of\n ordinary literal URLs.\"\"\"\n entry = {\n \"name\": \"bad\",\n \"base_url\": \"not-a-url\",\n }\n result = _normalize_custom_provider_entry(entry, provider_key=\"bad\")\n assert result is None\n"}}}}