band-aider-pub/tests/band_aider/test_aider_config.py
2026-10-17 11:57:37 -07:00

383 lines
12 KiB
Python

# SPDX-FileCopyrightText: 2026 Art Sea LLC
#
# SPDX-License-Identifier: AGPL-3.0-only
"""Memory-backed behavioral tests for provider configuration resolution."""
from __future__ import annotations
from argparse import Namespace
from band_aider.ai_client.openai_client_kind import OpenAIClientKind
from band_aider.ai_client.openai_config_factory import OpenAIConfigFactory
from band_aider.cli import build_parser
from band_aider.context.aider_config import AiderConfig, AiderConfigFactory
from tests.support.memory_path import MemoryPath
def _args(*argv: str) -> Namespace:
"""Parse one stationary command-line argument sequence."""
return build_parser().parse_args(list(argv))
def test_read_env_alias_returns_first_nonblank_value() -> None:
"""Alias lookup skips blanks and returns the first nonblank value."""
env = {"FIRST": " ", "SECOND": "value", "THIRD": "later"}
assert AiderConfigFactory.read_env_alias(env, ("FIRST", "SECOND", "THIRD")) == "value"
def test_read_env_alias_string_joins_aliases_with_slashes() -> None:
"""Alias display strings remain visibly grouped."""
assert AiderConfigFactory.read_env_alias_string(("A", "B", "C")) == "A/B/C"
def test_aider_read_env_file_parses_simple_dotenv_syntax(tmp_path: MemoryPath) -> None:
"""Dotenv parsing reads the memory repository and strips simple quotes."""
env_file = tmp_path / "band-aider.1.env"
env_file.write_text("# comment\n\n FIRST = one \nSECOND='two'\nTHIRD = \"three\"\nFOURTH=four=five")
assert AiderConfigFactory.read_env_file(env_file) == {
"FIRST": "one",
"SECOND": "two",
"THIRD": "three",
"FOURTH": "four=five",
}
def test_aider_config_from_env_prefers_cli_args_over_env_aliases() -> None:
"""CLI values override the supplied environment mapping."""
env = {
"BAND_AIDER_AIDER_CMD": "env-aider",
"BAND_AIDER_MODEL": "band-model",
"OPENAI_MODEL": "openai/openai-model",
"IONA_MODEL": "env-iona",
"BAND_AIDER_API_BASE": "https://env-base.example",
"OPENAI_API_BASE": "https://openai-base.example",
"OPENAI_API_KEY": "env-key",
"BAND_AIDER_EDIT_FORMAT": "whole",
"BAND_AIDER_EXTRA_ARGS": "--env-flag",
}
config = AiderConfigFactory.from_env(
_args(
"--aider-cmd",
"cli-aider",
"--aider-model",
"openai/cli-model",
"--aider-api-base",
"https://cli-base.example",
"--aider-api-key",
"cli-key",
"--aider-edit-format",
"diff",
"--aider-extra-arg=--cli-flag",
),
env,
)
assert config == AiderConfig(
aider_cmd="cli-aider",
wrapper_mode=False,
model="openai/cli-model",
api_base="https://cli-base.example",
api_key="cli-key",
edit_format="diff",
yes=False,
extra_args=["--env-flag", "--cli-flag"],
)
def test_aider_discovery_roots_order_memory_ancestors(tmp_path: MemoryPath) -> None:
"""Discovery returns a nearest-first bounded chain using only memory paths."""
home = tmp_path / "home"
nested = home / "project" / "nested"
nested.mkdir(parents=True)
assert AiderConfigFactory._discovery_roots(nested, home) == (
nested,
home / "project",
home,
)
def test_aider_nearest_numeric_env_returns_none_when_no_memory_candidate(tmp_path: MemoryPath) -> None:
"""A missing numeric tier has no candidate in the memory repository."""
root = tmp_path / "project"
root.mkdir()
assert AiderConfigFactory._nearest_numeric_env(1, (root,)) is None
def test_aider_nearest_numeric_env_prefers_nearest_memory_ancestor(tmp_path: MemoryPath) -> None:
"""The nearest numeric candidate wins over an ancestor with the same tier."""
home = tmp_path / "home"
nested = home / "project" / "nested"
nested.mkdir(parents=True)
(home / "band-aider.1.env").write_text("BAND_AIDER_MODEL=far")
near = home / "project" / "band-aider.1.env"
near.write_text("BAND_AIDER_MODEL=near")
roots = AiderConfigFactory._discovery_roots(nested, home)
assert AiderConfigFactory._nearest_numeric_env(1, roots) == near
def test_aider_read_env_file_returns_empty_for_absent_memory_file(tmp_path: MemoryPath) -> None:
"""An absent memory-backed dotenv file resolves to an empty mapping."""
assert AiderConfigFactory.read_env_file(tmp_path / "absent.env") == {}
def test_aider_nearest_default_env_returns_memory_file(tmp_path: MemoryPath) -> None:
"""Default dotenv discovery returns the first in-memory candidate."""
root = tmp_path / "project"
root.mkdir()
default_env = root / "band-aider.env"
default_env.write_text("BAND_AIDER_MODEL=from-file")
assert AiderConfigFactory._nearest_default_env((root,)) == default_env
def test_aider_config_from_explicit_file_values_needs_no_process_environment() -> None:
"""Explicit dotenv values resolve without consulting process configuration."""
config = AiderConfigFactory.from_env(
_args("--task", "x"),
{
"OPENAI_MODEL": "openai/from-file",
"OPENAI_API_BASE": "https://file.example",
"OPENAI_API_KEY": "file-key",
},
)
assert (config.model, config.api_base, config.api_key) == (
"openai/from-file",
"https://file.example",
"file-key",
)
def test_aider_config_from_env_honors_openai_aliases() -> None:
"""OpenAI aliases resolve when band-aider aliases are absent."""
config = AiderConfigFactory.from_env(
_args("--task", "x"),
{
"OPENAI_MODEL": "openai-model",
"OPENAI_API_BASE": "https://openai-base.example",
"OPENAI_API_KEY": "openai-key",
},
)
assert (config.model, config.api_base, config.api_key) == (
"openai-model",
"https://openai-base.example",
"openai-key",
)
def test_aider_config_from_env_prefers_band_aider_aliases_over_openai_aliases() -> None:
"""Band-aider aliases win over generic OpenAI aliases."""
config = AiderConfigFactory.from_env(
_args("--task", "x"),
{
"BAND_AIDER_MODEL": "band-model",
"OPENAI_MODEL": "openai-model",
"BAND_AIDER_API_BASE": "https://band-base.example",
"OPENAI_API_BASE": "https://openai-base.example",
"BAND_AIDER_API_KEY": "band-key",
"OPENAI_API_KEY": "openai-key",
},
)
assert (config.model, config.api_base, config.api_key) == (
"band-model",
"https://band-base.example",
"band-key",
)
def test_aider_config_from_env_keeps_iona_aliases_paired() -> None:
"""IONA values do not mix with aliases from a generic family."""
config = AiderConfigFactory.from_env(
_args("--task", "x"),
{
"IONA_MODEL": "iona-model",
"IONA_API_KEY": "iona-key",
"OPENAI_API_BASE": "https://openai-base.example",
"OPENAI_API_KEY": "openai-key",
},
)
assert (config.model, config.api_base, config.api_key) == (
"iona-model",
"https://iona.local.arpeggio.one/api",
"iona-key",
)
def test_aider_config_from_env_honors_iona_model_when_other_families_are_missing() -> None:
"""An IONA model is selected when no earlier generic model is supplied."""
assert AiderConfigFactory.from_env(_args("--task", "x"), {"IONA_MODEL": "default"}).model == "default"
def test_aider_config_from_env_uses_webui_key_only_with_iona_family() -> None:
"""WEBUI_KEY remains an IONA-only fallback."""
config = AiderConfigFactory.from_env(
_args("--task", "x"),
{
"IONA_MODEL": "iona-model",
"IONA_BASE_URL": "https://iona-base.example",
"WEBUI_KEY": "webui-key",
"OPENAI_API_KEY": "openai-key",
},
)
assert (config.model, config.api_base, config.api_key) == (
"iona-model",
"https://iona-base.example",
"webui-key",
)
def test_aider_config_from_env_prefers_cli_model_over_env_aliases() -> None:
"""CLI --aider-model wins over every supplied model alias."""
config = AiderConfigFactory.from_env(
_args("--task", "x", "--aider-model", "cli-model"),
{"BAND_AIDER_MODEL": "band-model", "OPENAI_MODEL": "openai-model", "IONA_MODEL": "default"},
)
assert config.model == "cli-model"
def test_openai_read_env_file_parses_memory_backed_tier(tmp_path: MemoryPath) -> None:
"""OpenAI dotenv parsing uses the same isolated memory file protocol."""
tier = tmp_path / "band-aider.1.env"
tier.write_text("OPENAI_MODEL=openai/first\nCLIENT=response\n")
assert OpenAIConfigFactory.read_env_file(tier) == {
"OPENAI_MODEL": "openai/first",
"CLIENT": "response",
}
def test_openai_discovery_roots_order_memory_ancestors(tmp_path: MemoryPath) -> None:
"""OpenAI discovery remains bounded by the supplied memory home path."""
home = tmp_path / "home"
nested = home / "project" / "nested"
nested.mkdir(parents=True)
assert OpenAIConfigFactory._discovery_roots(nested, home) == (
nested,
home / "project",
home,
)
def test_openai_nearest_default_env_prefers_nearest_memory_ancestor(tmp_path: MemoryPath) -> None:
"""OpenAI default candidate lookup chooses the closest memory-backed file."""
home = tmp_path / "home"
nested = home / "project" / "nested"
nested.mkdir(parents=True)
(home / "band-aider.env").write_text("OPENAI_MODEL=openai/far")
nearest = home / "project" / "band-aider.env"
nearest.write_text("OPENAI_MODEL=openai/near")
roots = OpenAIConfigFactory._discovery_roots(nested, home)
assert OpenAIConfigFactory._nearest_default_env(roots) == nearest
def test_openai_config_from_env_selects_response_client() -> None:
"""A stationary CLIENT value selects the response-client enum."""
config = OpenAIConfigFactory.from_env(
_args("--task", "x"),
{"OPENAI_MODEL": "openai/first", "CLIENT": "response"},
)
assert (config.model, config.client) == ("first", OpenAIClientKind.RESPONSE)
def test_openai_config_from_env_uses_completion_client_for_unknown_value() -> None:
"""Unexpected CLIENT values safely retain the completion-client default."""
config = OpenAIConfigFactory.from_env(
_args("--task", "x"),
{"OPENAI_MODEL": "openai/first", "CLIENT": "unknown"},
)
assert config.client is OpenAIClientKind.COMPLETION
def test_openai_config_from_env_reads_max_tokens_from_explicit_mapping() -> None:
"""An explicit mapping supplies max-token configuration without host state."""
config = OpenAIConfigFactory.from_env(
_args("--task", "x"),
{"OPENAI_MODEL": "openai/first", "BAND_AIDER_MAX_OUTPUT_TOKENS": "321"},
)
assert config.max_tokens == 321
def test_openai_config_from_env_keeps_iona_aliases_paired() -> None:
"""OpenAI config resolution does not mix strict IONA and generic aliases."""
config = OpenAIConfigFactory.from_env(
_args("--task", "x"),
{
"IONA_MODEL": "openai/iona-model",
"IONA_API_KEY": "iona-key",
"OPENAI_BASE_URL": "https://openai-base.example",
"OPENAI_API_KEY": "openai-key",
},
)
assert (config.model, config.api_base, config.api_key) == (
"iona-model",
"https://iona.local.arpeggio.one/api",
"iona-key",
)
def test_aider_config_priority_overlay_selects_new_provider_family() -> None:
"""A tier overlay selects its family without inheriting an unrelated base family."""
config = AiderConfigFactory.from_env(
_args("--task", "x"),
{"IONA_MODEL": "default", "IONA_API_KEY": "iona-key", "BAND_AIDER_MODEL": "escalated"},
priority_env={"BAND_AIDER_MODEL": "escalated"},
)
assert config.model == "escalated"
def test_openai_config_priority_overlay_selects_new_provider_family() -> None:
"""An OpenAI tier overlay remains selected over base IONA configuration."""
config = OpenAIConfigFactory.from_env(
_args("--task", "x"),
{"IONA_MODEL": "openai/default", "IONA_API_KEY": "iona-key", "OPENAI_MODEL": "openai/escalated"},
priority_env={"OPENAI_MODEL": "openai/escalated"},
)
assert config.model == "escalated"