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
172 lines
8.4 KiB
Python
172 lines
8.4 KiB
Python
"""Backend profiles (D2): the seam between the framework and a MAF chat client.
|
|
|
|
A MAF agent binds to a *chat client* and the model is a parameter on that client — so model
|
|
choice is per-client (and per-agent via a role->deployment model-map, B12). A "backend
|
|
profile" selects how models are served and produces the corresponding MAF chat client.
|
|
|
|
Two GA-wired profiles (Fase 2):
|
|
|
|
* **LOCAL** (dev default, D6): ``OpenAIChatCompletionClient`` against an OpenAI-compatible
|
|
local endpoint (Ollama/LM Studio). Chat Completions, **non-streaming** — NOT the
|
|
Responses-based ``OpenAIChatClient`` (research 03: non-streaming populates ``UsageDetails``
|
|
None-safely and avoids the ``/v1`` tool-drop). The base URL defaults to loopback; no egress.
|
|
* **AZURE**: ``FoundryChatClient`` against a Foundry project (deployment names tenant-specific,
|
|
supplied via env + ``data/model_map.json``). Reserved for targeted, minimal verification.
|
|
|
|
``get_backend()`` and ``create_chat_client()`` are fail-fast (``ValueError``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from enum import Enum
|
|
from importlib.resources import files
|
|
from pathlib import Path
|
|
from typing import Any, Protocol, runtime_checkable
|
|
|
|
from agent_framework import BaseChatClient
|
|
from agent_framework_foundry import FoundryChatClient
|
|
from agent_framework_openai import OpenAIChatCompletionClient
|
|
|
|
_MODEL_MAP_RESOURCE = "data/model_map.json"
|
|
# S4.1 — out-of-tree model-map override so tenant-specific deployment names are never committed.
|
|
_MODEL_MAP_ENV = "PORTFOLIO_MODEL_MAP"
|
|
# S4.1 — placeholder sentinel (mirrors costsim.PLACEHOLDER_PREFIX); an azure deployment left as
|
|
# ``REPLACE-WITH-*`` must never reach a client build.
|
|
_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]:
|
|
"""Load the role->model map (B12). ``PORTFOLIO_MODEL_MAP`` (an out-of-tree path) wins so
|
|
tenant-specific deployment names are never committed; otherwise the bundled resource. This is
|
|
the SINGLE source of truth shared by ``resolve_model`` and the S4.1 preflight — so the checker
|
|
and the run path can never validate different maps. Fail-fast (``FileNotFoundError``) when the
|
|
override path does not exist (mirror ``contracts.load_goal_config``)."""
|
|
override = os.environ.get(_MODEL_MAP_ENV)
|
|
if override:
|
|
path = Path(override)
|
|
if not path.is_file():
|
|
raise FileNotFoundError(f"model map not found: {override!r}")
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
return json.loads(
|
|
files("portfolio_optimiser").joinpath(_MODEL_MAP_RESOURCE).read_text(encoding="utf-8")
|
|
)
|
|
|
|
|
|
class Profile(str, Enum):
|
|
"""Model-serving backend profile (D2)."""
|
|
|
|
AZURE = "azure" # Foundry / Azure OpenAI — full profile
|
|
LOCAL = "local" # OpenAI-compatible local endpoint — fallback / dev default
|
|
|
|
|
|
@runtime_checkable
|
|
class ChatBackend(Protocol):
|
|
"""The seam: a backend produces a MAF chat client for a given model."""
|
|
|
|
profile: Profile
|
|
|
|
def create_chat_client(self, *, model: str) -> BaseChatClient:
|
|
"""Create a MAF chat client bound to ``model`` (the resolved deployment/model id)."""
|
|
...
|
|
|
|
|
|
def resolve_model(profile: Profile | str, role: str) -> str:
|
|
"""Resolve a role -> model/deployment id from the effective model map (B12), honoring
|
|
``PORTFOLIO_MODEL_MAP``. Falls back to the profile's ``default``; fail-fast (``ValueError``)
|
|
when nothing maps, OR when the resolved id is still an unreplaced ``REPLACE-WITH-*`` placeholder
|
|
(S4.1 — never build a client from a placeholder; the guard reaches the run path at
|
|
``run.py`` too, not just the preflight)."""
|
|
prof = Profile(profile)
|
|
table = _load_effective_map()
|
|
profile_map = table.get(prof.value, {})
|
|
model = profile_map.get(role) or profile_map.get("default")
|
|
if not model:
|
|
raise ValueError(f"no model mapped for profile={prof.value} role={role!r}")
|
|
if model.startswith(_PLACEHOLDER_PREFIX):
|
|
raise ValueError(
|
|
f"model map has unresolved placeholder for profile={prof.value} role={role!r}: "
|
|
f"{model!r} — replace the placeholder or set PORTFOLIO_MODEL_MAP"
|
|
)
|
|
return model
|
|
|
|
|
|
class AzureFoundryBackend:
|
|
"""AZURE profile: ``FoundryChatClient`` against a Foundry project (U18)."""
|
|
|
|
profile = Profile.AZURE
|
|
|
|
def create_chat_client(self, *, model: str) -> BaseChatClient:
|
|
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.
|
|
#
|
|
# 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
|
|
|
|
credential = ManagedIdentityCredential() if _is_hosted() else AzureCliCredential()
|
|
return FoundryChatClient(project_endpoint=endpoint, model=model, credential=credential)
|
|
|
|
|
|
class LocalBackend:
|
|
"""LOCAL profile: ``OpenAIChatCompletionClient`` against an OpenAI-compatible local
|
|
endpoint (Ollama/LM Studio). Development default per cost-discipline (D6)."""
|
|
|
|
profile = Profile.LOCAL
|
|
|
|
def create_chat_client(self, *, model: str) -> BaseChatClient:
|
|
base_url = os.environ.get("PORTFOLIO_LOCAL_BASE_URL", _DEFAULT_LOCAL_BASE_URL)
|
|
api_key = os.environ.get("PORTFOLIO_LOCAL_API_KEY", "ollama")
|
|
# Chat Completions (NOT the Responses-based OpenAIChatClient), non-streaming usage.
|
|
# Construction is offline — no network call until an agent actually runs.
|
|
return OpenAIChatCompletionClient(model=model, api_key=api_key, base_url=base_url)
|
|
|
|
|
|
def get_backend(profile: Profile | str) -> ChatBackend:
|
|
"""Select a backend by profile. Fail-fast (``ValueError``) on unknown profile."""
|
|
profile = Profile(profile) # validates: ValueError on unknown string
|
|
if profile is Profile.AZURE:
|
|
return AzureFoundryBackend()
|
|
return LocalBackend()
|