From b46e7f4377644a283b54546c1cb508a0c7896b55 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:44:59 +0800 Subject: [PATCH] fix(config): invalidate fields with missing env refs --- nanobot/config/loader.py | 15 +++++++++------ tests/providers/test_transcription.py | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index 4668be98..743ab8a0 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -121,15 +121,18 @@ def resolve_env_refs(value: str) -> str: """Resolve ``${VAR}`` references in a single string, leniently. Unlike :func:`resolve_config_env_vars` (which walks a whole ``Config`` and - raises on a missing variable), this resolves one value and substitutes an - empty string for any unset reference. It is meant for individual, lazily - consumed secret fields — e.g. a transcription provider's ``api_key`` — so a - missing variable degrades to "not configured" instead of sending the literal - ``${VAR}`` text to the provider. Non-string input is returned unchanged. + raises on a missing variable), this resolves one value and returns an empty + string if any reference is unset. It is meant for individual, lazily consumed + fields — e.g. a transcription provider's ``api_key`` or ``api_base`` — so a + missing variable degrades to "not configured" instead of producing a partial + value. Non-string input is returned unchanged. """ if not isinstance(value, str): return value - return _ENV_REF_PATTERN.sub(lambda m: os.environ.get(m.group(1), ""), value) + names = _ENV_REF_PATTERN.findall(value) + if any(name not in os.environ for name in names): + return "" + return _ENV_REF_PATTERN.sub(lambda m: os.environ[m.group(1)], value) def _resolve_in_place(obj: Any) -> Any: diff --git a/tests/providers/test_transcription.py b/tests/providers/test_transcription.py index 72256d8b..8ba08d23 100644 --- a/tests/providers/test_transcription.py +++ b/tests/providers/test_transcription.py @@ -185,6 +185,18 @@ def test_resolver_env_ref_missing_var_degrades_to_not_configured() -> None: assert resolved.configured is False +def test_resolver_missing_embedded_api_key_ref_degrades_to_not_configured() -> None: + config = Config() + config.transcription.provider = "groq" + config.providers.groq.api_key = "Bearer ${MISSING_GROQ_KEY}" + + with patch.dict(os.environ, {}, clear=True): + resolved = resolve_transcription_config(config) + + assert not resolved.api_key + assert resolved.configured is False + + def test_resolver_interpolates_env_ref_in_api_base() -> None: config = Config() config.transcription.provider = "groq" @@ -197,6 +209,18 @@ def test_resolver_interpolates_env_ref_in_api_base() -> None: assert resolved.api_base == "https://groq.example/v1" +def test_resolver_missing_embedded_api_base_ref_uses_provider_default() -> None: + config = Config() + config.transcription.provider = "groq" + config.providers.groq.api_key = "gsk-test" + config.providers.groq.api_base = "https://${MISSING_GROQ_HOST}/openai/v1" + + with patch.dict(os.environ, {}, clear=True): + resolved = resolve_transcription_config(config) + + assert resolved.api_base == "https://api.groq.com/openai/v1" + + def test_resolver_supports_xiaomi_mimo_transcription_provider() -> None: config = Config() config.transcription.provider = "xiaomi_mimo"