band-aider-pub/band_aider/env_aliases.py
2026-07-16 18:38:58 -07:00

193 lines
6.2 KiB
Python

# SPDX-FileCopyrightText: 2026 Art Sea LLC
#
# SPDX-License-Identifier: AGPL-3.0-only
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping, Sequence
def read_env_alias(env: Mapping[str, str], aliases: Sequence[str]) -> str | None:
"""Return the first nonblank value found across one env alias group."""
for alias in aliases:
value = env.get(alias)
if value is None:
continue
stripped = value.strip()
if stripped:
return stripped
return None
@dataclass(frozen=True)
class ProviderAliasFamily:
"""One provider-specific env alias family that must resolve as a unit."""
name: str
model_aliases: tuple[str, ...]
api_base_aliases: tuple[str, ...]
api_key_aliases: tuple[str, ...]
strict: bool = False
def resolve(self, env: Mapping[str, str]) -> "ResolvedProviderAliases":
return ResolvedProviderAliases(
family=self,
model=read_env_alias(env, self.model_aliases),
api_base=read_env_alias(env, self.api_base_aliases),
api_key=read_env_alias(env, self.api_key_aliases),
)
@dataclass(frozen=True)
class ResolvedProviderAliases:
"""Resolved env values for one provider family."""
family: ProviderAliasFamily
model: str | None
api_base: str | None
api_key: str | None
@property
def present(self) -> bool:
return any((self.model, self.api_base, self.api_key))
# Order is strict: niche and idiosyncratic providers must win before generic ones.
# Keep these families aligned by prefix and do not resolve model/base/key from
# separate precedence lists, or mixed env state will pair the wrong credentials.
OPENAI_PROVIDER_ALIAS_FAMILIES: tuple[ProviderAliasFamily, ...] = (
ProviderAliasFamily(
name="iona",
model_aliases=("IONA_MODEL",),
api_base_aliases=("IONA_BASE_URL",),
api_key_aliases=("IONA_API_KEY", "WEBUI_KEY"),
strict=True,
),
ProviderAliasFamily(
name="band_aider",
model_aliases=("BAND_AIDER_MODEL",),
api_base_aliases=("BAND_AIDER_API_BASE",),
api_key_aliases=("BAND_AIDER_API_KEY",),
),
ProviderAliasFamily(
name="openai",
model_aliases=("OPENAI_MODEL",),
api_base_aliases=("OPENAI_API_BASE", "OPENAI_BASE_URL"),
api_key_aliases=("OPENAI_API_KEY",),
),
)
FIRST_AIDER_PROVIDER_ALIAS_FAMILIES: tuple[ProviderAliasFamily, ...] = (
ProviderAliasFamily(
name="iona",
model_aliases=("IONA_MODEL",),
api_base_aliases=("IONA_BASE_URL",),
api_key_aliases=("IONA_API_KEY", "WEBUI_KEY"),
),
ProviderAliasFamily(
name="band_aider",
model_aliases=("BAND_AIDER_MODEL",),
api_base_aliases=("BAND_AIDER_MODEL_API_BASE",),
api_key_aliases=("BAND_AIDER_MODEL_API_KEY",),
),
ProviderAliasFamily(
name="openai",
model_aliases=("OPENAI_MODEL",),
api_base_aliases=("OPENAI_API_BASE", "OPENAI_BASE_URL"),
api_key_aliases=("OPENAI_API_KEY",),
),
)
def flatten_model_aliases(families: Sequence[ProviderAliasFamily]) -> tuple[str, ...]:
return tuple(alias for family in families for alias in family.model_aliases)
def flatten_api_base_aliases(families: Sequence[ProviderAliasFamily]) -> tuple[str, ...]:
return tuple(alias for family in families for alias in family.api_base_aliases)
def flatten_api_key_aliases(families: Sequence[ProviderAliasFamily]) -> tuple[str, ...]:
return tuple(alias for family in families for alias in family.api_key_aliases)
def resolve_provider_alias_family(
env: Mapping[str, str],
families: Sequence[ProviderAliasFamily],
*,
priority_env: Mapping[str, str] | None = None,
) -> ResolvedProviderAliases | None:
"""Resolve provider aliases without ever mixing a strict family with generic families."""
def _generic_resolution(source_env: Mapping[str, str]) -> ResolvedProviderAliases | None:
generic_families = tuple(family for family in families if not family.strict)
if not generic_families:
return None
return ResolvedProviderAliases(
family=ProviderAliasFamily(
name="generic",
model_aliases=flatten_model_aliases(generic_families),
api_base_aliases=flatten_api_base_aliases(generic_families),
api_key_aliases=flatten_api_key_aliases(generic_families),
),
model=read_env_alias(source_env, flatten_model_aliases(generic_families)),
api_base=read_env_alias(source_env, flatten_api_base_aliases(generic_families)),
api_key=read_env_alias(source_env, flatten_api_key_aliases(generic_families)),
)
def _select_family(source_env: Mapping[str, str]) -> ProviderAliasFamily | None:
for family in families:
if read_env_alias(source_env, family.model_aliases):
return family
for family in families:
if not family.strict:
continue
if family.resolve(source_env).present:
return family
generic = _generic_resolution(source_env)
if generic is not None and generic.present:
return generic.family
return None
selected = _select_family(priority_env or env) or _select_family(env)
if selected is None:
return None
return selected.resolve(env)
def discovery_roots(start: Path, home: Path) -> tuple[Path, ...]:
"""Return the bounded ancestor search path from start up to home, inclusive."""
start = start.resolve()
home = home.resolve()
if start == home:
return (home,)
try:
start.relative_to(home)
except ValueError:
return (start,)
roots: list[Path] = []
current = start
while True:
roots.append(current)
if current == home:
break
current = current.parent
return tuple(roots)
def nearest_env_file(name: str, roots: Sequence[Path]) -> Path | None:
"""Return the nearest ancestor env file with the given name."""
for root in roots:
candidate = root / name
if candidate.exists():
return candidate
return None