From 15e41dd1fabb7a802bc43bef69b1d8637d487e20 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Wed, 24 Jun 2026 13:38:43 +0200 Subject: [PATCH] feat(fase2): wire local + Foundry profiles + model-map + env template --- src/portfolio_optimiser/backends.py | 86 +++++++++++++-------- src/portfolio_optimiser/data/model_map.json | 13 ++++ tests/test_backends.py | 27 +++++-- 3 files changed, 86 insertions(+), 40 deletions(-) create mode 100644 src/portfolio_optimiser/data/model_map.json diff --git a/src/portfolio_optimiser/backends.py b/src/portfolio_optimiser/backends.py index 312d68c..5d84b34 100644 --- a/src/portfolio_optimiser/backends.py +++ b/src/portfolio_optimiser/backends.py @@ -1,26 +1,36 @@ """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 -parameter on that client — so model choice is per-client (and later per-agent -via a role->deployment model-map, B12). A "backend profile" selects how models -are served and produces the corresponding 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. -This is an EMPTY skeleton for Fase 0: it defines the profile selector and the -seam (`ChatBackend`), with stubs that raise ``NotImplementedError``. Live wiring -lands in Fase 1, when the Group Chat maker-checker spike needs real clients. +Two GA-wired profiles (Fase 2): -Cost-discipline (D6): the ``LOCAL`` profile (an OpenAI-compatible local endpoint -such as Ollama/LM Studio, driven by the GA ``agent-framework-openai`` client) is -the development default; the ``AZURE`` profile (``FoundryChatClient``) is reserved -for targeted, minimal verification against the private Foundry tenant. +* **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 typing import 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" +# 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): @@ -32,48 +42,56 @@ class Profile(str, Enum): @runtime_checkable class ChatBackend(Protocol): - """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). - """ + """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``. Wired in Fase 1.""" + """Create a MAF chat client bound to ``model`` (the resolved deployment/model id).""" ... -class AzureFoundryBackend: - """AZURE profile: ``FoundryChatClient`` against a Foundry project (U18). +def resolve_model(profile: Profile | str, role: str) -> str: + """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 def create_chat_client(self, *, model: str) -> BaseChatClient: - raise NotImplementedError( - "AzureFoundryBackend is a Fase 0 skeleton; Foundry wiring lands in Fase 1." - ) + endpoint = os.environ.get("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT") + 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: - """LOCAL profile: ``OpenAIChatClient`` against an OpenAI-compatible local - endpoint (Ollama/LM Studio). Development default per cost-discipline (D6). - - Skeleton — local-endpoint wiring (``base_url``, ``model``) lands in Fase 1. - """ + """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: - raise NotImplementedError( - "LocalBackend is a Fase 0 skeleton; local-endpoint wiring lands in Fase 1." - ) + 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: diff --git a/src/portfolio_optimiser/data/model_map.json b/src/portfolio_optimiser/data/model_map.json new file mode 100644 index 0000000..948858b --- /dev/null +++ b/src/portfolio_optimiser/data/model_map.json @@ -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" + } +} diff --git a/tests/test_backends.py b/tests/test_backends.py index 64c601d..dedde3b 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -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 +from agent_framework import BaseChatClient from portfolio_optimiser.backends import ( AzureFoundryBackend, @@ -8,6 +9,7 @@ from portfolio_optimiser.backends import ( LocalBackend, Profile, get_backend, + resolve_model, ) @@ -32,8 +34,21 @@ def test_unknown_profile_fails_fast() -> None: get_backend("on-prem") -@pytest.mark.parametrize("profile", ["azure", "local"]) -def test_create_chat_client_is_skeleton(profile: str) -> None: - # Fase 0: the seam is defined but not wired — must fail loudly, not silently. - with pytest.raises(NotImplementedError): - get_backend(profile).create_chat_client(model="dummy-model") +def test_local_backend_returns_client_no_network(monkeypatch: pytest.MonkeyPatch) -> None: + # Construction is offline (no network); the default base_url is loopback. + monkeypatch.delenv("PORTFOLIO_LOCAL_BASE_URL", raising=False) + client = get_backend("local").create_chat_client(model="qwen3:4b") + 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")