feat(fase2): wire local + Foundry profiles + model-map + env template

This commit is contained in:
Kjell Tore Guttormsen 2026-06-24 13:38:43 +02:00
commit 15e41dd1fa
3 changed files with 88 additions and 42 deletions

View file

@ -1,26 +1,36 @@
"""Backend profiles (D2): the seam between the framework and a MAF chat client. """Backend profiles (D2): the seam between the framework and a MAF chat client.
Per research §14, a MAF agent binds to a *chat client* and the model is a A MAF agent binds to a *chat client* and the model is a parameter on that client so model
parameter on that client so model choice is per-client (and later per-agent choice is per-client (and per-agent via a role->deployment model-map, B12). A "backend
via a role->deployment model-map, B12). A "backend profile" selects how models profile" selects how models are served and produces the corresponding MAF chat client.
are served and produces the corresponding MAF chat client.
This is an EMPTY skeleton for Fase 0: it defines the profile selector and the Two GA-wired profiles (Fase 2):
seam (`ChatBackend`), with stubs that raise ``NotImplementedError``. Live wiring
lands in Fase 1, when the Group Chat maker-checker spike needs real clients.
Cost-discipline (D6): the ``LOCAL`` profile (an OpenAI-compatible local endpoint * **LOCAL** (dev default, D6): ``OpenAIChatCompletionClient`` against an OpenAI-compatible
such as Ollama/LM Studio, driven by the GA ``agent-framework-openai`` client) is local endpoint (Ollama/LM Studio). Chat Completions, **non-streaming** NOT the
the development default; the ``AZURE`` profile (``FoundryChatClient``) is reserved Responses-based ``OpenAIChatClient`` (research 03: non-streaming populates ``UsageDetails``
for targeted, minimal verification against the private Foundry tenant. 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 from __future__ import annotations
import json
import os
from enum import Enum from enum import Enum
from importlib.resources import files
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
from agent_framework import BaseChatClient from agent_framework import BaseChatClient
from agent_framework_foundry import FoundryChatClient
from agent_framework_openai import OpenAIChatCompletionClient
_MODEL_MAP_RESOURCE = "data/model_map.json"
# 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"
class Profile(str, Enum): class Profile(str, Enum):
@ -32,48 +42,56 @@ class Profile(str, Enum):
@runtime_checkable @runtime_checkable
class ChatBackend(Protocol): class ChatBackend(Protocol):
"""The seam: a backend produces a MAF chat client for a given model. """The seam: a backend produces a MAF chat client for a given model."""
Implementations are Fase 0 skeletons ``create_chat_client`` raises
``NotImplementedError`` until wired in Fase 1. ``model`` is the
deployment/model name (resolved from the role->deployment model-map during
onboarding, B12).
"""
profile: Profile profile: Profile
def create_chat_client(self, *, model: str) -> BaseChatClient: def create_chat_client(self, *, model: str) -> BaseChatClient:
"""Create a MAF chat client bound to ``model``. Wired in Fase 1.""" """Create a MAF chat client bound to ``model`` (the resolved deployment/model id)."""
... ...
class AzureFoundryBackend: def resolve_model(profile: Profile | str, role: str) -> str:
"""AZURE profile: ``FoundryChatClient`` against a Foundry project (U18). """Resolve a role -> model/deployment id from ``data/model_map.json`` (B12). Falls back
to the profile's ``default``; fail-fast (``ValueError``) when nothing maps."""
prof = Profile(profile)
table = json.loads(
files("portfolio_optimiser").joinpath(_MODEL_MAP_RESOURCE).read_text(encoding="utf-8")
)
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}")
return model
Skeleton Foundry wiring (``project_endpoint``, ``credential``) lands in Fase 1.
""" class AzureFoundryBackend:
"""AZURE profile: ``FoundryChatClient`` against a Foundry project (U18)."""
profile = Profile.AZURE profile = Profile.AZURE
def create_chat_client(self, *, model: str) -> BaseChatClient: def create_chat_client(self, *, model: str) -> BaseChatClient:
raise NotImplementedError( endpoint = os.environ.get("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT")
"AzureFoundryBackend is a Fase 0 skeleton; Foundry wiring lands in Fase 1." if not endpoint:
) raise ValueError(
"PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT is required for the AZURE profile"
)
# Credential resolves lazily via Azure DefaultAzureCredential (az login / MI).
return FoundryChatClient(project_endpoint=endpoint, model=model)
class LocalBackend: class LocalBackend:
"""LOCAL profile: ``OpenAIChatClient`` against an OpenAI-compatible local """LOCAL profile: ``OpenAIChatCompletionClient`` against an OpenAI-compatible local
endpoint (Ollama/LM Studio). Development default per cost-discipline (D6). endpoint (Ollama/LM Studio). Development default per cost-discipline (D6)."""
Skeleton local-endpoint wiring (``base_url``, ``model``) lands in Fase 1.
"""
profile = Profile.LOCAL profile = Profile.LOCAL
def create_chat_client(self, *, model: str) -> BaseChatClient: def create_chat_client(self, *, model: str) -> BaseChatClient:
raise NotImplementedError( base_url = os.environ.get("PORTFOLIO_LOCAL_BASE_URL", _DEFAULT_LOCAL_BASE_URL)
"LocalBackend is a Fase 0 skeleton; local-endpoint wiring lands in Fase 1." 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: def get_backend(profile: Profile | str) -> ChatBackend:

View file

@ -0,0 +1,13 @@
{
"_note": "Role -> model/deployment map (B12). Validated by contracts.py (Step 11). LOCAL uses an Ollama model id; AZURE deployment names are tenant-specific — the operator replaces the placeholders (or supplies them via env).",
"local": {
"default": "qwen3:4b",
"proposer": "qwen3:4b",
"checker": "qwen3:4b"
},
"azure": {
"default": "REPLACE-WITH-FOUNDRY-DEPLOYMENT",
"proposer": "REPLACE-WITH-FOUNDRY-DEPLOYMENT",
"checker": "REPLACE-WITH-FOUNDRY-DEPLOYMENT"
}
}

View file

@ -1,6 +1,7 @@
"""Tests for the backend profile skeleton (D2).""" """Tests for the backend profiles (D2) — now wired (Fase 2), no longer skeletons."""
import pytest import pytest
from agent_framework import BaseChatClient
from portfolio_optimiser.backends import ( from portfolio_optimiser.backends import (
AzureFoundryBackend, AzureFoundryBackend,
@ -8,6 +9,7 @@ from portfolio_optimiser.backends import (
LocalBackend, LocalBackend,
Profile, Profile,
get_backend, get_backend,
resolve_model,
) )
@ -32,8 +34,21 @@ def test_unknown_profile_fails_fast() -> None:
get_backend("on-prem") get_backend("on-prem")
@pytest.mark.parametrize("profile", ["azure", "local"]) def test_local_backend_returns_client_no_network(monkeypatch: pytest.MonkeyPatch) -> None:
def test_create_chat_client_is_skeleton(profile: str) -> None: # Construction is offline (no network); the default base_url is loopback.
# Fase 0: the seam is defined but not wired — must fail loudly, not silently. monkeypatch.delenv("PORTFOLIO_LOCAL_BASE_URL", raising=False)
with pytest.raises(NotImplementedError): client = get_backend("local").create_chat_client(model="qwen3:4b")
get_backend(profile).create_chat_client(model="dummy-model") assert isinstance(client, BaseChatClient)
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.
monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False)
with pytest.raises(ValueError):
get_backend("azure").create_chat_client(model="dummy-deployment")
def test_model_map_resolves_role_to_model() -> None:
assert resolve_model("local", "proposer") == "qwen3:4b"
# Unknown role falls back to the profile default (still a non-empty id).
assert resolve_model(Profile.LOCAL, "no-such-role")