Skip to content

Commit 2c755c8

Browse files
author
Mateusz
committed
fix: support hyphenated backend names in _discover_api_keys_from_config_backends
getattr(backends, 'qwen-oauth') raises AttributeError because Pydantic v2 stores non-identifier extra fields in __pydantic_extra__. Fall back to get_named_backend_configs().get() when direct getattr returns None. Add regression tests with a _FakePydanticBackends helper that simulates Pydantic's AttributeError for hyphenated names.
1 parent 82612ee commit 2c755c8

2 files changed

Lines changed: 107 additions & 1 deletion

File tree

src/core/common/logging_utils.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -768,7 +768,18 @@ def _discover_api_keys_from_config_backends(
768768
# Iterate over registered backends and pull api_key fields
769769
for b in registered:
770770
try:
771-
bcfg = getattr(backends, b)
771+
bcfg = getattr(backends, b, None)
772+
if bcfg is None:
773+
# Hyphenated backend names (e.g. qwen-oauth) live in
774+
# __pydantic_extra__ and cannot be reached via getattr.
775+
named = (
776+
backends.get_named_backend_configs()
777+
if hasattr(backends, "get_named_backend_configs")
778+
else {}
779+
)
780+
bcfg = named.get(b)
781+
if bcfg is None:
782+
continue
772783
ak = getattr(bcfg, "api_key", None)
773784
if ak:
774785
# Map backend names to environment variables (handle exceptions)

tests/unit/core/common/test_logging_utils.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,3 +426,98 @@ def test_no_false_positive_list_keys_from_persistent_fallback(
426426
]
427427
assert not any("SECURITY WARNING" in w for w in warning_calls)
428428
assert key in found
429+
430+
431+
class _FakePydanticBackends:
432+
"""Mimics Pydantic BackendSettings with extra='allow' for hyphenated names.
433+
434+
getattr raises AttributeError for names containing '-', matching real
435+
Pydantic v2 behaviour for non-identifier field names. The real model
436+
exposes such fields only through get_named_backend_configs().
437+
"""
438+
439+
def __init__(self, named: dict[str, object]) -> None:
440+
self._named = named
441+
442+
def __getattr__(self, name: str):
443+
if name.startswith("_"):
444+
raise AttributeError(name)
445+
if "-" in name:
446+
raise AttributeError(
447+
f"'BackendSettings' object has no attribute '{name}'"
448+
)
449+
return MagicMock()
450+
451+
def get_named_backend_configs(self) -> dict[str, object]:
452+
return self._named
453+
454+
455+
class TestHyphenatedBackendNameSupport:
456+
"""Regression tests for backends with hyphenated names (e.g. qwen-oauth).
457+
458+
Pydantic v2 BackendSettings stores extra fields with hyphenated names in
459+
__pydantic_extra__; getattr(backends, 'qwen-oauth') raises AttributeError.
460+
The discovery function must use get_named_backend_configs() as a fallback.
461+
"""
462+
463+
def test_discovers_api_key_for_hyphenated_backend_name(self):
464+
"""getattr raises AttributeError for hyphenated names on Pydantic models;
465+
get_named_backend_configs() must be used as fallback."""
466+
key = "sk-hyphenated-backend-key"
467+
mock_backend = MagicMock()
468+
mock_backend.api_key = key
469+
470+
mock_backends = _FakePydanticBackends({"qwen-oauth": mock_backend})
471+
mock_config = MagicMock()
472+
mock_config.backends = mock_backends
473+
474+
with (
475+
patch(
476+
"src.core.services.backend_registry.backend_registry"
477+
) as mock_registry,
478+
patch("src.core.common.logging_utils._logged_security_warnings", new=set()),
479+
patch("src.core.common.logging_utils.get_logger") as mock_get_logger,
480+
patch.dict(os.environ, {"QWEN_OAUTH_API_KEY": key}, clear=False),
481+
patch(
482+
"src.core.common.env_utils.get_env_value_with_windows_persistent_fallback",
483+
side_effect=lambda _name, **_kw: (os.environ.get(_name), "process"),
484+
),
485+
):
486+
mock_registry.get_registered_backends.return_value = ["qwen-oauth"]
487+
mock_logger = MagicMock()
488+
mock_get_logger.return_value = mock_logger
489+
490+
found: set[str] = set()
491+
_discover_api_keys_from_config_backends(mock_config, found)
492+
493+
assert key in found, "API key from hyphenated backend must be discovered"
494+
495+
def test_no_crash_on_hyphenated_backend_name(self):
496+
"""The function must not crash when a registered backend has a
497+
hyphenated name and getattr raises AttributeError."""
498+
mock_backend_no_key = MagicMock()
499+
mock_backend_no_key.api_key = None
500+
mock_backends = _FakePydanticBackends({"qwen-oauth": mock_backend_no_key})
501+
mock_config = MagicMock()
502+
mock_config.backends = mock_backends
503+
504+
with (
505+
patch(
506+
"src.core.services.backend_registry.backend_registry"
507+
) as mock_registry,
508+
patch("src.core.common.logging_utils._logged_security_warnings", new=set()),
509+
patch("src.core.common.logging_utils.get_logger") as mock_get_logger,
510+
):
511+
mock_registry.get_registered_backends.return_value = ["qwen-oauth"]
512+
mock_logger = MagicMock()
513+
mock_get_logger.return_value = mock_logger
514+
515+
found: set[str] = set()
516+
# Must not raise - this was the original crash scenario
517+
_discover_api_keys_from_config_backends(mock_config, found)
518+
519+
# Should not log any "Skipping malformed backend config" debug error
520+
debug_calls = [
521+
call.args[0] for call in mock_logger.debug.call_args_list
522+
]
523+
assert not any("Skipping malformed" in msg for msg in debug_calls)

0 commit comments

Comments
 (0)