193 lines
6.2 KiB
Python
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
|