Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions backend/consts/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,29 @@ 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, category="MODEL_OUTLET_INVALID", path=""):
super().__init__("MODEL_OUTLET_INVALID")
self.category = category
self.path = path


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
113 changes: 113 additions & 0 deletions backend/consts/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,119 @@ def _validated_context_policy(value: Optional[Dict[str, Any]]) -> Optional[Dict[
return value


class ModelProfileSelection(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str = Field(min_length=1, max_length=200)
revision: str = Field(min_length=1, max_length=100)


class ModelProfileProviderInfo(BaseModel):
model_config = ConfigDict(extra="forbid")
base_url: str = Field(min_length=1, max_length=500)
provider_id: Optional[str] = Field(default=None, min_length=1, max_length=100)
ssl_verify: bool = True
timeout_seconds: int = Field(default=120, gt=0, lt=2**31)
concurrency_limit: Optional[int] = Field(default=None, gt=0, lt=2**31)


class ModelOutletContextPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
combined_window: Optional[int] = Field(default=None, gt=0, lt=2**31, strict=True)
input_limit: Optional[int] = Field(default=None, gt=0, lt=2**31, strict=True)
output_limit: Optional[int] = Field(default=None, gt=0, lt=2**31, strict=True)


class ModelOutletContextConfigurationPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
output_reserve_tokens: Optional[int] = Field(default=None, gt=0, lt=2**31, strict=True)
tokenizer_family: Optional[str] = Field(default=None, min_length=1, max_length=100)
capacity_source: Optional[str] = Field(default=None, min_length=1, max_length=100)
capability_profile_version: Optional[str] = Field(default=None, min_length=1, max_length=100)


ThinkingControlFormat = Literal["profile_default", "enable_thinking", "thinking_type", "chat_template"]


class ModelProfilePreviewRequest(BaseModel):
"""Selection inputs only; clients cannot author effective capabilities or bindings."""
model_config = ConfigDict(extra="forbid")
selection_mode: Literal["auto", "manual"] = "auto"
model_profile: Optional[ModelProfileSelection] = None
provider_profile: Optional[ModelProfileSelection] = None
catalog_revision: Optional[str] = Field(default=None, min_length=1, max_length=100)
provider_info: ModelProfileProviderInfo
request_model_id: str = Field(min_length=1, max_length=100)
display_name: Optional[str] = Field(default=None, min_length=1, max_length=100)
protocol_id: Literal["openai_chat_completions"] = "openai_chat_completions"
authentication_scheme: Literal["bearer_token_v1", "no_auth_v1"] = "bearer_token_v1"
parameter_values: Dict[str, Any] = Field(default_factory=dict)
extra_api_parameters: List[Dict[str, Any]] = Field(default_factory=list)
context_limits: Optional[ModelOutletContextPatch] = None
context_configuration: Optional[ModelOutletContextConfigurationPatch] = None
thinking_control: Optional[ThinkingControlFormat] = None
thinking_mode: Optional[Literal["default", "effort", "budget_tokens"]] = None


class ModelOutletCreateRequest(ModelProfilePreviewRequest):
api_key: Optional[str] = Field(default=None, max_length=500, repr=False)


class ModelOutletConnectionPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
base_url: Optional[str] = Field(default=None, min_length=1, max_length=500)
ssl_verify: Optional[bool] = None
timeout_seconds: Optional[int] = Field(default=None, gt=0, lt=2**31, strict=True)
concurrency_limit: Optional[int] = Field(default=None, gt=0, lt=2**31, strict=True)


class ModelOutletPatchRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(gt=0, lt=2**31, strict=True)
parameter_values: Dict[str, Any] = Field(default_factory=dict)
delete_parameters: List[str] = Field(default_factory=list, max_length=128)
connection: Optional[ModelOutletConnectionPatch] = None
display_name: Optional[str] = Field(default=None, min_length=1, max_length=100)
api_key: Optional[str] = Field(default=None, max_length=500, repr=False)
extra_api_parameters: Optional[List[Dict[str, Any]]] = None
context_limits: Optional[ModelOutletContextPatch] = None
context_configuration: Optional[ModelOutletContextConfigurationPatch] = None
thinking_control: Optional[ThinkingControlFormat] = None
thinking_mode: Optional[Literal["default", "effort", "budget_tokens"]] = None


class ConversationModelPreferencePatch(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_version: int = Field(ge=0, lt=2**31 - 1, strict=True)
parameter_values: Dict[str, Any] = Field(default_factory=dict)
delete_parameters: List[str] = Field(default_factory=list, max_length=128)


class ConversationModelSelectionPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_version: int = Field(ge=0, lt=2**31 - 1, strict=True)
selected_model_id: int = Field(gt=0, lt=2**31, strict=True)


class ModelOutletMigrationReviewRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(gt=0, lt=2**31, strict=True)


class ModelOutletValidationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
outlet_id: Optional[int] = Field(default=None, gt=0, lt=2**31, strict=True)
expected_revision: Optional[int] = Field(default=None, gt=0, lt=2**31, strict=True)
draft: Optional[ModelOutletCreateRequest] = None

@model_validator(mode="after")
def require_single_source(self):
if (self.outlet_id is None) == (self.draft is None):
raise ValueError("Select exactly one draft or saved outlet")
if self.outlet_id is None and self.expected_revision is not None:
raise ValueError("A draft has no saved revision")
return self


class ModelConnectStatusEnum(Enum):
"""Enum class for model connection status"""
NOT_DETECTED = "not_detected"
Expand Down
27 changes: 26 additions & 1 deletion backend/database/db_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ class ConversationRecord(TableBase):
workbench_config = Column(
JSONB,
nullable=True,
doc="Canonical schema-v3 Workbench conversation declaration",
doc="Canonical schema-v4 Workbench resource declaration",
)
workbench_pinned_at = Column(
TIMESTAMP(timezone=True), nullable=True,
Expand All @@ -108,6 +108,12 @@ class ConversationRecord(TableBase):
doc="Monotonic version of the canonical Workbench declaration",
)

selected_model_id = Column(Integer, nullable=True, comment="Root model selected for this Agent conversation; NULL inherits the Agent default.")
model_parameter_preferences = Column(JSONB, nullable=False, server_default=text("'{}'::jsonb"),
comment="Explicit parameter preferences keyed by outlet ID; no credentials or default-value copies.")
model_parameter_preferences_version = Column(Integer, nullable=False, server_default=text("0"),
comment="Optimistic revision of model preferences, independent of metadata and Workbench configuration.")


class ConversationMessage(TableBase):
"""
Expand Down Expand Up @@ -497,6 +503,25 @@ 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.")
default_max_output_tokens = Column(Integer, comment="Saved LLM request output default; separate from the hard output cap. NULL omitted unless explicitly marked 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
9 changes: 9 additions & 0 deletions backend/management/services/model/reasoning.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.

"""Shared normalization for canonical reasoning settings."""

import logging
Expand Down Expand Up @@ -134,6 +136,13 @@ def _enrich_model_reasoning_capability(model: Dict[str, Any]) -> None:
"""Attach catalog-declared reasoning capability to an API model row."""
if model.get("model_type") not in {"llm", "chat"}:
return
remainder = model.get("resolved_profile")
if model.get("profile_schema_version") is not None or (
isinstance(remainder, dict) and "migration_error" in remainder):
from services.model_outlet_projection import project_saved_outlet

model.update(project_saved_outlet(model))
return
model_name = add_repo_to_name(
model.get("model_repo", ""), model.get("model_name", "")
)
Expand Down
51 changes: 51 additions & 0 deletions backend/services/model_outlet_projection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.

"""Memory-only legacy API views of saved outlets; never recompose or persist projections."""

from nexent.core.model_service.profiles.outlet_storage import ProfileStorageError, decode_resolved_model_profile
from nexent.core.model_service.profiles.request_parameters import apply_extra_api_parameters
from nexent.core.model_service.registry import model_service_references


def project_saved_outlet(row):
"""Return compatible read fields and review state without modifying the stored row."""
remainder = row.get("resolved_profile")
if row.get("profile_schema_version") is None:
if isinstance(remainder, dict) and "migration_error" in remainder:
return {"migration_status": "failed", "migration_diagnostic": remainder["migration_error"]}
return None
try:
profile = decode_resolved_model_profile(row, registered_references=model_service_references())
except ProfileStorageError:
return {"migration_status": "failed", "migration_diagnostic": {"category": "PROFILE_STORAGE_INVALID"}}
controls = []
levels = []
primary = "toggle"
for control in profile.capabilities.reasoning.controls or []:
primary = control.mode
item = {"type": control.mode}
if control.mode == "effort":
item["values"] = list(control.values)
levels = [value for value in control.values if isinstance(value, str)]
elif control.mode == "budget_tokens":
item.update(min=control.min, max=control.max)
controls.append(item)
params = {key: value for key, value in profile.parameter_values.items()
if key not in {"temperature", "top_p", "max_output_tokens", "max_tokens"}}
custom = apply_extra_api_parameters({}, profile.extra_api_parameters)
if custom:
params["__custom__"] = custom
result = {"extra_params": params or None,
"max_tokens": profile.parameter_values.get("max_output_tokens", profile.capabilities.context.output_limit),
"reasoning_capability": {"status": profile.capabilities.reasoning.status, "control": primary,
"levels": levels, "controls": controls, "default": profile.parameter_values.get("reasoning_effort", "auto"),
"provider_id": profile.identity.provider_id, "matched_model_id": profile.identity.request_model_id,
"matched_api": profile.connection.base_url, "source": "operator"},
"profile_schema_version": profile.schema_version, "config_revision": profile.config_revision}
migration = profile.provenance.provider_metadata.get("migration")
if isinstance(migration, dict):
result.update(migration_status="migrated", migration_review_required=
migration.get("reviewed_revision") != profile.config_revision,
migration_details={key: migration.get(key) for key in ("from_version", "version", "migrated_at",
"defaulted_fields", "unmatched_atoms", "unbound_legacy_parameters")})
return result
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
-- Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.

-- 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.';
7 changes: 7 additions & 0 deletions deploy/sql/migrations/v2.7.1_003_model_output_default.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.

-- Preserve the existing hard output cap; store the LLM request default once.
ALTER TABLE nexent.model_record_t
ADD COLUMN IF NOT EXISTS default_max_output_tokens INTEGER;
COMMENT ON COLUMN nexent.model_record_t.default_max_output_tokens IS
'Saved LLM request output default; separate from the hard output cap. NULL omitted unless explicitly marked null.';
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.

-- Extend existing conversation configuration; preferences never enter model-visible metadata.
ALTER TABLE nexent.conversation_record_t
ADD COLUMN IF NOT EXISTS model_parameter_preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN IF NOT EXISTS model_parameter_preferences_version INTEGER NOT NULL DEFAULT 0;
COMMENT ON COLUMN nexent.conversation_record_t.model_parameter_preferences IS
'Explicit parameter preferences keyed by outlet ID for this conversation; no credentials or default-value copies.';
COMMENT ON COLUMN nexent.conversation_record_t.model_parameter_preferences_version IS
'Optimistic revision of conversation model preferences; independent of runtime metadata and Workbench configuration.';
2 changes: 2 additions & 0 deletions sdk/nexent/core/agents/agent_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator

from ..model_service.profiles.resolved_profile import ResolvedModelProfile
from ..models.capacity_budget import ContextBudgetSnapshot
from ..models.sampling import DEFAULT_TEMPERATURE, DEFAULT_TOP_P, resolve_sampling
from ..utils.observer import MessageObserver
Expand Down Expand Up @@ -53,6 +54,7 @@ def preserve_services(value):


class ModelConfig(BaseModel):
resolved_profile: Optional[ResolvedModelProfile] = Field(default=None, repr=False)
diagnostic_chunks_enabled: bool = Field(
default=False, description="Capture bounded content-free response chunk structure"
)
Expand Down
Loading