portfolio-optimiser/src/portfolio_optimiser/backends.py

138 lines
6.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"
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 = os.environ.get("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT")
if not endpoint:
raise ValueError("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT is required for the AZURE profile")
# 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
return FoundryChatClient(
project_endpoint=endpoint, model=model, credential=AzureCliCredential()
)
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()