"""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()