@@ -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