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:
Kjell Tore Guttormsen 2026-08-13 22:27:21 +02:00
commit 63eec917d2
7 changed files with 373 additions and 12 deletions

View file

@ -139,6 +139,30 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
fixturen — pakkekonfigen er selv en søm), tre mutasjoner alle røde mot hele suiten: detach
fallbacken (1 rød) · detach force-include (3 røde) · snu rekkefølgen (1 rød — ordnings-testen
var grønn før fiksen; dens kontroll på at pakket kopi FINNES er det som gjør flippen målbar).
- **AZURE-profilen leser MILJØET sitt ved kall-tid, ikke operatørens laptop (Fase 4b):** endepunktet
løses som første IKKE-TOMME av `_ENDPOINT_ENVS` — vårt eget `PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT`
FØRST, deretter Foundrys injiserte `FOUNDRY_PROJECT_ENDPOINT`. **Vårt vinner** (det er dét enhver
doc, recipe og test setter, så en eksport av det er en bevisst handling; en plattformverdi som
stille overstyrte den ville vært uforklarlig utenfra), og fallbacken er dét som lar samme image
kjøre hostet uten ekstra wiring. **Presedensen gjelder VERDIER, ikke deklarasjoner** — et
eksportert-men-tomt eget navn faller igjennom i stedet for å skygge et ekte injisert inn i en
fail-fast. Feilmeldingen navngir BEGGE: operatøren i en container og operatøren på en laptop
leter etter hver sin variabel. Credential velges av samme miljø: `AzureCliCredential` lokalt
(konstruksjon henter INGEN token — `az login` er operatørens manuelle steg),
`ManagedIdentityCredential` når `FOUNDRY_HOSTING_ENVIRONMENT` er satt, fordi containeren ikke har
noen Azure CLI og plattformen mynter den en egen Entra-identitet ved deploy. **Ikke
`DefaultAzureCredential`:** Learns egen MAF-veiledning sier «prefer a specific credential such as
`ManagedIdentityCredential` to avoid unintended credential probing» — probing ville vandret en
kjede som ikke KAN lykkes der, og gjort en konfigfeil om til en treg en. Markøren leses på
**truthiness, ikke presence**: en eksportert tom verdi er et shell-uhell, ikke et hosting-signal.
Klienten eksponerer INGEN credential-attributt (målt), så testene observerer via en
`FoundryChatClient`-recorder — med én UPATCHET arm, ellers ville de kun bevist at vi sender
*noe* som heter `credential`. Load-bearing MÅLT
(`tests/test_hosted_backend_loadbearing.py`), fire mutasjoner alle røde mot hele suiten + grønn
kontroll: detach credential-valget · presence i stedet for truthiness · detach fallbacken · snu
presedensen. **Fail-fast-testen ble skrevet VAKUØS først** (repoets 08-09-klasse):
`PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT` INNEHOLDER `FOUNDRY_PROJECT_ENDPOINT`, så asserten på det
injiserte navnet var oppfylt av vårt eget; den fjerner nå vårt navn før den sjekker.
- **Stoppkriterier + budsjett-tak påkrevd ved oppstart** (fail-fast, aldri ubegrenset loop).
- **Group Chat maker-checker** som debatt-default (IKKE Magentic, som er eksperimentell).
- **To falsifiserere, samme kandidat (Steg 3/4, målbilde §2/§6):** den deterministiske validatoren

View file

@ -313,6 +313,20 @@ when the seam is detached, so the loop cannot silently degrade into theater.
extension point) via a deterministic, schema-validated manifest that runs *before* the loop is
implemented and exercised against committed fixtures — no bundle has yet been materialized from
a live source.
- **Backends:** a run binds to one of two profiles. `local` (the development default) talks to an
OpenAI-compatible endpoint on loopback and makes no egress. `azure` talks to a Foundry project
and resolves its configuration from the environment *at call time*:
- **Endpoint**`PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT` first, then the `FOUNDRY_PROJECT_ENDPOINT`
that Foundry injects into a hosted agent. Ours wins, so exporting it is always decisive; the
injected name is what lets the same image run hosted with no extra wiring. Neither set is a
fail-fast naming both.
- **Credential**`AzureCliCredential` on a developer host (constructing it acquires no token;
`az login` stays your manual step), and `ManagedIdentityCredential` when
`FOUNDRY_HOSTING_ENVIRONMENT` is present, because a hosted container has no Azure CLI and the
platform mints it a dedicated Entra identity instead.
Deployment names are never committed: the role→model map ships `REPLACE-WITH-*` placeholders that
fail fast, and `PORTFOLIO_MODEL_MAP` points at an out-of-tree map that wins over the bundled one.
- **Run:** the `run.py` CLI has **three modes** — a documented partition, since one invocation
cannot exercise every flag:
- **Single-project**`PROJECT_ID --docs-dir <dir>`, plus optional `--bundle-dir`,

View file

@ -0,0 +1,125 @@
# Fase 4· — de fire åpne `azure.yaml`-valgene, lukket mot skjemaet
**Dato:** 2026-08-13 · **Metode:** de to autoritative JSON-skjemaene hentet og lest, ikke referert
fra Learn-prosa · **Status:** beslutninger, ingen fil skrevet. `azure.yaml` skrives i 4d.
Spiken (`docs/2026-08-13-fase4-research-spike.md`) etterlot fire valg under «Ikke verifisert», med
begrunnelsen at Learn og Microsofts eget sample er uenige og at autoriteten —
`schemas/v1.0/azure.yaml.json` — ikke var lest. Den er lest nå.
## 0. Funnet som endrer hvordan de andre skal leses
**Skjemaet er PERMISSIVT, og kan derfor ikke avgjøre tre av de fire valgene.** Målt:
| Felt | Hva skjemaet faktisk sier |
|---|---|
| `host` | `type: string`, **ingen `enum`** — kun `examples`, der `azure.ai.agent` står |
| `language` | `type: string`, **ingen `enum`**`examples` inneholder BÅDE `python` og `docker` |
| service-objektet | `additionalProperties: **true**` |
Et ukjent felt blir altså ikke avvist — det blir *ignorert*. Å skrive `environmentVariables:` ville
ikke gitt en valideringsfeil; det ville gitt en fil som ser konfigurert ut og ikke er det. Det er en
skarpere failure-mode enn den planen fryktet, og den er grunnen til at «samplet er fasit» ikke er
godt nok som regel her.
**Den ekte autoriteten lå ett hopp videre.** `azure.yaml.json` delegerer agent-formen via `$ref`:
```
"if": { "properties": { "host": { "const": "azure.ai.agent" } } },
"then": { "required": ["project"],
"allOf": [ { "$ref": ".../extensions/azure.ai.agents/schemas/azure.ai.agent.json" } ],
"properties": { "config": { "deprecated": true, ... },
"k8s": false, "apiVersion": false, "network": false } }
```
Hentet: `azure.ai.agent.json` (http 200, 16 385 bytes). Den bærer `kind`, `protocols`,
`codeConfiguration`, `container`, `startupCommand` — feltene Learn omtaler, og som hovedskjemaet
ikke kjenner. To ting følger direkte: **`project` er påkrevd** for en hosted agent, og
**`config:` er deprekert** (agent-innstillingene er flyttet opp på service-nivå).
## 1. Protokollversjon: `2.0.0`
Skjemaet lukker ikke valget, men det avgrenser det. `ProtocolVersionRecord`:
```json
{"properties": {"protocol": {"type": "string"}, "version": {"type": "string"}},
"required": ["protocol"], "additionalProperties": false}
```
`version` er en fri streng **uten enum og uten default**, og er **valgfri**. Valget er altså vårt,
og det er et valg mellom å pinne og å la plattformen bestemme.
**Vi pinner `2.0.0`, uttalt.** Grunnen er ikke at skjemaet sier det — det gjør det ikke — men at
spikens §1.5 dokumenterer headere (`x-agent-user-id`, `x-agent-foundry-call-id`, og en gateway som
dropper alt utenfor en allowlist) som er **egenskaper ved 2.0.0**. Kjører plattformen oss under en
annen versjon enn den vi har lest kontrakten for, brytes antakelsene stille. Learn oppgir dessuten
at 1.0.0 er deprekert med blokkering etter fristen. En utelatt versjon ville gjort dette til noe vi
oppdager i drift; en pinnet gjør det til noe vi ser i diffen.
Skrives som `protocols: [{protocol: invocations, version: "2.0.0"}]` — **Invocations, ikke
Responses**, som spikens §5 allerede avgjorde: `run_project` er ett kall med strukturert inn og ut,
ikke en samtale.
## 2. Miljøvariabler: `env:`-map — dette lukkes AV skjemaet
MÅLT, i begge filer: `grep -c environmentVariables`**0 og 0**. Ordet finnes ikke i noen av dem.
`env` derimot er en deklarert service-property:
```json
{"type": "object", "title": "Environment variables for the service",
"description": "Optional. A map of environment variable names to values.
Supports environment variable substitution.", "additionalProperties": {"type": "string"}}
```
**`env:` er den eneste formen skjemaet kjenner.** `environmentVariables:` er ikke ulovlig (jf. §0),
men ingenting validerer den og ingenting leser den. Dette er det ene av de fire valgene som er
avgjort av autoriteten selv, ikke av vår vurdering.
Konsekvens for innholdet: `FOUNDRY_PROJECT_ENDPOINT` skal **ikke** redeklareres (spiken pkt. 2 —
det risikerer å skygge plattformens egen verdi). Modelldeployment-navnet må derimot deklareres, det
injiseres ikke.
## 3. `language: docker` — ikke `python`
Skjemaet tillater begge (ingen enum). Men `azure.ai.agent.json` gjør dem til to **gjensidig
utelukkende deploy-modi**, og sier det rett ut i `CodeConfiguration`:
> «Code deploy configuration. **When present, the agent is deployed from source (ZIP) instead of a
> container image.**» — påkrevd: `runtime` + `entryPoint`; `dependencyResolution` er
> `bundled` eller `remote_build`.
Vi velger container, og grunnen er vår egen avhengighetsform: de to sikkerhetskomponentene er
**git-pinnet via `[tool.uv.sources]`**, ikke publisert på PyPI (spikens §3 og §7.2). En
source-ZIP-deploy overlater resolusjonen til en byggetjeneste vi ikke kontrollerer og som må nå
`git.fromaitochitta.com` — og den formen bærer heller ikke `git` i base-imaget, som spikens §3
målte at pip-veien trenger. Vår Dockerfile er nettopp det stedet den kunnskapen kan bo.
Altså: `language: docker`, **ingen `codeConfiguration`**. `entryPoint`-uenigheten i spikens tabell
(`main.py` vs `["python", "main.py"]`) faller bort med den — feltet tilhører kun ZIP-modusen, og er
`type: string`, ikke en liste, i skjemaet.
## 4. Wrapper-form: IKKE avgjort her — og det er en måling, ikke en utsettelse
Skjemaet har ingenting å si om dette; det er et kodespørsmål. Men det ene alternativet er MÅLT
utilgjengelig i dag:
```
agent_framework_foundry_hosting -> ModuleNotFoundError
agent_framework.foundry -> IMPORT OK, 27 eksporter, 0 med "Host"/"Server" i navnet
agent_framework_foundry -> 1.8.2, 0 host-klasser
```
`InvocationsHostServer` finnes altså ikke i det installerte treet, og pakka som skal bære den har
ingen stabil release (spikens pkt. 8). **Valget kan ikke tas før pakka er installert og målt**, og
det hører uansett til 4c — som spikens §5 allerede har vist er et *signaturproblem*
(`run_project` tar `project_id` + to påkrevde keyword-argumenter og gir en frozen `RunResult`),
ikke et grafproblem. Ført videre som åpent, med en kjørbar grunn.
## Sidefunn, ikke handlet på
`agent_framework.foundry` eksporterer **`AnthropicFoundryClient`** og `RawAnthropicFoundryClient`.
STATEs 1b-blokk slår fast at `FoundryChatClient` ikke kan binde en Claude-deployment fordi Claude på
Foundry er en tredje klientflate (`/anthropic/v1/messages`, målt 13.08). Det står ved lag — men MAF
ser altså ut til å shippe en egen klient for nettopp den flaten. Det endrer potensielt 1b-valget
«Microsoft-solgt modell → ren konfig», og bør måles før 1b, ikke under. **Ikke verifisert:** at
klassen faktisk virker mot en Claude-deployment, og at den komponerer med `backends.py`-sømmen.

View file

@ -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:

View file

@ -59,7 +59,10 @@ def test_local_backend_returns_client_no_network(monkeypatch: pytest.MonkeyPatch
def test_azure_backend_fails_fast_without_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
# Fail-fast (no silent default endpoint) — the operator must supply the Foundry endpoint.
# Fase 4b: BOTH accepted names must be cleared, or this asserts "without OUR endpoint" while
# claiming "without endpoint" — the platform-injected name would satisfy the call.
monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False)
monkeypatch.delenv("FOUNDRY_PROJECT_ENDPOINT", raising=False)
with pytest.raises(ValueError):
get_backend("azure").create_chat_client(model="dummy-deployment")

View file

@ -0,0 +1,157 @@
"""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", "")

View file

@ -27,6 +27,10 @@ def _isolate_model_env(monkeypatch: pytest.MonkeyPatch) -> None:
isolation in ``test_backends.py``/``test_preflight.py`` (both ``delenv PORTFOLIO_MODEL_MAP``)."""
monkeypatch.delenv("PORTFOLIO_MODEL_MAP", raising=False)
monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False)
# Fase 4b: the endpoint now also resolves from the platform-injected ``FOUNDRY_PROJECT_ENDPOINT``
# (backends.py ``_ENDPOINT_ENVS``), so hermetic isolation must clear that name too — otherwise
# this fixture no longer achieves what its own docstring above promises.
monkeypatch.delenv("FOUNDRY_PROJECT_ENDPOINT", raising=False)
def test_cli_live_dry_run_ok(capsys) -> None: