Skip to content
21 changes: 21 additions & 0 deletions backend/consts/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,27 @@ class RuntimeCapacityExceededError(RuntimeError):
retry_after_seconds = 1


class ModelOutletConflictError(ValueError):
"""An absent, stale or unavailable outlet cannot be overwritten."""

def __init__(self):
super().__init__("MODEL_OUTLET_VERSION_CONFLICT")


class ModelOutletValidationError(ValueError):
"""Invalid storage input without parameter or credential details."""

def __init__(self):
super().__init__("MODEL_OUTLET_INVALID")


class ModelOutletDatabaseError(RuntimeError):
"""Safe database diagnostic excluding SQL bound values."""

def __init__(self):
super().__init__("MODEL_OUTLET_DATABASE_ERROR")


class RuntimeQueueTimeoutError(RuntimeError):
"""Raised when an accepted Runtime agent request expires in the queue."""

Expand Down
18 changes: 18 additions & 0 deletions backend/database/db_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,24 @@ class ModelRecord(TableBase):
extra_params = Column(
JSONB, doc="Fixed inference params without dedicated columns (key-value pairs constrained by FIXED_INFERENCE_FIELDS_BY_TYPE). NULL means no extra params.")

# Schema extension only: NULL capability values remain unknown until outlet migration.
reasoning_supported = Column(Boolean, comment="Reasoning support: true supported, false unsupported, NULL unknown.")
reasoning_controls = Column(JSONB, comment="Declared reasoning control domains; NULL unknown, empty array no controls.")
native_tool_call_supported = Column(Boolean, comment="Native tool call support; NULL unknown, not an execution guarantee.")
structured_output_supported = Column(Boolean, comment="Structured output support; NULL unknown, formats require binding.")
temperature_supported = Column(Boolean, comment="Temperature control support; NULL unknown, separate from parameter value.")
top_p_supported = Column(Boolean, comment="Top-p control support; NULL unknown, separate from parameter value.")
input_modalities = Column(JSONB, comment="Unique supported input modality names; NULL unknown, empty array no modalities.")
output_modalities = Column(JSONB, comment="Unique supported output modality names; NULL unknown, empty array no modalities.")
enable_thinking = Column(Boolean, comment="Saved thinking switch; NULL omitted unless marked as explicit JSON null.")
reasoning_effort = Column(String(30), comment="Saved reasoning effort; NULL omitted unless marked as explicit JSON null.")
reasoning_budget_tokens = Column(Integer, comment="Saved reasoning token budget; sentinel values require explicit declaration.")
protocol_id = Column(String(30), comment="Registered model protocol; NULL only before resolved outlet migration completes.")
resolved_profile = Column(JSONB, comment="Remaining resolved rules, bindings and provenance; no copies of dedicated column values.")
profile_schema_version = Column(Integer, comment="Persisted resolved profile schema version; NULL for pre-migration rows.")
config_revision = Column(Integer, nullable=False, default=1, server_default=text("1"),
comment="Outlet configuration revision for optimistic updates; initial value 1.")


class ModelMonitoringRecord(SimpleTableBase):
"""
Expand Down
18 changes: 18 additions & 0 deletions backend/database/model_management_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,24 @@
logger = logging.getLogger("database.model_management_db")


def create_resolved_model_record(*args, **kwargs):
"""Create a resolved outlet through the transactional storage implementation."""
from .model_outlet_db import create_resolved_model_record as create_resolved
return create_resolved(*args, **kwargs)


def get_resolved_model_record(*args, **kwargs):
"""Load a tenant-owned resolved outlet without applying legacy parameter filters."""
from .model_outlet_db import get_resolved_model_record as get_resolved
return get_resolved(*args, **kwargs)


def update_resolved_model_record(*args, **kwargs):
"""Update a resolved outlet with an expected configuration revision."""
from .model_outlet_db import update_resolved_model_record as update_resolved
return update_resolved(*args, **kwargs)


def create_model_record(model_data: Dict[str, Any], user_id: str, tenant_id: str) -> bool:
"""
Create a model record
Expand Down
271 changes: 271 additions & 0 deletions backend/database/model_outlet_db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
"""Resolved model-record operations using caller-owned transactions and revisions."""

from collections.abc import Mapping, Sequence

from consts.exceptions import (
ModelOutletConflictError,
ModelOutletDatabaseError,
ModelOutletValidationError,
)
from nexent.core.model_service.profiles.contracts import VersionedReference
from nexent.core.model_service.profiles.outlet_storage import (
decode_resolved_model_profile,
encode_resolved_model_profile,
)
from nexent.core.model_service.profiles.parameter_rules import (
compile_parameter_rules,
validate_saved_parameter_values,
)
from nexent.core.model_service.profiles.parameter_schema import json_values_equal
from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile
from sqlalchemy import func, insert, select, update
from sqlalchemy.exc import SQLAlchemyError

from .client import get_db_session
from .db_models import ModelRecord

_UNSET = object()


def _string(value, maximum=100):
if (
not isinstance(value, str)
or not value.strip()
or len(value) > maximum
or "\x00" in value
):
raise ModelOutletValidationError()
try:
value.encode("utf-8")
except UnicodeError:
raise ModelOutletValidationError() from None


def _credential(value):
if value is None:
return
if not isinstance(value, str) or len(value) > 500 or "\x00" in value:
raise ModelOutletValidationError()
try:
value.encode("utf-8")
except UnicodeError:
raise ModelOutletValidationError() from None


def _check_json_strings(value):
if isinstance(value, str):
if "\x00" in value:
raise ModelOutletValidationError()
elif isinstance(value, dict):
for key, child in value.items():
_check_json_strings(key)
_check_json_strings(child)
elif isinstance(value, list):
for child in value:
_check_json_strings(child)


def _execute(session, statement):
try:
return session.execute(statement)
except SQLAlchemyError:
# Session owners handle rollback; never expose statement parameters to their logger.
raise ModelOutletDatabaseError() from None


def _active_statement(model_id, tenant_id):
if type(model_id) is not int or not 0 < model_id < 2**31:
raise ModelOutletValidationError()
_string(tenant_id)
return select(ModelRecord.__table__).where(
ModelRecord.model_id == model_id,
ModelRecord.tenant_id == tenant_id,
ModelRecord.delete_flag == "N",
)


def create_resolved_model_record(
profile: ResolvedModelProfile,
user_id: str,
tenant_id: str,
*,
registered_references: Mapping[str, Sequence[VersionedReference]],
api_key: str | None = None,
db_session=None,
protected_paths: Sequence[str] = (),
) -> int:
"""Insert one complete snapshot; return its assigned identifier without committing shared sessions."""
_string(user_id)
_string(tenant_id)
_credential(api_key)
if (
not isinstance(profile, ResolvedModelProfile)
or profile.outlet_id is not None
or profile.config_revision != 1
or profile.identity.tenant_id != tenant_id
):
raise ModelOutletValidationError()
values = encode_resolved_model_profile(
profile,
registered_references=registered_references,
protected_paths=protected_paths,
)
_check_json_strings(values)
values.update(
api_key=api_key,
created_by=user_id,
updated_by=user_id,
delete_flag="N",
create_time=func.current_timestamp(),
update_time=func.current_timestamp(),
)
with get_db_session(db_session) as session:
return _execute(
session, insert(ModelRecord).values(values).returning(ModelRecord.model_id)
).scalar_one()


def get_resolved_model_record(
model_id: int,
tenant_id: str,
*,
registered_references: Mapping[str, Sequence[VersionedReference]],
db_session=None,
protected_paths: Sequence[str] = (),
) -> ResolvedModelProfile | None:
"""Read active rows only; missing and other-tenant rows both return no result."""
statement = _active_statement(model_id, tenant_id)
with get_db_session(db_session) as session:
row = _execute(session, statement).mappings().first()
if row is None:
return None
return decode_resolved_model_profile(
row,
registered_references=registered_references,
protected_paths=protected_paths,
)


def _validate_update(current, proposed):
for key in (
"provider_id",
"request_model_id",
"model_type",
"tenant_id",
"provider_key",
"model_profile_key",
):
if getattr(current.identity, key) != getattr(proposed.identity, key):
raise ModelOutletValidationError()
for key in (
"schema_version",
"bindings",
"instruction_mapping",
"connection_rules",
"provenance",
"parameter_rules",
):
left, right = getattr(current, key), getattr(proposed, key)
if hasattr(left, "model_dump"):
left, right = left.model_dump(mode="json"), right.model_dump(mode="json")
if not json_values_equal(left, right):
raise ModelOutletValidationError()
old_caps, new_caps = (
current.capabilities.model_dump(),
proposed.capabilities.model_dump(),
)
old_caps.pop("context")
new_caps.pop("context")
if not json_values_equal(old_caps, new_caps):
raise ModelOutletValidationError()
compiled = compile_parameter_rules(current.parameter_rules)
validate_saved_parameter_values(compiled, proposed.parameter_values)
rules = {rule["key"]: rule for rule in compiled.declarations}
for key in current.parameter_values.keys() | proposed.parameter_values.keys():
unchanged = (
key in current.parameter_values
and key in proposed.parameter_values
and json_values_equal(
current.parameter_values[key], proposed.parameter_values[key]
)
)
if not unchanged and (
not rules[key]["editable"] or "outlet" not in rules[key]["allowed_scopes"]
):
raise ModelOutletValidationError()


def update_resolved_model_record(
profile: ResolvedModelProfile,
expected_revision: int,
user_id: str,
tenant_id: str,
*,
registered_references: Mapping[str, Sequence[VersionedReference]],
api_key=_UNSET,
db_session=None,
protected_paths: Sequence[str] = (),
) -> int:
"""Atomically update a permitted snapshot; return the new revision or raise a safe conflict."""
_string(user_id)
_string(tenant_id)
if (
not isinstance(profile, ResolvedModelProfile)
or profile.outlet_id is None
or profile.identity.tenant_id != tenant_id
or type(expected_revision) is not int
or not 0 < expected_revision < 2**31 - 1
or profile.config_revision != expected_revision
):
raise ModelOutletValidationError()
if api_key is not _UNSET:
_credential(api_key)
values = encode_resolved_model_profile(
profile,
registered_references=registered_references,
protected_paths=protected_paths,
)
_check_json_strings(values)
with get_db_session(db_session) as session:
current = get_resolved_model_record(
profile.outlet_id,
tenant_id,
registered_references=registered_references,
db_session=session,
protected_paths=protected_paths,
)
if current is None or current.config_revision != expected_revision:
raise ModelOutletConflictError()
_validate_update(current, profile)
for key in (
"model_id",
"tenant_id",
"model_factory",
"model_name",
"model_type",
"protocol_id",
"profile_schema_version",
):
values.pop(key)
values.update(
config_revision=expected_revision + 1,
updated_by=user_id,
update_time=func.current_timestamp(),
)
if api_key is not _UNSET:
values["api_key"] = api_key
statement = (
update(ModelRecord)
.where(
ModelRecord.model_id == profile.outlet_id,
ModelRecord.tenant_id == tenant_id,
ModelRecord.delete_flag == "N",
ModelRecord.config_revision == expected_revision,
)
.values(values)
.returning(ModelRecord.config_revision)
)
revision = _execute(session, statement).scalar_one_or_none()
if revision is None:
raise ModelOutletConflictError()
return revision
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- Add resolved outlet storage without inferring legacy model capabilities.
-- Existing configuration values remain in their original columns.
ALTER TABLE nexent.model_record_t
ADD COLUMN IF NOT EXISTS reasoning_supported BOOLEAN,
ADD COLUMN IF NOT EXISTS reasoning_controls JSONB,
ADD COLUMN IF NOT EXISTS native_tool_call_supported BOOLEAN,
ADD COLUMN IF NOT EXISTS structured_output_supported BOOLEAN,
ADD COLUMN IF NOT EXISTS temperature_supported BOOLEAN,
ADD COLUMN IF NOT EXISTS top_p_supported BOOLEAN,
ADD COLUMN IF NOT EXISTS input_modalities JSONB,
ADD COLUMN IF NOT EXISTS output_modalities JSONB,
ADD COLUMN IF NOT EXISTS enable_thinking BOOLEAN,
ADD COLUMN IF NOT EXISTS reasoning_effort VARCHAR(30),
ADD COLUMN IF NOT EXISTS reasoning_budget_tokens INTEGER,
ADD COLUMN IF NOT EXISTS protocol_id VARCHAR(30),
ADD COLUMN IF NOT EXISTS resolved_profile JSONB,
ADD COLUMN IF NOT EXISTS profile_schema_version INTEGER,
ADD COLUMN IF NOT EXISTS config_revision INTEGER NOT NULL DEFAULT 1;

COMMENT ON COLUMN nexent.model_record_t.reasoning_supported IS 'Reasoning support: true supported, false unsupported, NULL unknown.';
COMMENT ON COLUMN nexent.model_record_t.reasoning_controls IS 'Declared reasoning control domains; NULL unknown, empty array no controls.';
COMMENT ON COLUMN nexent.model_record_t.native_tool_call_supported IS 'Native tool call support; NULL unknown, not an execution guarantee.';
COMMENT ON COLUMN nexent.model_record_t.structured_output_supported IS 'Structured output support; NULL unknown, formats require binding.';
COMMENT ON COLUMN nexent.model_record_t.temperature_supported IS 'Temperature control support; NULL unknown, separate from parameter value.';
COMMENT ON COLUMN nexent.model_record_t.top_p_supported IS 'Top-p control support; NULL unknown, separate from parameter value.';
COMMENT ON COLUMN nexent.model_record_t.input_modalities IS 'Unique supported input modality names; NULL unknown, empty array no modalities.';
COMMENT ON COLUMN nexent.model_record_t.output_modalities IS 'Unique supported output modality names; NULL unknown, empty array no modalities.';
COMMENT ON COLUMN nexent.model_record_t.enable_thinking IS 'Saved thinking switch; NULL omitted unless marked as explicit JSON null.';
COMMENT ON COLUMN nexent.model_record_t.reasoning_effort IS 'Saved reasoning effort; NULL omitted unless marked as explicit JSON null.';
COMMENT ON COLUMN nexent.model_record_t.reasoning_budget_tokens IS 'Saved reasoning token budget; sentinel values require explicit declaration.';
COMMENT ON COLUMN nexent.model_record_t.protocol_id IS 'Registered model protocol; NULL only before resolved outlet migration completes.';
COMMENT ON COLUMN nexent.model_record_t.resolved_profile IS 'Remaining resolved rules, bindings and provenance; no copies of dedicated column values.';
COMMENT ON COLUMN nexent.model_record_t.profile_schema_version IS 'Persisted resolved profile schema version; NULL for pre-migration rows.';
COMMENT ON COLUMN nexent.model_record_t.config_revision IS 'Outlet configuration revision for optimistic updates; initial value 1.';
Loading
Loading