feat(fase2): wire local + Foundry profiles + model-map + env template
This commit is contained in:
parent
f981c51e0b
commit
15e41dd1fa
3 changed files with 88 additions and 42 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
13
src/portfolio_optimiser/data/model_map.json
Normal file
13
src/portfolio_optimiser/data/model_map.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue