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
157 lines
8.1 KiB
Python
157 lines
8.1 KiB
Python
"""Fase 4b — the AZURE backend must read its environment, not assume the operator's laptop.
|
|
|
|
Two seams, each with the control that makes it discriminate:
|
|
|
|
* **Credential by environment.** ``AzureCliCredential`` is the friction-minimal path on a
|
|
developer host (``az login`` is a manual step), but inside a Foundry hosted agent there is no
|
|
Azure CLI at all — the platform mints a dedicated Entra agent identity for the container, and
|
|
Learn's own MAF guidance says to prefer a specific credential there: "``DefaultAzureCredential``
|
|
is convenient for development. In production, prefer a specific credential such as
|
|
``ManagedIdentityCredential`` to avoid unintended credential probing"
|
|
(<https://learn.microsoft.com/agent-framework/integrations/by-component/agent-services/foundry>).
|
|
``FOUNDRY_HOSTING_ENVIRONMENT`` is the marker: it is one of the variables the platform injects at
|
|
startup (spike §"Ikke verifisert" pkt. 2) and exists nowhere else.
|
|
|
|
* **Endpoint name fallback.** The platform injects the project endpoint as
|
|
``FOUNDRY_PROJECT_ENDPOINT``; our own ``PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT`` predates it and is
|
|
what every existing doc, test and recipe sets. Ours WINS — an operator who exports our name is
|
|
making a deliberate choice, and a platform value silently overriding it would be unexplainable
|
|
from the outside. The injected name is a fallback, so a container needs no extra wiring.
|
|
|
|
The recorder tests below patch ``backends.FoundryChatClient`` to observe what is passed to it —
|
|
the client itself exposes no credential attribute (MEASURED: ``[a for a in dir(client) if 'cred'
|
|
in a.lower()]`` is empty), so there is no way to read the choice back off a built client. That
|
|
patch would, alone, stop proving the real client ACCEPTS the credential — so
|
|
``test_real_client_accepts_managed_identity_offline`` keeps one unpatched arm.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from agent_framework import BaseChatClient
|
|
from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential
|
|
|
|
from portfolio_optimiser import backends
|
|
|
|
_ENDPOINT = "https://x.services.ai.azure.com/api/projects/p"
|
|
_INJECTED_ENDPOINT = "https://platform.services.ai.azure.com/api/projects/injected"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Hermetic: BOTH endpoint names and the hosting marker are cleared. Without clearing the
|
|
injected name too, an ambient value would invert the precedence arms."""
|
|
monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False)
|
|
monkeypatch.delenv("FOUNDRY_PROJECT_ENDPOINT", raising=False)
|
|
monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False)
|
|
|
|
|
|
@pytest.fixture()
|
|
def captured(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
|
"""Record the kwargs that reach ``FoundryChatClient`` on the real ``create_chat_client`` path."""
|
|
seen: dict[str, Any] = {}
|
|
|
|
def _recorder(**kwargs: Any) -> object:
|
|
seen.update(kwargs)
|
|
return object()
|
|
|
|
monkeypatch.setattr(backends, "FoundryChatClient", _recorder)
|
|
return seen
|
|
|
|
|
|
# --- Seam 1: credential by environment ------------------------------------------------------
|
|
|
|
|
|
def test_hosted_marker_selects_managed_identity(
|
|
captured: dict[str, Any], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Detach point: construct ``AzureCliCredential`` unconditionally → RED here."""
|
|
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _ENDPOINT)
|
|
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "AzureFoundryAgentService")
|
|
backends.get_backend("azure").create_chat_client(model="gpt-4o-mini")
|
|
assert isinstance(captured["credential"], ManagedIdentityCredential)
|
|
|
|
|
|
def test_unhosted_selects_azure_cli_credential(
|
|
captured: dict[str, Any], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""CONTROL — without it, an implementation that ALWAYS returns a managed identity passes the
|
|
test above. The selector must discriminate, not merely reach the hosted branch."""
|
|
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _ENDPOINT)
|
|
backends.get_backend("azure").create_chat_client(model="gpt-4o-mini")
|
|
assert isinstance(captured["credential"], AzureCliCredential)
|
|
|
|
|
|
def test_empty_hosting_marker_is_not_hosted(
|
|
captured: dict[str, Any], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""An exported-but-empty marker is not a hosting signal. Detach point: test presence with
|
|
``is not None`` instead of truthiness → RED here. This is the arm that separates "the variable
|
|
exists" from "we are hosted"; a shell that exports an empty value is a real, cheap accident."""
|
|
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _ENDPOINT)
|
|
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "")
|
|
backends.get_backend("azure").create_chat_client(model="gpt-4o-mini")
|
|
assert isinstance(captured["credential"], AzureCliCredential)
|
|
|
|
|
|
def test_real_client_accepts_managed_identity_offline(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""UNPATCHED arm: the real ``FoundryChatClient`` accepts the hosted credential and construction
|
|
stays OFFLINE (no token acquired). Without this, the recorder tests would prove only that we
|
|
pass SOMETHING named ``credential``."""
|
|
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _ENDPOINT)
|
|
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "AzureFoundryAgentService")
|
|
client = backends.get_backend("azure").create_chat_client(model="gpt-4o-mini")
|
|
assert isinstance(client, BaseChatClient)
|
|
|
|
|
|
# --- Seam 2: endpoint name fallback ---------------------------------------------------------
|
|
|
|
|
|
def test_injected_endpoint_used_when_ours_is_absent(
|
|
captured: dict[str, Any], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Detach point: read only ``PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT`` → RED here (raises)."""
|
|
monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", _INJECTED_ENDPOINT)
|
|
backends.get_backend("azure").create_chat_client(model="gpt-4o-mini")
|
|
assert captured["project_endpoint"] == _INJECTED_ENDPOINT
|
|
|
|
|
|
def test_our_endpoint_wins_over_injected(
|
|
captured: dict[str, Any], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""CONTROL for precedence — the two values must DIFFER, otherwise the assertion cannot tell
|
|
which name was read. Detach point: let the injected name win → RED here."""
|
|
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _ENDPOINT)
|
|
monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", _INJECTED_ENDPOINT)
|
|
backends.get_backend("azure").create_chat_client(model="gpt-4o-mini")
|
|
assert captured["project_endpoint"] == _ENDPOINT
|
|
|
|
|
|
def test_empty_own_endpoint_falls_through_to_injected(
|
|
captured: dict[str, Any], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Precedence is over VALUES, not over declarations: an exported-but-empty own name must not
|
|
shadow a real injected one into a fail-fast."""
|
|
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", "")
|
|
monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", _INJECTED_ENDPOINT)
|
|
backends.get_backend("azure").create_chat_client(model="gpt-4o-mini")
|
|
assert captured["project_endpoint"] == _INJECTED_ENDPOINT
|
|
|
|
|
|
def test_neither_endpoint_fails_fast_naming_both() -> None:
|
|
"""Fail-fast is unchanged in kind, but the message must name BOTH names — an operator in a
|
|
container and an operator on a laptop set different ones, and a message naming only ours sends
|
|
the container operator looking for the wrong variable.
|
|
|
|
The naive form of the second assertion is VACUOUS and was written that way first:
|
|
``"FOUNDRY_PROJECT_ENDPOINT" in message`` is satisfied by the substring inside
|
|
``PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT``, so a message naming ONLY our own variable passes it.
|
|
That is the repo's own 08-09 defect class ("assert never on a substring two branches share").
|
|
Removing our name first is what makes the assertion able to fail."""
|
|
with pytest.raises(ValueError) as excinfo:
|
|
backends.get_backend("azure").create_chat_client(model="gpt-4o-mini")
|
|
message = str(excinfo.value)
|
|
assert "PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT" in message
|
|
assert "FOUNDRY_PROJECT_ENDPOINT" in message.replace("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", "")
|