diff --git a/AGENTS.md b/AGENTS.md index 63e3a5d8..0fbd97c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,6 +162,15 @@ Seed byte-preservation installer tests with explicit LF and CRLF bytes, not text-mode writes that translate newlines. Assert the original prefix survives the first install and the entire file is identical after a repeat install. +## Lightweight provider configuration + +Model discovery and login must expand an independent full configuration copy +once with `lib.env_vars.expand_env_vars`, before deriving constructor arguments. +Do not discard saved account settings or expand returned environment values a +second time. Keep the helper lightweight; importing `runtime.config` from the +provider loader creates a cycle. Run `tests/test_provider_loader_configuration.py` +and `tests/test_env_vars.py` for this boundary. + ## Interactive control-flow exits REPL exit commands return an action from `CommandProcessor`; only the normal diff --git a/amplifier_app_cli/lib/env_vars.py b/amplifier_app_cli/lib/env_vars.py new file mode 100644 index 00000000..a7bbe920 --- /dev/null +++ b/amplifier_app_cli/lib/env_vars.py @@ -0,0 +1,28 @@ +"""Environment expansion shared by session setup and lightweight providers.""" + +import os +import re +from typing import Any + + +ENV_PATTERN = re.compile(r"\$\{([^}:]+)(?::([^}]*))?}") + + +def expand_env_vars(config: dict[str, Any]) -> dict[str, Any]: + """Expand ${VAR} references within configuration values.""" + + def replace_value(value: Any) -> Any: + if isinstance(value, str): + return ENV_PATTERN.sub(_replace_match, value) + if isinstance(value, dict): + return {k: replace_value(v) for k, v in value.items()} + if isinstance(value, list): + return [replace_value(item) for item in value] + return value + + def _replace_match(match: re.Match[str]) -> str: + var_name = match.group(1) + default = match.group(2) + return os.environ.get(var_name, default if default is not None else "") + + return replace_value(config) \ No newline at end of file diff --git a/amplifier_app_cli/provider_loader.py b/amplifier_app_cli/provider_loader.py index 565b4ccf..48268243 100644 --- a/amplifier_app_cli/provider_loader.py +++ b/amplifier_app_cli/provider_loader.py @@ -15,6 +15,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +from .lib.env_vars import expand_env_vars from .provider_diagnostics import invoke_list_models if TYPE_CHECKING: @@ -310,19 +311,21 @@ def _try_instantiate_provider( Provider instance, or None when its signature cannot accept the config. Provider constructor validation/runtime errors propagate unchanged. """ - collected_config = collected_config or {} + # Match session expansion before either constructor handoff. Expand once: + # values returned by the environment may themselves contain literal ${...}. + # Deepcopy also isolates mutable values the expander does not traverse. + collected_config = expand_env_vars(deepcopy(collected_config or {})) - # Extract connection values from collected config - # Resolve ${VAR} placeholders to actual environment values + # Derive standalone arguments from the same expanded configuration. raw_base_url = collected_config.get("base_url") or collected_config.get( "azure_endpoint" ) raw_host = collected_config.get("host") raw_api_key = collected_config.get("api_key") - base_url = _resolve_env_placeholder(raw_base_url) or "http://placeholder" - host = _resolve_env_placeholder(raw_host) or "http://localhost:11434" - api_key = _resolve_env_placeholder(raw_api_key) or "" + base_url = raw_base_url or "http://placeholder" + host = raw_host or "http://localhost:11434" + api_key = raw_api_key or "" # Bind before construction: a TypeError *inside* a valid constructor is # a provider failure, not permission to retry with different/default @@ -344,7 +347,7 @@ def _try_instantiate_provider( # Some constructors normalize nested config in place. Discovery and # login must not mutate the caller's saved configuration or the # values the wizard later persists. - kwargs["config"] = deepcopy(collected_config) + kwargs["config"] = collected_config elif collected_config: # Metadata-only, no-argument facades remain usable for get_info(), # but cannot act as a configured provider for login/model discovery. diff --git a/amplifier_app_cli/runtime/config.py b/amplifier_app_cli/runtime/config.py index a752e4ad..a074f19c 100644 --- a/amplifier_app_cli/runtime/config.py +++ b/amplifier_app_cli/runtime/config.py @@ -6,7 +6,6 @@ import copy import logging import os -import re from pathlib import Path from typing import TYPE_CHECKING from typing import Any @@ -15,6 +14,7 @@ from rich.console import Console from ..lib.bundle_loader.discovery import WELL_KNOWN_BUNDLES +from ..lib.env_vars import ENV_PATTERN, expand_env_vars from ..lib.settings import AppSettings, NotificationFlags, get_custom_routing_dir from ..lib.merge_utils import merge_module_items from ..lib.merge_utils import merge_tool_configs @@ -1216,9 +1216,6 @@ def _merge_module_lists( return result -ENV_PATTERN = re.compile(r"\$\{([^}:]+)(?::([^}]*))?}") - - async def _validate_provider_credentials( providers: list[Any], *, @@ -1228,7 +1225,7 @@ async def _validate_provider_credentials( """Fail loudly, before session mount, when a provider instance's configured credential placeholder resolves to nothing. - Why this exists: ``expand_env_vars`` (below) treats an unset ``${VAR}`` + Why this exists: ``expand_env_vars`` treats an unset ``${VAR}`` as an empty string. Several provider modules treat an empty/absent ``api_key`` config value as "not configured" and fall back to their own canonical ambient env var (e.g. ``OPENAI_API_KEY``). For a *separate* @@ -1329,26 +1326,6 @@ async def _validate_provider_credentials( ) -def expand_env_vars(config: dict[str, Any]) -> dict[str, Any]: - """Expand ${VAR} references within configuration values.""" - - def replace_value(value: Any) -> Any: - if isinstance(value, str): - return ENV_PATTERN.sub(_replace_match, value) - if isinstance(value, dict): - return {k: replace_value(v) for k, v in value.items()} - if isinstance(value, list): - return [replace_value(item) for item in value] - return value - - def _replace_match(match: re.Match[str]) -> str: - var_name = match.group(1) - default = match.group(2) - return os.environ.get(var_name, default if default is not None else "") - - return replace_value(config) - - def inject_user_providers(config: dict, prepared_bundle: "PreparedBundle") -> None: """Inject user-configured providers into bundle's mount plan. diff --git a/tests/test_env_vars.py b/tests/test_env_vars.py new file mode 100644 index 00000000..7bdbb11e --- /dev/null +++ b/tests/test_env_vars.py @@ -0,0 +1,104 @@ +"""Pin the session expansion contract reused by lightweight provider loading.""" + +from copy import deepcopy + +import pytest + +from amplifier_app_cli.lib.env_vars import ENV_PATTERN, expand_env_vars + + +def test_runtime_retains_the_shared_expansion_exports(): + from amplifier_app_cli.runtime import config + + assert config.expand_env_vars is expand_env_vars + assert config.ENV_PATTERN is ENV_PATTERN + + +@pytest.mark.parametrize( + ("env_value", "value", "expected"), + [ + (None, "${FIXTURE_VALUE}", ""), + (None, "${FIXTURE_VALUE:default}", "default"), + (None, "${FIXTURE_VALUE:}", ""), + ("present", "${FIXTURE_VALUE:default}", "present"), + ("", "${FIXTURE_VALUE}", ""), + ("", "${FIXTURE_VALUE:default}", ""), + # ':' supplies a default; shell-style ':-' includes the '-' literally. + (None, "${FIXTURE_VALUE:-default}", "-default"), + ("", "${FIXTURE_VALUE:-default}", ""), + ( + None, + "${FIXTURE_VALUE:https://example.invalid:8443/v1}", + "https://example.invalid:8443/v1", + ), + ("present", "$FIXTURE_VALUE", "$FIXTURE_VALUE"), + ("present", "${FIXTURE_VALUE", "${FIXTURE_VALUE"), + ("present", "${}", "${}"), + ("present", "$${FIXTURE_VALUE}", "$present"), + ], +) +def test_expand_env_vars_placeholder_defaults_and_empty_values( + monkeypatch, env_value, value, expected +): + monkeypatch.delenv("FIXTURE_VALUE", raising=False) + if env_value is not None: + monkeypatch.setenv("FIXTURE_VALUE", env_value) + config = {"value": value} + original = deepcopy(config) + assert expand_env_vars(config) == {"value": expected} + assert config == original + + +def test_expand_env_vars_walks_dict_and_list_values_not_keys_or_tuples(monkeypatch): + monkeypatch.setenv("FIXTURE_VALUE", "expanded") + monkeypatch.setenv("FIXTURE_HOST", "example.invalid") + monkeypatch.delenv("FIXTURE_UNKNOWN", raising=False) + config = { + "${FIXTURE_VALUE}": { + "values": [ + "${FIXTURE_VALUE}", + {"endpoint": "https://${FIXTURE_HOST}/${FIXTURE_VALUE}/v1"}, + ["${FIXTURE_UNKNOWN}", "${FIXTURE_VALUE}"], + ] + }, + "tuple": ("${FIXTURE_VALUE}", ["${FIXTURE_VALUE}"]), + "bool": False, + "int": 7, + "float": 1.5, + "none": None, + } + original = deepcopy(config) + expanded = expand_env_vars(config) + assert expanded == { + "${FIXTURE_VALUE}": { + "values": [ + "expanded", + {"endpoint": "https://example.invalid/expanded/v1"}, + ["", "expanded"], + ] + }, + "tuple": ("${FIXTURE_VALUE}", ["${FIXTURE_VALUE}"]), + "bool": False, + "int": 7, + "float": 1.5, + "none": None, + } + assert expanded["tuple"] is config["tuple"] + for key in ("bool", "int", "float", "none"): + assert type(expanded[key]) is type(config[key]) + assert config == original + + +def test_expand_env_vars_substitutes_each_original_placeholder_only_once(monkeypatch): + monkeypatch.setenv("FIXTURE_FIRST", "${FIXTURE_SECOND}") + monkeypatch.setenv("FIXTURE_SECOND", "second-value") + config = { + "value": "prefix-${FIXTURE_FIRST}-${FIXTURE_SECOND}", + "nested": ["${FIXTURE_FIRST}"], + } + original = deepcopy(config) + assert expand_env_vars(config) == { + "value": "prefix-${FIXTURE_SECOND}-second-value", + "nested": ["${FIXTURE_SECOND}"], + } + assert config == original \ No newline at end of file diff --git a/tests/test_provider_instance_credentials.py b/tests/test_provider_instance_credentials.py index 0d7ec9a6..35e07f0b 100644 --- a/tests/test_provider_instance_credentials.py +++ b/tests/test_provider_instance_credentials.py @@ -2355,8 +2355,9 @@ def test_no_kernel_level_env_var_rederivation(): docs/designs/provider-instance-credentials.md §3. Scoped to the specific runtime-resolution functions §3 identified - (``expand_env_vars`` in runtime/config.py, ``_resolve_env_placeholder`` - in provider_loader.py) rather than a whole-file grep: provider_loader.py + (the shared ``expand_env_vars`` helper and provider construction, plus the + legacy ``_resolve_env_placeholder`` compatibility helper) rather than a + whole-file grep: provider_loader.py legitimately *defines* and uses ``get_provider_info`` elsewhere in the file for wizard/prompt-time field derivation (§3: "type-level declarations consumed at authoring/prompt time only") -- a whole-file @@ -2364,11 +2365,14 @@ def test_no_kernel_level_env_var_rederivation(): """ import inspect - from amplifier_app_cli.provider_loader import _resolve_env_placeholder + from amplifier_app_cli.provider_loader import ( + _resolve_env_placeholder, + _try_instantiate_provider, + ) from amplifier_app_cli.runtime.config import expand_env_vars forbidden = ("get_provider_info", "ConfigField", "credential_env_vars") - for fn in (expand_env_vars, _resolve_env_placeholder): + for fn in (expand_env_vars, _try_instantiate_provider, _resolve_env_placeholder): source = inspect.getsource(fn) for name in forbidden: assert name not in source, ( diff --git a/tests/test_provider_loader_configuration.py b/tests/test_provider_loader_configuration.py index b74e1e79..8739fd94 100644 --- a/tests/test_provider_loader_configuration.py +++ b/tests/test_provider_loader_configuration.py @@ -1,5 +1,6 @@ """Configured discovery must use the same settings as login and runtime.""" +import os from copy import deepcopy from types import SimpleNamespace from typing import ClassVar @@ -11,6 +12,7 @@ from amplifier_app_cli import provider_config_utils as wizard from amplifier_app_cli import provider_loader as loader from amplifier_app_cli.lib.settings import AppSettings, SettingsPaths +from amplifier_app_cli.runtime.config import expand_env_vars @pytest.fixture(autouse=True) @@ -47,27 +49,143 @@ def test_config_reaches_each_constructor_and_is_independent( provider_class, monkeypatch ): monkeypatch.setenv("FIXTURE_KEY", "fixture-key") + monkeypatch.setenv("FIXTURE_ENDPOINT", "https://example.invalid/v1") + monkeypatch.setenv("FIXTURE_HOST", "https://host.example.invalid") + monkeypatch.setenv("FIXTURE_OPTION", "selected-option") config = { "auth_mode": "alternate", "api_key": "${FIXTURE_KEY}", - "base_url": "https://endpoint.invalid/v1", - "host": "https://host.invalid", + "base_url": "${FIXTURE_ENDPOINT}", + "host": "${FIXTURE_HOST}", "token_file_path": "/fixture/account.json", - "options": {"values": [1]}, + "options": {"values": [1, "${FIXTURE_OPTION}"]}, } original = deepcopy(config) + expected = expand_env_vars(deepcopy(original)) provider = loader._try_instantiate_provider(provider_class, config) - assert provider.config == config + assert provider.config == expected if hasattr(provider, "api_key"): assert provider.api_key == "fixture-key" if hasattr(provider, "base_url"): - assert provider.base_url == config["base_url"] + assert provider.base_url == expected["base_url"] if hasattr(provider, "host"): - assert provider.host == config["host"] + assert provider.host == expected["host"] provider.config["options"]["values"].append(2) assert config == original +@pytest.mark.parametrize( + "endpoint", + ["${FIXTURE_ENDPOINT}", "https://${FIXTURE_HOST}/v1"], + ids=["whole-reference", "interpolated"], +) +def test_openai_shaped_constructor_expands_config_and_preserves_mutable_values( + monkeypatch, endpoint +): + monkeypatch.setenv("FIXTURE_KEY", "fixture-key") + monkeypatch.setenv("FIXTURE_ENDPOINT", "https://example.invalid/v1") + monkeypatch.setenv("FIXTURE_HOST", "example.invalid") + monkeypatch.setenv("FIXTURE_OPTION", "selected-option") + config = { + "api_key": "${FIXTURE_KEY}", + "base_url": endpoint, + "interpolated_endpoint": "https://${FIXTURE_HOST}/models", + "options": { + "values": ["${FIXTURE_OPTION}", {"endpoint": "${FIXTURE_ENDPOINT}"}] + }, + # Expansion intentionally does not walk tuples; deepcopy must still + # protect the mutable list contained in this unsupported container. + "opaque": (["${FIXTURE_OPTION}"],), + } + original = deepcopy(config) + expected = expand_env_vars(deepcopy(original)) + attempts = [] + + class OpenAIShapedProvider: + def __init__(self, api_key=None, config=None): + self.api_key = api_key + self.base_url = config["base_url"] + self.received = deepcopy(config) + attempts.append(self.received) + config["options"]["values"].append("constructor-added") + config["options"]["values"][1]["endpoint"] = "constructor-changed" + config["opaque"][0].append("constructor-added") + + provider = loader._try_instantiate_provider(OpenAIShapedProvider, config) + assert config == original + assert len(attempts) == 1 + assert provider.base_url == "https://example.invalid/v1" + assert provider.api_key == "fixture-key" + assert provider.received == expected + assert provider.received["opaque"] == (["${FIXTURE_OPTION}"],) + + +@pytest.mark.parametrize("provider_class", [EndpointProvider, HostProvider]) +def test_connection_arguments_and_config_share_single_pass_expansion( + provider_class, monkeypatch +): + monkeypatch.setenv("FIXTURE_ENDPOINT", "https://example.invalid/${SECOND}") + monkeypatch.setenv("FIXTURE_HOST", "https://host.example.invalid/${SECOND}") + # An entire placeholder returned by getenv catches the legacy standalone + # resolver being applied a second time after shared config expansion. + monkeypatch.setenv("FIXTURE_KEY", "${SECOND}") + monkeypatch.setenv("SECOND", "must-not-be-substituted-again") + monkeypatch.delenv("FIXTURE_MISSING", raising=False) + monkeypatch.setenv("FIXTURE_EMPTY", "") + config = { + "base_url": "${FIXTURE_ENDPOINT}", + "host": "${FIXTURE_HOST}", + "api_key": "${FIXTURE_KEY}", + "options": { + "missing": "${FIXTURE_MISSING}", + "default": "${FIXTURE_MISSING:default}", + "empty": "${FIXTURE_EMPTY:default}", + }, + } + original = deepcopy(config) + expected = expand_env_vars(deepcopy(original)) + provider = loader._try_instantiate_provider(provider_class, config) + assert config == original + if hasattr(provider, "base_url"): + assert provider.base_url == "https://example.invalid/${SECOND}" + assert provider.base_url == expected["base_url"] + if hasattr(provider, "host"): + assert provider.host == "https://host.example.invalid/${SECOND}" + assert provider.host == expected["host"] + assert provider.api_key == "${SECOND}" + assert provider.api_key == expected["api_key"] + assert provider.config == expected + assert provider.config["options"] == { + "missing": "", + "default": "default", + "empty": "", + } + + +def test_configured_environment_bindings_win_over_ambient_sdk_defaults(monkeypatch): + monkeypatch.setenv("FIXTURE_ENDPOINT", "https://selected.example.invalid/v1") + monkeypatch.setenv("FIXTURE_KEY", "selected-fixture-key") + monkeypatch.setenv("OPENAI_BASE_URL", "https://ambient.example.invalid/v1") + monkeypatch.setenv("OPENAI_API_KEY", "ambient-fixture-key") + config = {"base_url": "${FIXTURE_ENDPOINT}", "api_key": "${FIXTURE_KEY}"} + original = deepcopy(config) + attempts = [] + + class SDKShapedProvider: + def __init__(self, api_key=None, config=None): + self.config = config + self.base_url = config.get("base_url") or os.environ["OPENAI_BASE_URL"] + self.api_key = api_key or os.environ["OPENAI_API_KEY"] + attempts.append(deepcopy(config)) + + provider = loader._try_instantiate_provider(SDKShapedProvider, config) + assert config == original + assert len(attempts) == 1 + assert provider.base_url == "https://selected.example.invalid/v1" + assert provider.api_key == "selected-fixture-key" + assert provider.config == expand_env_vars(deepcopy(original)) + + @pytest.mark.parametrize("error_type", [ValueError, RuntimeError, TypeError]) def test_constructor_validation_never_retries_using_defaults(error_type): attempts = [] @@ -120,15 +238,27 @@ def __init__(self, **kwargs): def test_catalog_uses_the_supplied_config(monkeypatch): + monkeypatch.setenv("FIXTURE_CATALOG", "selected-catalog") + events = [] + class CatalogProvider(ConfigProvider): async def list_models(self): + events.append(("models", deepcopy(self.config))) return [SimpleNamespace(id=self.config["catalog"])] + async def close(self): + events.append(("close", deepcopy(self.config))) + monkeypatch.setattr(loader, "load_provider_class", lambda _: CatalogProvider) - models = loader.get_provider_models( - "fixture", collected_config={"catalog": "selected-catalog"} - ) + config = {"catalog": "${FIXTURE_CATALOG}"} + original = deepcopy(config) + models = loader.get_provider_models("fixture", collected_config=config) + assert config == original assert [m.id for m in models] == ["selected-catalog"] + assert events == [ + ("models", {"catalog": "selected-catalog"}), + ("close", {"catalog": "selected-catalog"}), + ] def test_azure_endpoint_alias_is_resolved(monkeypatch): @@ -139,6 +269,42 @@ def test_azure_endpoint_alias_is_resolved(monkeypatch): assert provider.base_url == "https://azure.invalid" +@pytest.mark.parametrize("env_value", [None, ""]) +def test_absent_connection_bindings_follow_runtime_expansion(monkeypatch, env_value): + for name in ("FIXTURE_KEY", "FIXTURE_ENDPOINT", "FIXTURE_HOST"): + monkeypatch.delenv(name, raising=False) + if env_value is not None: + monkeypatch.setenv(name, env_value) + config = { + "api_key": "${FIXTURE_KEY}", + "base_url": "${FIXTURE_ENDPOINT}", + "host": "${FIXTURE_HOST}", + } + original = deepcopy(config) + provider = loader._try_instantiate_provider(EndpointProvider, config) + assert provider.config == expand_env_vars(original) + assert provider.config == {"api_key": "", "base_url": "", "host": ""} + assert provider.api_key == "" + assert provider.base_url == "http://placeholder" + assert config == original + # This pins normalization only. Session credential validation and a + # provider/SDK's treatment of empty credentials remain separate boundaries. + + +def test_expanded_empty_primary_endpoint_uses_supplied_azure_alias(monkeypatch): + monkeypatch.delenv("FIXTURE_PRIMARY", raising=False) + monkeypatch.setenv("FIXTURE_ALIAS", "https://azure.example.invalid") + config = { + "base_url": "${FIXTURE_PRIMARY}", + "azure_endpoint": "${FIXTURE_ALIAS}", + } + original = deepcopy(config) + provider = loader._try_instantiate_provider(EndpointProvider, config) + assert provider.base_url == "https://azure.example.invalid" + assert provider.config == expand_env_vars(original) + assert config == original + + class OAuthProvider(ConfigProvider): events: ClassVar[list] = [] @@ -248,6 +414,8 @@ def test_login_uses_named_instance_even_when_module_has_multiple_accounts( local_settings=tmp_path / "local.yaml", ) ) + monkeypatch.setenv("FIXTURE_ACCOUNT_PATH", str(tmp_path / f"{name}.json")) + monkeypatch.setenv("FIXTURE_OTHER_ACCOUNT_PATH", str(tmp_path / "other.json")) monkeypatch.setattr(loader, "load_provider_class", lambda _: OAuthProvider) settings._write_scope( "global", @@ -257,13 +425,24 @@ def test_login_uses_named_instance_even_when_module_has_multiple_accounts( { "id": account, "module": "provider-fixture", - "config": {"account": account}, + "config": { + "account": account, + "token_file_path": ( + "${FIXTURE_ACCOUNT_PATH}" + if account == name + else "${FIXTURE_OTHER_ACCOUNT_PATH}" + ), + }, } for account in ("first-account", "second-account") ] } }, ) + original = deepcopy(settings.get_provider_overrides()) + original_bytes = settings.paths.global_settings.read_bytes() + selected_config = next(entry["config"] for entry in original if entry["id"] == name) + expected = expand_env_vars(deepcopy(selected_config)) OAuthProvider.events = [] with ( patch( @@ -282,8 +461,114 @@ def test_login_uses_named_instance_even_when_module_has_multiple_accounts( assert result.exit_code == 0, result.output installed.assert_called_once_with("provider-fixture") load.assert_called_once_with("provider-fixture") - assert ("login", {"account": name}) in OAuthProvider.events - assert all(config == {"account": name} for _, config in OAuthProvider.events) + assert settings.paths.global_settings.read_bytes() == original_bytes + assert settings.get_provider_overrides() == original + assert selected_config["token_file_path"] == "${FIXTURE_ACCOUNT_PATH}" + assert OAuthProvider.events == [ + ("status", expected), + ("login", expected), + ("status", expected), + ] + + +def test_models_command_uses_exact_instance_and_expanded_config_through_real_loader( + tmp_path, monkeypatch +): + from amplifier_app_cli.commands.provider import provider + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("FIXTURE_ENDPOINT", "https://selected.example.invalid/v1") + monkeypatch.setenv("FIXTURE_KEY", "selected-fixture-key") + monkeypatch.setenv("FIXTURE_CATALOG", "selected-model") + monkeypatch.setenv("FIXTURE_OTHER_KEY", "other-fixture-key") + events = [] + loaded_modules = [] + + class OpenAIShapedProvider: + def __init__(self, api_key=None, config=None): + self.api_key = api_key + self.config = config + self.base_url = config.get("base_url") + events.append(("construct", api_key, self.base_url, deepcopy(config))) + + def get_info(self): + return SimpleNamespace(display_name="Fixture OpenAI", config_fields=[]) + + async def list_models(self): + events.append( + ("models", self.api_key, self.base_url, deepcopy(self.config)) + ) + return [ + SimpleNamespace( + id=self.config["catalog"], + display_name="Fixture model", + context_window=1024, + max_output_tokens=128, + capabilities=[], + ) + ] + + async def close(self): + events.append( + ("close", self.api_key, self.base_url, deepcopy(self.config)) + ) + + def load_provider_class(module_id): + loaded_modules.append(module_id) + return OpenAIShapedProvider + + monkeypatch.setattr(loader, "load_provider_class", load_provider_class) + settings = AppSettings() + selected_config = { + "base_url": "${FIXTURE_ENDPOINT}", + "api_key": "${FIXTURE_KEY}", + "catalog": "${FIXTURE_CATALOG}", + "priority": 99, + } + entries = [ + { + "id": "other-openai", + "module": "provider-openai", + "config": { + "base_url": "https://other.example.invalid/v1", + "api_key": "${FIXTURE_OTHER_KEY}", + "catalog": "other-model", + "priority": 1, + }, + }, + { + "id": "selected-openai", + "module": "provider-openai", + "config": selected_config, + }, + ] + original = deepcopy(entries) + settings._write_scope("global", {"config": {"providers": entries}}) + original_bytes = settings.paths.global_settings.read_bytes() + expected = expand_env_vars(deepcopy(selected_config)) + events.clear() + loaded_modules.clear() + + # Keep settings resolution, routing and get_provider_models real; suppress + # only first-run installation. Click exports a provider group, so patch the + # module boundary rather than traversing package attributes. + with patch( + "amplifier_app_cli.commands.provider._ensure_providers_ready", lambda: None + ): + result = CliRunner().invoke(provider, ["models", "selected-openai"]) + + assert result.exit_code == 0, result.output + assert loaded_modules == ["provider-openai"] + assert entries == original + assert settings.get_provider_overrides() == original + assert settings.paths.global_settings.read_bytes() == original_bytes + assert events == [ + (action, "selected-fixture-key", "https://selected.example.invalid/v1", expected) + for action in ("construct", "models", "close") + ] + assert "Models for selected-openai" in result.output + assert "selected-model" in result.output + assert "other-model" not in result.output @pytest.mark.parametrize("name", ["fixture", "provider-fixture"])