Files
vpd-permission-poc/ai-web-agent-console/ai_web_agent_console/model_registry.py

435 lines
16 KiB
Python

"""8512/8513 전용 AI Web Agent Console model profile registry.
이 registry는 모델 metadata만 관리한다. ``provider=oci``는 모델의 출처를 뜻하며
``AI_WEB_AGENT_CONSOLE_MCP_PROVIDER``와 독립적이다. 따라서 기본 model profile이 GPT-5.5여도 현재
MCP 실행 경로는 계속 ``mock``일 수 있다.
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
import json
import os
from pathlib import Path
import re
from typing import Any, Mapping, Optional
ROOT = Path(__file__).resolve().parents[1]
REGISTRY_PATH = ROOT / "config" / "model_profiles.json"
DEFAULT_MODEL_PROFILE_KEY = "gpt55_oci"
MODEL_PROFILE_ENV = "AI_WEB_AGENT_CONSOLE_MODEL_PROFILE"
MODEL_PROFILE_DEFAULT_ENV = "AI_WEB_AGENT_CONSOLE_MODEL_PROFILE_DEFAULT"
EXISTING_MODEL_PROFILE_KEYS = ("grok43", "llama4_maverick", "llama33_70b")
MODEL_PROFILE_ALIASES = {
"gpt54_mini": "gpt54_mini_oci",
"llama33": "llama33_70b",
}
EXPECTED_SOURCE_TAG = "poc_2-gpt55-oci-partial"
EXPECTED_SOURCE_COMMIT = "7a3b37f175b65ed5eab1d8bf37c9bf6114e7558f"
_EXPECTED_ANSWER_MODEL_ROUTES = {
"gpt55_oci": (
"openai.gpt-5.5",
"us-chicago-1",
"OPENAI_GPT_5_5_CHAT",
"OCI_REGIONAL_DEFAULT",
),
"gpt54_mini_oci": (
"openai.gpt-5.4-mini",
"us-chicago-1",
"OPENAI_GPT_5_4_MINI_CHAT",
"OCI_REGIONAL_DEFAULT",
),
"grok43": (
"xai.grok-4.3",
"us-chicago-1",
"XAI_GROK_4_3_CHAT",
"OCI_REGIONAL_DEFAULT",
),
"llama4_maverick": (
"meta.llama-4-maverick-17b-128e-instruct-fp8",
"us-chicago-1",
"META_LLAMA_4_MAVERICK_CHAT",
"OCI_REGIONAL_DEFAULT",
),
"llama33_70b": (
"meta.llama-3.3-70b-instruct",
"us-chicago-1",
"META_LLAMA_3_3_70B_CHAT",
"OCI_REGIONAL_DEFAULT",
),
}
_MODEL_KEY = re.compile(r"^[a-z][a-z0-9_]{1,63}$")
_MODEL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
_ANSWER_MODEL_ID_ALIAS = re.compile(r"^[A-Z][A-Z0-9_]{1,127}$")
_OCI_REGION = re.compile(r"^[a-z]{2}-[a-z0-9-]+-[1-9][0-9]*$")
_OCI_REGIONAL_ENDPOINT = re.compile(
r"^https://inference\.generativeai\."
r"(?P<region>[a-z]{2}-[a-z0-9-]+-[1-9][0-9]*)\.oci\.oraclecloud\.com$"
)
_ORACLE_IDENTIFIER = re.compile(r"^[A-Z][A-Z0-9_$#]{0,127}$")
_SOURCE_TAG = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{1,127}$")
_VERIFICATION_STATUSES = frozenset(
{"VERIFIED", "PARTIAL_VERIFIED", "VERIFIED_WITH_WARNINGS"}
)
_REQUIRED_PROFILE_FIELDS = (
"model_key",
"display_name",
"provider",
"model_id",
"answer_model_id_alias",
"answer_model_region",
"answer_model_endpoint_mode",
"poc2_select_ai_profile",
"poc2_native_agent_team",
"verification_status",
"default_for_console",
"source_tag",
"notes",
)
_PROFILE_ROUTE_ENV_KEYS = {
"gpt55_oci": (
"AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_MODEL_ID",
"AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_REGION",
"AI_WEB_AGENT_CONSOLE_LLM_GPT55_OCI_ENDPOINT",
),
"gpt54_mini_oci": (
"AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_MODEL_ID",
"AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_REGION",
"AI_WEB_AGENT_CONSOLE_LLM_GPT54_MINI_OCI_ENDPOINT",
),
"grok43": (
"AI_WEB_AGENT_CONSOLE_LLM_GROK43_MODEL_ID",
"AI_WEB_AGENT_CONSOLE_LLM_GROK43_REGION",
"AI_WEB_AGENT_CONSOLE_LLM_GROK43_ENDPOINT",
),
"llama4_maverick": (
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_MODEL_ID",
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_REGION",
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA4_MAVERICK_ENDPOINT",
),
"llama33_70b": (
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_MODEL_ID",
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_REGION",
"AI_WEB_AGENT_CONSOLE_LLM_LLAMA33_70B_ENDPOINT",
),
}
def _regional_endpoint(region: str) -> str:
return "https://inference.generativeai.%s.oci.oraclecloud.com" % region
def _endpoint_host_alias(region: str) -> str:
return "OCI_GENAI_INFERENCE_%s" % region.upper().replace("-", "_")
def _env_override(
environ: Mapping[str, str],
key: str,
default: str,
) -> str:
if key not in environ:
return default
value = environ.get(key)
if not isinstance(value, str) or not value.strip():
raise ValueError("answer model route override is invalid")
return value.strip()
@dataclass(frozen=True)
class ModelProfile:
"""UI와 workflow가 공유하는 비밀값 없는 model metadata."""
model_key: str
display_name: str
provider: str
model_id: str
answer_model_id_alias: str
answer_model_region: str
answer_model_endpoint_mode: str
answer_model_endpoint: str = field(repr=False)
poc2_select_ai_profile: str
poc2_native_agent_team: str
verification_status: str
default_for_console: bool
source_tag: str
notes: str
display_order: int = 999
@classmethod
def from_mapping(cls, value: Mapping[str, Any]) -> "ModelProfile":
missing = [name for name in _REQUIRED_PROFILE_FIELDS if name not in value]
if missing:
raise ValueError("AI Web Agent Console model profile fields are missing")
if not isinstance(value.get("default_for_console"), bool):
raise ValueError("default_for_console must be boolean")
order = value.get("display_order", 999)
if isinstance(order, bool) or not isinstance(order, int) or order < 0:
raise ValueError("model profile display_order is invalid")
answer_model_region = str(value["answer_model_region"]).strip().lower()
profile = cls(
model_key=str(value["model_key"]).strip().lower(),
display_name=str(value["display_name"]).strip(),
provider=str(value["provider"]).strip().lower(),
model_id=str(value["model_id"]).strip(),
answer_model_id_alias=str(value["answer_model_id_alias"])
.strip()
.upper(),
answer_model_region=answer_model_region,
answer_model_endpoint_mode=str(
value["answer_model_endpoint_mode"]
).strip().upper(),
answer_model_endpoint=_regional_endpoint(answer_model_region),
poc2_select_ai_profile=str(value["poc2_select_ai_profile"])
.strip()
.upper(),
poc2_native_agent_team=str(value["poc2_native_agent_team"])
.strip()
.upper(),
verification_status=str(value["verification_status"]).strip().upper(),
default_for_console=value["default_for_console"],
source_tag=str(value["source_tag"]).strip(),
notes=str(value["notes"]).strip(),
display_order=order,
)
if not _MODEL_KEY.fullmatch(profile.model_key):
raise ValueError("model profile key is invalid")
if not profile.display_name or len(profile.display_name) > 128:
raise ValueError("model profile display name is invalid")
if profile.provider != "oci":
raise ValueError("unsupported model provider")
if not _MODEL_ID.fullmatch(profile.model_id):
raise ValueError("model id is invalid")
if not _ANSWER_MODEL_ID_ALIAS.fullmatch(profile.answer_model_id_alias):
raise ValueError("answer model id alias is invalid")
if not _OCI_REGION.fullmatch(profile.answer_model_region):
raise ValueError("answer model region is invalid")
if profile.answer_model_endpoint_mode != "OCI_REGIONAL_DEFAULT":
raise ValueError("answer model endpoint mode is invalid")
endpoint_match = _OCI_REGIONAL_ENDPOINT.fullmatch(
profile.answer_model_endpoint
)
if (
endpoint_match is None
or endpoint_match.group("region") != profile.answer_model_region
):
raise ValueError("answer model endpoint is invalid")
if not _ORACLE_IDENTIFIER.fullmatch(profile.poc2_select_ai_profile):
raise ValueError("PoC_2 Select AI profile mapping is invalid")
if not _ORACLE_IDENTIFIER.fullmatch(profile.poc2_native_agent_team):
raise ValueError("PoC_2 Native Agent team mapping is invalid")
if profile.verification_status not in _VERIFICATION_STATUSES:
raise ValueError("model verification status is invalid")
if not _SOURCE_TAG.fullmatch(profile.source_tag):
raise ValueError("model profile source tag is invalid")
if not profile.notes or len(profile.notes) > 1_000:
raise ValueError("model profile notes are invalid")
return profile
def with_answer_route_overrides(
self,
environ: Mapping[str, str],
) -> "ModelProfile":
"""Apply only this profile's validated, non-secret OCI route settings."""
keys = _PROFILE_ROUTE_ENV_KEYS.get(self.model_key)
if keys is None:
raise ValueError("answer model route is not registered")
model_id = _env_override(environ, keys[0], self.model_id)
region = _env_override(environ, keys[1], self.answer_model_region).lower()
endpoint = _env_override(
environ,
keys[2],
_regional_endpoint(region),
)
if not _MODEL_ID.fullmatch(model_id):
raise ValueError("answer model route override is invalid")
if not _OCI_REGION.fullmatch(region):
raise ValueError("answer model route override is invalid")
endpoint_match = _OCI_REGIONAL_ENDPOINT.fullmatch(endpoint)
if endpoint_match is None or endpoint_match.group("region") != region:
raise ValueError("answer model route override is invalid")
return replace(
self,
model_id=model_id,
answer_model_region=region,
answer_model_endpoint=endpoint,
)
def public_metadata(self) -> dict[str, object]:
"""System Details에 투영 가능한 비밀값 없는 metadata를 반환한다."""
return {
"model_key": self.model_key,
"display_name": self.display_name,
"provider": self.provider,
"answer_model_id_alias": self.answer_model_id_alias,
"answer_model_region": self.answer_model_region,
"answer_model_endpoint_mode": self.answer_model_endpoint_mode,
"answer_model_endpoint_host_alias": _endpoint_host_alias(
self.answer_model_region
),
"poc2_select_ai_profile": self.poc2_select_ai_profile,
"poc2_native_agent_team": self.poc2_native_agent_team,
"verification_status": self.verification_status,
"default_for_console": self.default_for_console,
"source_tag": self.source_tag,
"notes": self.notes,
}
@dataclass(frozen=True)
class ModelProfileRegistry:
"""검증된 AI Web Agent Console model profile 집합."""
profiles: tuple[ModelProfile, ...]
default_model_profile: str
registry_name: str
source_commit: str
schema_version: int = 1
def by_key(self, model_key: object) -> ModelProfile:
candidate = str(model_key or "").strip().lower()
candidate = MODEL_PROFILE_ALIASES.get(candidate, candidate)
for profile in self.profiles:
if profile.model_key == candidate:
return profile
# 사용자 입력이나 환경변수 원문을 오류에 반사하지 않는다.
raise ValueError("model profile is not registered")
@property
def default_profile(self) -> ModelProfile:
return self.by_key(self.default_model_profile)
def selector_options(self) -> tuple[ModelProfile, ...]:
return tuple(sorted(self.profiles, key=lambda item: item.display_order))
def resolve(
self,
requested: object = None,
*,
environ: Optional[Mapping[str, str]] = None,
) -> ModelProfile:
"""명시 요청은 엄격히 검증하고 환경 기본값은 안전하게 fallback한다."""
source = os.environ if environ is None else environ
if requested is not None and str(requested).strip():
return self.by_key(requested).with_answer_route_overrides(source)
for name in (MODEL_PROFILE_ENV, MODEL_PROFILE_DEFAULT_ENV):
candidate = source.get(name)
if not candidate or not candidate.strip():
continue
try:
profile = self.by_key(candidate)
except ValueError:
continue
return profile.with_answer_route_overrides(source)
return self.default_profile.with_answer_route_overrides(source)
def load_model_registry(path: Path = REGISTRY_PATH) -> ModelProfileRegistry:
"""JSON registry를 매 호출마다 검증해 반환한다."""
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError("AI Web Agent Console model profile registry cannot be loaded") from exc
if not isinstance(payload, Mapping):
raise ValueError("AI Web Agent Console model profile registry must be an object")
raw_profiles = payload.get("profiles")
if not isinstance(raw_profiles, list) or not raw_profiles:
raise ValueError("AI Web Agent Console model profile registry has no profiles")
profiles = tuple(
ModelProfile.from_mapping(item)
for item in raw_profiles
if isinstance(item, Mapping)
)
if len(profiles) != len(raw_profiles):
raise ValueError("AI Web Agent Console model profile registry contains an invalid profile")
keys = tuple(item.model_key for item in profiles)
if len(set(keys)) != len(keys):
raise ValueError("AI Web Agent Console model profile keys must be unique")
if len({item.display_name for item in profiles}) != len(profiles):
raise ValueError("AI Web Agent Console model profile display names must be unique")
defaults = tuple(item.model_key for item in profiles if item.default_for_console)
configured_default = str(payload.get("default_model_profile") or "").strip().lower()
if defaults != (configured_default,):
raise ValueError("AI Web Agent Console model profile default is inconsistent")
if configured_default != DEFAULT_MODEL_PROFILE_KEY:
raise ValueError("AI Web Agent Console GPT-5.5 default contract is not satisfied")
if not set(EXISTING_MODEL_PROFILE_KEYS).issubset(keys):
raise ValueError("existing AI Web Agent Console selector models are missing")
actual_answer_routes = {
item.model_key: (
item.model_id,
item.answer_model_region,
item.answer_model_id_alias,
item.answer_model_endpoint_mode,
)
for item in profiles
}
if actual_answer_routes != _EXPECTED_ANSWER_MODEL_ROUTES:
raise ValueError("AI Web Agent Console answer model route mapping is inconsistent")
if str(payload.get("source_commit") or "").strip() != EXPECTED_SOURCE_COMMIT:
raise ValueError("PoC_2 source commit is inconsistent")
if any(item.source_tag != EXPECTED_SOURCE_TAG for item in profiles):
raise ValueError("PoC_2 source tag is inconsistent")
if payload.get("schema_version") != 1:
raise ValueError("unsupported AI Web Agent Console model profile registry schema")
registry_name = str(payload.get("registry_name") or "").strip()
if not registry_name:
raise ValueError("AI Web Agent Console model profile registry name is missing")
return ModelProfileRegistry(
profiles=profiles,
default_model_profile=configured_default,
registry_name=registry_name,
source_commit=EXPECTED_SOURCE_COMMIT,
)
def resolve_model_profile(
requested: object = None,
*,
environ: Optional[Mapping[str, str]] = None,
) -> ModelProfile:
return load_model_registry().resolve(requested, environ=environ)
def resolve_model_profile_key(
requested: object = None,
*,
environ: Optional[Mapping[str, str]] = None,
) -> str:
return resolve_model_profile(requested, environ=environ).model_key
def is_registered_model_profile(value: object) -> bool:
try:
load_model_registry().by_key(value)
except ValueError:
return False
return True
__all__ = [
"DEFAULT_MODEL_PROFILE_KEY",
"EXISTING_MODEL_PROFILE_KEYS",
"EXPECTED_SOURCE_COMMIT",
"EXPECTED_SOURCE_TAG",
"MODEL_PROFILE_DEFAULT_ENV",
"MODEL_PROFILE_ENV",
"MODEL_PROFILE_ALIASES",
"ModelProfile",
"ModelProfileRegistry",
"REGISTRY_PATH",
"is_registered_model_profile",
"load_model_registry",
"resolve_model_profile",
"resolve_model_profile_key",
]