383 lines
12 KiB
Python
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"
|