fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
396 lines
13 KiB
Python
396 lines
13 KiB
Python
"""YAML loader for model catalog files under ``application/core/models/``.
|
|
|
|
Each ``*.yaml`` file declares one provider's static model catalog. Files
|
|
are validated with Pydantic at load time; any parse, schema, or alias
|
|
error aborts startup with the offending file path in the message.
|
|
|
|
For most providers, one YAML maps to one catalog. The
|
|
``openai_compatible`` provider is special: each YAML file represents a
|
|
distinct logical endpoint (Mistral, Together, Ollama, ...) with its own
|
|
``api_key_env`` and ``base_url``. The loader returns a flat list so the
|
|
registry can distinguish multiple files with the same ``provider:`` value.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Sequence
|
|
|
|
import yaml
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
|
|
from application.core.model_settings import (
|
|
AvailableModel,
|
|
ModelCapabilities,
|
|
ModelProvider,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
BUILTIN_MODELS_DIR = Path(__file__).parent / "models"
|
|
DEFAULTS_FILENAME = "_defaults.yaml"
|
|
|
|
# Accepted reasoning_effort values across the OpenAI reasoning lineup. This
|
|
# is the union of all models; the set a given model actually accepts is a
|
|
# subset (older o-series take low/medium/high only; GPT-5.5 adds xhigh;
|
|
# none/minimal are GPT-5-era additions). Validated at YAML load so a typo
|
|
# aborts boot rather than surfacing as a provider 400.
|
|
VALID_REASONING_EFFORTS = frozenset(
|
|
{"none", "minimal", "low", "medium", "high", "xhigh"}
|
|
)
|
|
# Accepted api_flavor values: which OpenAI wire protocol a model speaks.
|
|
VALID_API_FLAVORS = frozenset({"chat_completions", "responses"})
|
|
|
|
|
|
class _DefaultsFile(BaseModel):
|
|
"""Schema for ``_defaults.yaml``. Currently just attachment aliases."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
attachment_aliases: Dict[str, List[str]] = Field(default_factory=dict)
|
|
|
|
|
|
class _CapabilityFields(BaseModel):
|
|
"""Capability fields shared between provider ``defaults:`` and per-model overrides.
|
|
|
|
All fields are optional so a per-model override can selectively replace
|
|
a single field from the provider-level defaults.
|
|
"""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
supports_tools: Optional[bool] = None
|
|
supports_structured_output: Optional[bool] = None
|
|
supports_streaming: Optional[bool] = None
|
|
attachments: Optional[List[str]] = None
|
|
context_window: Optional[int] = None
|
|
input_cost_per_token: Optional[float] = None
|
|
output_cost_per_token: Optional[float] = None
|
|
reasoning_effort: Optional[str] = None
|
|
api_flavor: Optional[str] = None
|
|
|
|
@field_validator("reasoning_effort")
|
|
@classmethod
|
|
def _valid_reasoning_effort(cls, v: Optional[str]) -> Optional[str]:
|
|
if v is not None and v not in VALID_REASONING_EFFORTS:
|
|
valid = ", ".join(sorted(VALID_REASONING_EFFORTS))
|
|
raise ValueError(
|
|
f"reasoning_effort must be one of [{valid}], got {v!r}"
|
|
)
|
|
return v
|
|
|
|
@field_validator("api_flavor")
|
|
@classmethod
|
|
def _valid_api_flavor(cls, v: Optional[str]) -> Optional[str]:
|
|
if v is not None and v not in VALID_API_FLAVORS:
|
|
valid = ", ".join(sorted(VALID_API_FLAVORS))
|
|
raise ValueError(
|
|
f"api_flavor must be one of [{valid}], got {v!r}"
|
|
)
|
|
return v
|
|
|
|
|
|
class _ModelEntry(_CapabilityFields):
|
|
"""Schema for one model row inside a YAML's ``models:`` list."""
|
|
|
|
id: str
|
|
display_name: Optional[str] = None
|
|
description: str = ""
|
|
enabled: bool = True
|
|
base_url: Optional[str] = None
|
|
upstream_model_id: Optional[str] = None
|
|
aliases: List[str] = Field(default_factory=list)
|
|
|
|
@field_validator("id")
|
|
@classmethod
|
|
def _id_nonempty(cls, v: str) -> str:
|
|
if not v or not v.strip():
|
|
raise ValueError("model id must be a non-empty string")
|
|
return v
|
|
|
|
|
|
class _ProviderFile(BaseModel):
|
|
"""Schema for one ``<provider>.yaml`` catalog file."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
provider: str
|
|
defaults: _CapabilityFields = Field(default_factory=_CapabilityFields)
|
|
models: List[_ModelEntry] = Field(default_factory=list)
|
|
# openai_compatible metadata. Optional for other providers.
|
|
display_provider: Optional[str] = None
|
|
api_key_env: Optional[str] = None
|
|
base_url: Optional[str] = None
|
|
|
|
|
|
class ProviderCatalog(BaseModel):
|
|
"""One YAML file's parsed contents, ready for the registry.
|
|
|
|
For most providers, multiple catalogs with the same ``provider`` get
|
|
merged later by the registry. The ``openai_compatible`` provider is
|
|
the exception: each catalog is treated as a distinct endpoint, with
|
|
its own ``api_key_env`` and ``base_url``.
|
|
"""
|
|
|
|
provider: str
|
|
models: List[AvailableModel]
|
|
source_path: Optional[Path] = None
|
|
display_provider: Optional[str] = None
|
|
api_key_env: Optional[str] = None
|
|
base_url: Optional[str] = None
|
|
|
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
|
|
|
|
class ModelYAMLError(ValueError):
|
|
"""Raised when a model YAML fails parsing, schema, or alias validation."""
|
|
|
|
|
|
def _expand_attachments(
|
|
attachments: Sequence[str], aliases: Dict[str, List[str]], source: str
|
|
) -> List[str]:
|
|
"""Resolve attachment shorthands (``image``, ``pdf``) to MIME types.
|
|
|
|
Raw MIME-typed entries (containing ``/``) pass through unchanged.
|
|
Unknown aliases raise ``ModelYAMLError``.
|
|
"""
|
|
expanded: List[str] = []
|
|
seen: set = set()
|
|
for entry in attachments:
|
|
if "/" in entry:
|
|
if entry not in seen:
|
|
expanded.append(entry)
|
|
seen.add(entry)
|
|
continue
|
|
if entry not in aliases:
|
|
valid = ", ".join(sorted(aliases.keys())) or "<none defined>"
|
|
raise ModelYAMLError(
|
|
f"{source}: unknown attachment alias '{entry}'. "
|
|
f"Valid aliases: {valid}. "
|
|
"(Or use a raw MIME type like 'image/png'.)"
|
|
)
|
|
for mime in aliases[entry]:
|
|
if mime not in seen:
|
|
expanded.append(mime)
|
|
seen.add(mime)
|
|
return expanded
|
|
|
|
|
|
def _load_defaults(directory: Path) -> Dict[str, List[str]]:
|
|
"""Load ``_defaults.yaml`` from ``directory`` if it exists."""
|
|
path = directory / DEFAULTS_FILENAME
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
except yaml.YAMLError as e:
|
|
raise ModelYAMLError(f"{path}: invalid YAML: {e}") from e
|
|
try:
|
|
parsed = _DefaultsFile.model_validate(raw)
|
|
except Exception as e:
|
|
raise ModelYAMLError(f"{path}: schema error: {e}") from e
|
|
return parsed.attachment_aliases
|
|
|
|
|
|
def _resolve_provider_enum(name: str, source: Path) -> ModelProvider:
|
|
try:
|
|
return ModelProvider(name)
|
|
except ValueError as e:
|
|
valid = ", ".join(p.value for p in ModelProvider)
|
|
raise ModelYAMLError(
|
|
f"{source}: unknown provider '{name}'. Valid: {valid}"
|
|
) from e
|
|
|
|
|
|
def _build_model(
|
|
entry: _ModelEntry,
|
|
defaults: _CapabilityFields,
|
|
provider: ModelProvider,
|
|
aliases: Dict[str, List[str]],
|
|
source: Path,
|
|
display_provider: Optional[str] = None,
|
|
) -> AvailableModel:
|
|
"""Merge defaults + per-model overrides into a final ``AvailableModel``."""
|
|
|
|
def pick(field_name: str, fallback):
|
|
v = getattr(entry, field_name)
|
|
if v is not None:
|
|
return v
|
|
d = getattr(defaults, field_name)
|
|
if d is not None:
|
|
return d
|
|
return fallback
|
|
|
|
raw_attachments = entry.attachments
|
|
if raw_attachments is None:
|
|
raw_attachments = defaults.attachments
|
|
if raw_attachments is None:
|
|
raw_attachments = []
|
|
expanded = _expand_attachments(
|
|
raw_attachments, aliases, f"{source} [model={entry.id}]"
|
|
)
|
|
|
|
caps = ModelCapabilities(
|
|
supports_tools=pick("supports_tools", False),
|
|
supports_structured_output=pick("supports_structured_output", False),
|
|
supports_streaming=pick("supports_streaming", True),
|
|
supported_attachment_types=expanded,
|
|
context_window=pick("context_window", 128000),
|
|
input_cost_per_token=pick("input_cost_per_token", None),
|
|
output_cost_per_token=pick("output_cost_per_token", None),
|
|
reasoning_effort=pick("reasoning_effort", None),
|
|
api_flavor=pick("api_flavor", "chat_completions"),
|
|
)
|
|
|
|
return AvailableModel(
|
|
id=entry.id,
|
|
provider=provider,
|
|
display_name=entry.display_name or entry.id,
|
|
description=entry.description,
|
|
capabilities=caps,
|
|
enabled=entry.enabled,
|
|
base_url=entry.base_url,
|
|
upstream_model_id=entry.upstream_model_id,
|
|
display_provider=display_provider,
|
|
)
|
|
|
|
|
|
def _load_one_yaml(
|
|
path: Path, aliases: Dict[str, List[str]]
|
|
) -> ProviderCatalog:
|
|
try:
|
|
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
except yaml.YAMLError as e:
|
|
raise ModelYAMLError(f"{path}: invalid YAML: {e}") from e
|
|
try:
|
|
parsed = _ProviderFile.model_validate(raw)
|
|
except Exception as e:
|
|
raise ModelYAMLError(f"{path}: schema error: {e}") from e
|
|
|
|
provider_enum = _resolve_provider_enum(parsed.provider, path)
|
|
models = [
|
|
_build_model(
|
|
entry,
|
|
parsed.defaults,
|
|
provider_enum,
|
|
aliases,
|
|
path,
|
|
display_provider=parsed.display_provider,
|
|
)
|
|
for entry in parsed.models
|
|
]
|
|
|
|
return ProviderCatalog(
|
|
provider=parsed.provider,
|
|
models=models,
|
|
source_path=path,
|
|
display_provider=parsed.display_provider,
|
|
api_key_env=parsed.api_key_env,
|
|
base_url=parsed.base_url,
|
|
)
|
|
|
|
|
|
_BUILTIN_ALIASES_CACHE: Optional[Dict[str, List[str]]] = None
|
|
|
|
|
|
def builtin_attachment_aliases() -> Dict[str, List[str]]:
|
|
"""Return the built-in attachment alias map from ``_defaults.yaml``.
|
|
|
|
Cached after first read so repeat calls are cheap.
|
|
"""
|
|
global _BUILTIN_ALIASES_CACHE
|
|
if _BUILTIN_ALIASES_CACHE is None:
|
|
_BUILTIN_ALIASES_CACHE = _load_defaults(BUILTIN_MODELS_DIR)
|
|
return _BUILTIN_ALIASES_CACHE
|
|
|
|
|
|
def resolve_attachment_alias(alias: str) -> List[str]:
|
|
"""Resolve a single attachment alias (e.g. ``"image"``) to its
|
|
canonical MIME-type list. Raises ``ModelYAMLError`` if unknown.
|
|
"""
|
|
aliases = builtin_attachment_aliases()
|
|
if alias not in aliases:
|
|
valid = ", ".join(sorted(aliases.keys())) or "<none defined>"
|
|
raise ModelYAMLError(
|
|
f"Unknown attachment alias '{alias}'. Valid: {valid}"
|
|
)
|
|
return list(aliases[alias])
|
|
|
|
|
|
def expand_attachments_lenient(
|
|
attachments: Sequence[str], source: str
|
|
) -> List[str]:
|
|
"""Expand attachment aliases to MIME types, tolerating unknowns.
|
|
|
|
Mirrors ``_expand_attachments`` but logs+skips unknown aliases
|
|
rather than raising. Used for runtime call sites (BYOM registry
|
|
load) where an operator-side alias-map edit must not drop the
|
|
entire user's BYOM layer; the strict raise still happens at the
|
|
API validation boundary.
|
|
"""
|
|
aliases = builtin_attachment_aliases()
|
|
expanded: List[str] = []
|
|
seen: set = set()
|
|
for entry in attachments:
|
|
if "/" in entry:
|
|
if entry not in seen:
|
|
expanded.append(entry)
|
|
seen.add(entry)
|
|
continue
|
|
mime_list = aliases.get(entry)
|
|
if mime_list is None:
|
|
logger.warning(
|
|
"%s: skipping unknown attachment alias %r", source, entry,
|
|
)
|
|
continue
|
|
for mime in mime_list:
|
|
if mime not in seen:
|
|
expanded.append(mime)
|
|
seen.add(mime)
|
|
return expanded
|
|
|
|
|
|
def load_model_yamls(directories: Sequence[Path]) -> List[ProviderCatalog]:
|
|
"""Load every ``*.yaml`` file (excluding ``_defaults.yaml``) under each
|
|
directory in order and return a flat list of catalogs.
|
|
|
|
Caller is responsible for merging multiple catalogs that target the
|
|
same provider plugin. The flat-list shape lets ``openai_compatible``
|
|
keep each file separate (one logical endpoint per file).
|
|
|
|
When the same model ``id`` appears in more than one YAML across the
|
|
directory list, a warning is logged. Order in the returned list
|
|
preserves load order, so the registry's "later wins" merge gives the
|
|
later directory's definition.
|
|
"""
|
|
catalogs: List[ProviderCatalog] = []
|
|
seen_ids: Dict[str, Path] = {}
|
|
|
|
aliases: Dict[str, List[str]] = {}
|
|
for d in directories:
|
|
if not d or not d.exists():
|
|
continue
|
|
aliases.update(_load_defaults(d))
|
|
|
|
for d in directories:
|
|
if not d or not d.exists():
|
|
continue
|
|
for path in sorted(d.glob("*.yaml")):
|
|
if path.name == DEFAULTS_FILENAME:
|
|
continue
|
|
catalog = _load_one_yaml(path, aliases)
|
|
catalogs.append(catalog)
|
|
for m in catalog.models:
|
|
prior = seen_ids.get(m.id)
|
|
if prior is not None and prior != path:
|
|
logger.warning(
|
|
"Model id %r redefined: %s overrides %s (later wins)",
|
|
m.id,
|
|
path,
|
|
prior,
|
|
)
|
|
seen_ids[m.id] = path
|
|
|
|
return catalogs
|