feat(4b): AZURE-profilen leser miljøet sitt, ikke operatørens laptop
Endepunktet løses som første ikke-tomme av vårt eget PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT og Foundrys injiserte FOUNDRY_PROJECT_ENDPOINT — vårt vinner, fallbacken lar samme image kjøre hostet uten ekstra wiring. Presedensen gjelder verdier, ikke deklarasjoner. Credential velges av samme miljø: AzureCliCredential lokalt, ManagedIdentityCredential når FOUNDRY_HOSTING_ENVIRONMENT er satt, fordi containeren ikke har noen Azure CLI. Ikke DefaultAzureCredential — Learns MAF-veiledning navngir den spesifikke credentialen for å unngå probing. Load-bearing målt mot hele suiten, fire mutasjoner alle røde + grønn kontroll: detach credential-valget · presence i stedet for truthiness · detach fallbacken · snu presedensen. Fail-fast-testen var vakuøs først — vårt variabelnavn inneholder det injiserte som delstreng. De fire åpne azure.yaml-valgene lukket mot de to JSON-skjemaene og ført i docs/2026-08-13-fase4-azure-yaml-valg.md. Ingen azure.yaml skrevet (4d). 821 passed / 4 skipped. Ruff + format + mypy rene. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jk8tauXXAojNKC7Tzq7ziF
This commit is contained in:
parent
447c5a9d15
commit
63eec917d2
7 changed files with 373 additions and 12 deletions
|
|
@ -37,6 +37,34 @@ _MODEL_MAP_ENV = "PORTFOLIO_MODEL_MAP"
|
|||
_PLACEHOLDER_PREFIX = "REPLACE-WITH-"
|
||||
# Loopback only — never a remote host (D6 / research 03 no-egress). Override via env.
|
||||
_DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:11434/v1"
|
||||
# Fase 4b — the Foundry project endpoint may arrive under either name, OURS FIRST. Ours predates
|
||||
# the hosting flow and is what every doc/recipe/test sets, so an operator who exports it is making
|
||||
# a deliberate choice; the platform-injected name is the fallback that lets a hosted container run
|
||||
# with no extra wiring. Precedence is over VALUES, not declarations — an exported-but-empty name
|
||||
# falls through rather than shadowing a real one into a fail-fast.
|
||||
_ENDPOINT_ENVS = ("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_PROJECT_ENDPOINT")
|
||||
# Injected by the platform at startup inside a Foundry hosted agent, and set nowhere else — so its
|
||||
# presence is the marker for "there is no Azure CLI here". Truthiness, not presence: an
|
||||
# exported-but-empty value is a shell accident, not a hosting signal.
|
||||
_HOSTING_MARKER_ENV = "FOUNDRY_HOSTING_ENVIRONMENT"
|
||||
|
||||
|
||||
def _resolve_endpoint() -> str:
|
||||
"""First non-empty of ``_ENDPOINT_ENVS``; fail-fast (``ValueError``) naming BOTH, since the
|
||||
operator in a container and the operator on a laptop set different ones."""
|
||||
for name in _ENDPOINT_ENVS:
|
||||
value = os.environ.get(name)
|
||||
if value:
|
||||
return value
|
||||
raise ValueError(
|
||||
f"{_ENDPOINT_ENVS[0]} (or the platform-injected {_ENDPOINT_ENVS[1]}) "
|
||||
"is required for the AZURE profile"
|
||||
)
|
||||
|
||||
|
||||
def _is_hosted() -> bool:
|
||||
"""True inside a Foundry hosted agent (Fase 4b)."""
|
||||
return bool(os.environ.get(_HOSTING_MARKER_ENV))
|
||||
|
||||
|
||||
def _load_effective_map() -> dict[str, Any]:
|
||||
|
|
@ -100,20 +128,26 @@ class AzureFoundryBackend:
|
|||
profile = Profile.AZURE
|
||||
|
||||
def create_chat_client(self, *, model: str) -> BaseChatClient:
|
||||
endpoint = os.environ.get("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT")
|
||||
if not endpoint:
|
||||
raise ValueError("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT is required for the AZURE profile")
|
||||
endpoint = _resolve_endpoint()
|
||||
# FoundryChatClient REQUIRES an explicit credential (verified against agent-framework-foundry
|
||||
# 1.8.2 — it raises ``ValueError`` without one; there is NO lazy DefaultAzureCredential
|
||||
# default). Lazy import so the LOCAL path never pulls azure.identity. AzureCliCredential is
|
||||
# the documented, friction-minimal path on a non-Azure host — constructing it acquires NO
|
||||
# token (``az login`` is the operator's manual step), so this is not auto-login. Recipe:
|
||||
# docs/2026-07-15-foundry-auth-recipe.md.
|
||||
from azure.identity.aio import AzureCliCredential
|
||||
# default). Lazy import so the LOCAL path never pulls azure.identity.
|
||||
#
|
||||
# Fase 4b — the credential is chosen by ENVIRONMENT, because the two environments have
|
||||
# different identities available:
|
||||
# * developer host: AzureCliCredential, the friction-minimal path — constructing it
|
||||
# acquires NO token (``az login`` is the operator's manual step), so this is not
|
||||
# auto-login. Recipe: docs/2026-07-15-foundry-auth-recipe.md.
|
||||
# * Foundry hosted agent: there is no Azure CLI in the container. The platform mints a
|
||||
# dedicated Entra agent identity for it at deploy time, so ManagedIdentityCredential is
|
||||
# the identity that exists. Learn's MAF guidance names it explicitly over
|
||||
# DefaultAzureCredential ("prefer a specific credential such as ManagedIdentityCredential
|
||||
# to avoid unintended credential probing") — probing would otherwise walk a chain of
|
||||
# credentials that cannot succeed here, turning a config error into a slow one.
|
||||
from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential
|
||||
|
||||
return FoundryChatClient(
|
||||
project_endpoint=endpoint, model=model, credential=AzureCliCredential()
|
||||
)
|
||||
credential = ManagedIdentityCredential() if _is_hosted() else AzureCliCredential()
|
||||
return FoundryChatClient(project_endpoint=endpoint, model=model, credential=credential)
|
||||
|
||||
|
||||
class LocalBackend:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue