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
24
CLAUDE.md
24
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -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`,
|
||||
|
|
|
|||
125
docs/2026-08-13-fase4-azure-yaml-valg.md
Normal file
125
docs/2026-08-13-fase4-azure-yaml-valg.md
Normal 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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
157
tests/test_hosted_backend_loadbearing.py
Normal file
157
tests/test_hosted_backend_loadbearing.py
Normal 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", "")
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue