From 2ec24b7b7b4d3214adf7f3be0218d3da5fb2ca13 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 27 Oct 2026 11:01:16 +0800 Subject: [PATCH] Outlet rules and model execution service Refresh existing review layer 4/9 onto develop ef7a3b553. Includes the verified model-access integration at e4a61b399. Local preparation only; no remote updates. Rebased onto approved develop 003771c9d; local validation only. --- backend/contracts/errors/exceptions.py | 23 + backend/contracts/model.py | 152 +++++- backend/management/db/conversation/models.py | 8 +- backend/management/db/model/models.py | 22 + .../services/model/outlet_projection.py | 51 ++ .../management/services/model/reasoning.py | 9 + ...2.7.1_002_model_outlet_profile_columns.sql | 36 ++ .../v2.7.1_003_model_output_default.sql | 7 + ...7.1_004_conversation_model_preferences.sql | 10 + sdk/nexent/core/agents/agent_model.py | 3 + sdk/nexent/core/agents/core_agent.py | 20 +- sdk/nexent/core/gateway/model_context.py | 8 + .../compatibility/agent_parameters.py | 42 ++ .../compatibility/invocation_options.py | 74 +++ .../compatibility/legacy_config.py | 98 ++++ .../compatibility/legacy_reasoning.py | 35 ++ sdk/nexent/core/model_service/http_sender.py | 322 +++++++++++ .../profiles/outlet_capabilities.py | 142 +++++ .../profiles/outlet_parameters.py | 102 ++++ .../profiles/outlet_resolution.py | 143 +++++ .../model_service/profiles/outlet_rules.py | 132 +++++ .../model_service/profiles/outlet_storage.py | 217 ++++++++ .../profiles/protocol_parameters.py | 72 +++ .../profiles/thinking_configuration.py | 161 ++++++ sdk/nexent/core/model_service/registry.py | 118 +++++ sdk/nexent/core/models/capacity_budget.py | 34 ++ .../core/models/openai_long_context_model.py | 38 +- sdk/nexent/monitor/monitoring.py | 16 +- test/sdk/core/agents/conftest.py | 14 + test/sdk/core/model_service/conftest.py | 74 +++ .../model_service/test_adapter_registry.py | 52 ++ .../test_authentication_registry.py | 121 +++++ .../core/model_service/test_composition.py | 467 ++++++++++++++++ .../model_service/test_errors_registry.py | 268 ++++++++++ .../core/model_service/test_http_sender.py | 499 ++++++++++++++++++ .../test_legacy_configuration_export.py | 48 ++ .../test_legacy_invocation_options.py | 123 +++++ .../test_legacy_reasoning_export.py | 56 ++ .../model_service/test_openai_chat_adapter.py | 464 ++++++++++++++++ .../core/model_service/test_openai_stream.py | 413 +++++++++++++++ .../model_service/test_outlet_capabilities.py | 238 +++++++++ .../model_service/test_outlet_parameters.py | 248 +++++++++ .../model_service/test_outlet_resolution.py | 160 ++++++ .../core/model_service/test_outlet_rules.py | 261 +++++++++ .../core/model_service/test_outlet_storage.py | 448 ++++++++++++++++ .../test_parameter_transforms.py | 63 +++ .../model_service/test_request_contracts.py | 98 ++++ .../model_service/test_resource_conversion.py | 89 ++++ .../test_thinking_configuration.py | 108 ++++ 49 files changed, 6343 insertions(+), 64 deletions(-) create mode 100644 backend/management/services/model/outlet_projection.py create mode 100644 deploy/sql/migrations/v2.7.1_002_model_outlet_profile_columns.sql create mode 100644 deploy/sql/migrations/v2.7.1_003_model_output_default.sql create mode 100644 deploy/sql/migrations/v2.7.1_004_conversation_model_preferences.sql create mode 100644 sdk/nexent/core/model_service/compatibility/agent_parameters.py create mode 100644 sdk/nexent/core/model_service/compatibility/invocation_options.py create mode 100644 sdk/nexent/core/model_service/compatibility/legacy_config.py create mode 100644 sdk/nexent/core/model_service/compatibility/legacy_reasoning.py create mode 100644 sdk/nexent/core/model_service/http_sender.py create mode 100644 sdk/nexent/core/model_service/profiles/outlet_capabilities.py create mode 100644 sdk/nexent/core/model_service/profiles/outlet_parameters.py create mode 100644 sdk/nexent/core/model_service/profiles/outlet_resolution.py create mode 100644 sdk/nexent/core/model_service/profiles/outlet_rules.py create mode 100644 sdk/nexent/core/model_service/profiles/outlet_storage.py create mode 100644 sdk/nexent/core/model_service/profiles/protocol_parameters.py create mode 100644 sdk/nexent/core/model_service/profiles/thinking_configuration.py create mode 100644 sdk/nexent/core/model_service/registry.py create mode 100644 test/sdk/core/agents/conftest.py create mode 100644 test/sdk/core/model_service/conftest.py create mode 100644 test/sdk/core/model_service/test_adapter_registry.py create mode 100644 test/sdk/core/model_service/test_authentication_registry.py create mode 100644 test/sdk/core/model_service/test_composition.py create mode 100644 test/sdk/core/model_service/test_errors_registry.py create mode 100644 test/sdk/core/model_service/test_http_sender.py create mode 100644 test/sdk/core/model_service/test_legacy_configuration_export.py create mode 100644 test/sdk/core/model_service/test_legacy_invocation_options.py create mode 100644 test/sdk/core/model_service/test_legacy_reasoning_export.py create mode 100644 test/sdk/core/model_service/test_openai_chat_adapter.py create mode 100644 test/sdk/core/model_service/test_openai_stream.py create mode 100644 test/sdk/core/model_service/test_outlet_capabilities.py create mode 100644 test/sdk/core/model_service/test_outlet_parameters.py create mode 100644 test/sdk/core/model_service/test_outlet_resolution.py create mode 100644 test/sdk/core/model_service/test_outlet_rules.py create mode 100644 test/sdk/core/model_service/test_outlet_storage.py create mode 100644 test/sdk/core/model_service/test_parameter_transforms.py create mode 100644 test/sdk/core/model_service/test_request_contracts.py create mode 100644 test/sdk/core/model_service/test_resource_conversion.py create mode 100644 test/sdk/core/model_service/test_thinking_configuration.py diff --git a/backend/contracts/errors/exceptions.py b/backend/contracts/errors/exceptions.py index 66010f2f6b..189d534df8 100644 --- a/backend/contracts/errors/exceptions.py +++ b/backend/contracts/errors/exceptions.py @@ -552,6 +552,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.""" diff --git a/backend/contracts/model.py b/backend/contracts/model.py index bcf9dae7bd..2d015d74c9 100644 --- a/backend/contracts/model.py +++ b/backend/contracts/model.py @@ -3,14 +3,137 @@ """Immutable model descriptors and capacity suggestion contracts.""" import math +import re from dataclasses import dataclass from enum import Enum from typing import Any, Dict, List, Literal, Optional -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from enum import Enum +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) + http_headers: Dict[str, str] = Field(default_factory=dict, repr=False) + 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): + check_receipt: Optional[str] = Field(default=None, max_length=1024, repr=False) + 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): + check_receipt: Optional[str] = Field(default=None, max_length=1024, repr=False) + 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) + http_headers: Optional[Dict[str, Optional[str]]] = Field(default=None, 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 + patch: Optional[ModelOutletPatchRequest] = 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") + if self.patch is not None and self.outlet_id is None: + raise ValueError("A patch requires a saved outlet") + if self.patch is not None and self.expected_revision not in (None, self.patch.expected_revision): + raise ValueError("Conflicting saved revisions") + return self + + ProfileKey = tuple[str, str] CapabilityProfileLike = Any @@ -202,6 +325,9 @@ class ProviderModelRequest(BaseModel): # opt out explicitly instead of verification being silently disabled. ssl_verify: bool = True + timeout_seconds: int = Field(default=120, gt=0, lt=2**31) + http_headers: Dict[str, str] = Field(default_factory=dict, repr=False) + class BatchCreateModelsRequest(BaseModel): api_key: str @@ -484,12 +610,12 @@ class ManageProviderModelCreateRequest(BaseModel): ) # v2.6.0: model_type is now optional. When omitted, returns all models and # infers type from model name. When provided, behavior is unchanged. - model_type: Optional[str] = Field( - None, - description="Model type (e.g., 'llm', 'embedding'). Optional since v2.6.0.", - ) - api_key: Optional[str] = Field("", description="API key for the provider") - base_url: Optional[str] = Field("", description="Base URL for the provider API") + model_type: Optional[str] = Field(None, description="Model type (e.g., 'llm', 'embedding'). Optional since v2.6.0.") + api_key: Optional[str] = Field('', description="API key for the provider") + base_url: Optional[str] = Field('', description="Base URL for the provider API") + ssl_verify: bool = True + timeout_seconds: int = Field(default=120, gt=0, lt=2**31) + http_headers: Dict[str, str] = Field(default_factory=dict, repr=False) # ============================================================================= @@ -1036,16 +1162,8 @@ def _infer_visual_model_type(name: str, final_segment: str) -> Optional[str]: ): return "vlm2" if ( - _name_matches( - name, - final_segment, - "qwen-vl-", - "glm-v", - "internvl-", - "llava-", - "gpt-4o-", - "gpt-4-vision-", - ) + _name_matches(name, final_segment, "qwen-vl-", "internvl-", "llava-", "gpt-4o-", "gpt-4-vision-") + or re.match(r"^glm[-_]\d+(?:[._-]\d+)?v(?:$|[-_])", final_segment) or "vl" in final_segment.split("-") or _name_contains_any(name, final_segment, "vision", "visual", "ocr") ): diff --git a/backend/management/db/conversation/models.py b/backend/management/db/conversation/models.py index cbaf4140de..4dd560ef4f 100644 --- a/backend/management/db/conversation/models.py +++ b/backend/management/db/conversation/models.py @@ -64,7 +64,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=False), nullable=True, @@ -77,6 +77,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): """ diff --git a/backend/management/db/model/models.py b/backend/management/db/model/models.py index 6275262530..b8a8f540cc 100644 --- a/backend/management/db/model/models.py +++ b/backend/management/db/model/models.py @@ -1,6 +1,7 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. from sqlalchemy import ( + text, Boolean, Column, Float, @@ -33,6 +34,8 @@ class ModelRecord(TableBase): String(100), doc="Model type, such as chat, embedding, rerank, tts, asr") api_key = Column( String(500), doc="Model API key, used for authentication for some models") + http_headers = Column(JSONB, nullable=False, server_default="{}", + comment="Credential-bearing custom HTTP headers, excluded from public responses and snapshots.") base_url = Column( String(500), doc="Base URL address for requesting remote model services") max_tokens = Column(Integer, doc="Maximum available tokens of the model") @@ -80,3 +83,22 @@ class ModelRecord(TableBase): Float, doc="Default nucleus sampling probability for LLM/VLM models. NULL means provider default. Nullable.") 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.") diff --git a/backend/management/services/model/outlet_projection.py b/backend/management/services/model/outlet_projection.py new file mode 100644 index 0000000000..d091669a64 --- /dev/null +++ b/backend/management/services/model/outlet_projection.py @@ -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 diff --git a/backend/management/services/model/reasoning.py b/backend/management/services/model/reasoning.py index 0b9bc0f4f0..c2ed9faa05 100644 --- a/backend/management/services/model/reasoning.py +++ b/backend/management/services/model/reasoning.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Shared normalization for canonical reasoning settings.""" import logging @@ -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 management.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", "") ) diff --git a/deploy/sql/migrations/v2.7.1_002_model_outlet_profile_columns.sql b/deploy/sql/migrations/v2.7.1_002_model_outlet_profile_columns.sql new file mode 100644 index 0000000000..fcd3f9b584 --- /dev/null +++ b/deploy/sql/migrations/v2.7.1_002_model_outlet_profile_columns.sql @@ -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.'; diff --git a/deploy/sql/migrations/v2.7.1_003_model_output_default.sql b/deploy/sql/migrations/v2.7.1_003_model_output_default.sql new file mode 100644 index 0000000000..9a8c281f41 --- /dev/null +++ b/deploy/sql/migrations/v2.7.1_003_model_output_default.sql @@ -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.'; diff --git a/deploy/sql/migrations/v2.7.1_004_conversation_model_preferences.sql b/deploy/sql/migrations/v2.7.1_004_conversation_model_preferences.sql new file mode 100644 index 0000000000..a1df100af4 --- /dev/null +++ b/deploy/sql/migrations/v2.7.1_004_conversation_model_preferences.sql @@ -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.'; diff --git a/sdk/nexent/core/agents/agent_model.py b/sdk/nexent/core/agents/agent_model.py index cd80c8e7d5..4ac0d0a3ec 100644 --- a/sdk/nexent/core/agents/agent_model.py +++ b/sdk/nexent/core/agents/agent_model.py @@ -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 @@ -53,6 +54,8 @@ def preserve_services(value): class ModelConfig(BaseModel): + http_headers: Dict[str, str] = Field(default_factory=dict, repr=False, exclude=True) + resolved_profile: Optional[ResolvedModelProfile] = Field(default=None, repr=False) diagnostic_chunks_enabled: bool = Field( default=False, description="Capture bounded content-free response chunk structure" ) diff --git a/sdk/nexent/core/agents/core_agent.py b/sdk/nexent/core/agents/core_agent.py index 6f43be7e17..1562d1e5b8 100644 --- a/sdk/nexent/core/agents/core_agent.py +++ b/sdk/nexent/core/agents/core_agent.py @@ -1371,24 +1371,8 @@ def rebuild_after_provider_overflow(): except ModelInvocationTerminalError as terminal_error: if self.stop_event.is_set(): raise RunTerminated() from terminal_error - finish_reason = getattr(self.model, "last_finish_reason", None) - if finish_reason is None: - diagnostics = getattr(self.model, "last_response_diagnostics", None) or {} - finish_reason = diagnostics.get("finish_reason") - if ( - terminal_error.error_code == ModelErrorCode.EMPTY_RESPONSE_EXHAUSTED - and legacy_code_action - and finish_reason != "length" - ): - raise RuntimeFinalAnswer( - self._empty_model_response_hint(), "empty_model_output" - ) from terminal_error - if terminal_error.error_code == ModelErrorCode.EMPTY_RESPONSE_EXHAUSTED and not legacy_code_action: - raise ModelOutputProtocolError( - reason=ProtocolErrorReason.EMPTY_VISIBLE_CONTENT, - protocol=getattr(self, "output_protocol", "code_action"), - logger=self.logger, - ) from terminal_error + # Empty-response recovery is exhausted in the model layer. Never + # restart it through the protocol repair or Agent step loop. raise except Exception as e: if self.stop_event.is_set(): diff --git a/sdk/nexent/core/gateway/model_context.py b/sdk/nexent/core/gateway/model_context.py index 323a221401..6964bae89c 100644 --- a/sdk/nexent/core/gateway/model_context.py +++ b/sdk/nexent/core/gateway/model_context.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Modality-specific construction contexts for the gateway.""" from __future__ import annotations @@ -5,6 +7,9 @@ from dataclasses import dataclass, field from typing import Any, Dict, Optional +from nexent.core.concurrency.cancellation import RunCancellationScope +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile + @dataclass class ModelContext: @@ -30,6 +35,9 @@ class ModelContext: @dataclass class LLMContext(ModelContext): + resolved_profile: Optional[ResolvedModelProfile] = field(default=None, repr=False) + http_headers: Dict[str, str] = field(default_factory=dict, repr=False) + cancellation_scope: Optional[RunCancellationScope] = field(default=None, repr=False) temperature: Optional[float] = None top_p: Optional[float] = None stream: Optional[bool] = None diff --git a/sdk/nexent/core/model_service/compatibility/agent_parameters.py b/sdk/nexent/core/model_service/compatibility/agent_parameters.py new file mode 100644 index 0000000000..128f687bb3 --- /dev/null +++ b/sdk/nexent/core/model_service/compatibility/agent_parameters.py @@ -0,0 +1,42 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Read historical Agent entries once into the current override structure.""" + +from copy import deepcopy + +from nexent.core.model_service.profiles.protocol_parameters import OUTLET_TOKEN_LIMIT_KEYS, without_outlet_token_limits + +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError + + +AGENT_PARAMETER_KEYS = ( + "temperature", "top_p", "top_k", "enable_thinking", "reasoning_effort", "reasoning_budget_tokens", +) + + +def read_agent_parameter_entry(entry): + """Current entries preserve explicit values; historical nulls meant inheritance.""" + if not isinstance(entry, dict): + raise ParameterValidationError("PARAMETER_OBJECT_REQUIRED", "/model_params_override") + if "parameter_values" in entry: + if not isinstance(entry["parameter_values"], dict): + raise ParameterValidationError("PARAMETER_OBJECT_REQUIRED", "/parameter_values") + if any(key in entry for key in (*AGENT_PARAMETER_KEYS, "extra_params")): + raise ParameterValidationError("PARAMETER_UPDATE_CONFLICT", "/model_params_override") + result = deepcopy(entry) + result["parameter_values"] = without_outlet_token_limits(result["parameter_values"]) + for key in OUTLET_TOKEN_LIMIT_KEYS: + result.pop(key, None) + if isinstance(result.get("custom_overrides"), dict): + result["custom_overrides"] = without_outlet_token_limits(result["custom_overrides"]) + return result + extra = entry.get("extra_params") or {} + if not isinstance(extra, dict): + raise ParameterValidationError("PARAMETER_OBJECT_REQUIRED", "/extra_params") + values = {key: entry[key] for key in AGENT_PARAMETER_KEYS if entry.get(key) is not None} + values.update({key: value for key, value in extra.items() if key in AGENT_PARAMETER_KEYS and value is not None}) + result = {key: deepcopy(value) for key, value in entry.items() if key not in (*AGENT_PARAMETER_KEYS, "extra_params", *OUTLET_TOKEN_LIMIT_KEYS)} + result["parameter_values"] = values + if "__custom__" in extra: + result["custom_overrides"] = without_outlet_token_limits(deepcopy(extra["__custom__"])) if isinstance(extra["__custom__"], dict) else deepcopy(extra["__custom__"]) + return result diff --git a/sdk/nexent/core/model_service/compatibility/invocation_options.py b/sdk/nexent/core/model_service/compatibility/invocation_options.py new file mode 100644 index 0000000000..e39372832c --- /dev/null +++ b/sdk/nexent/core/model_service/compatibility/invocation_options.py @@ -0,0 +1,74 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Legacy call options validated against the saved outlet's declarations.""" + +from dataclasses import dataclass +from typing import Mapping + +from nexent.core.model_service.profiles.parameter_rules import overlay_profile_parameters +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError, json_values_equal +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile +from nexent.core.models.capacity_budget import ( + CallerMaxTokensOverrideForbidden, + ContextBudgetSnapshot, + parse_context_budget_snapshot, + verify_context_budget_capacity, +) + + +@dataclass(frozen=True) +class LegacyInvocationOptions: + """Interface aliases only; no provider defaults or unregistered API fields.""" + + parameters: Mapping[str, object] + stop_sequences: object = None + response_format: object = None + context_budget_snapshot: object = None + capacity_snapshot: object = None + custom_overrides: Mapping[str, object] | None = None + + def resolve(self, profile: ResolvedModelProfile) -> ResolvedModelProfile: + submitted = dict(self.parameters) + for key, value in (("stop", self.stop_sequences), ("response_format", self.response_format)): + if value is None: + continue + if key in submitted and not json_values_equal(submitted[key], value): + raise ParameterValidationError("LEGACY_PARAMETER_CONFLICT", "/" + key) + submitted[key] = value + declared = {rule["key"] for rule in profile.parameter_rules} + # Old clients use these values to express omission, not new API fields. + if submitted.get("enable_thinking") is False and "enable_thinking" not in declared: + submitted.pop("enable_thinking") + if submitted.get("reasoning_effort") == "auto" and "reasoning_effort" not in declared: + submitted.pop("reasoning_effort") + output_keys = {rule["key"] for rule in profile.parameter_rules if rule.get("api_binding")} & { + "max_tokens", "max_output_tokens", + } + if "max_tokens" in submitted and output_keys == {"max_output_tokens"}: + value = submitted.pop("max_tokens") + if "max_output_tokens" in submitted and not json_values_equal(submitted["max_output_tokens"], value): + raise ParameterValidationError("LEGACY_PARAMETER_CONFLICT", "/max_output_tokens") + submitted["max_output_tokens"] = value + if self.context_budget_snapshot is not None: + if not isinstance(self.context_budget_snapshot, (dict, ContextBudgetSnapshot)): + raise TypeError("context_budget_snapshot must be a ContextBudgetSnapshot or dict") + budget = parse_context_budget_snapshot(self.context_budget_snapshot) + verify_context_budget_capacity(budget_snapshot=budget, capacity_snapshot=self.capacity_snapshot) + verify_context_budget_capacity(budget_snapshot=budget, capacity_snapshot={ + "provider": profile.identity.provider_id, "model_name": profile.identity.request_model_id, + }) + if len(output_keys) != 1: + raise ParameterValidationError("LEGACY_OUTPUT_BINDING_REQUIRED") + key = next(iter(output_keys)) + if submitted.get(key) is not None and not json_values_equal(submitted[key], budget.requested_output_tokens): + raise CallerMaxTokensOverrideForbidden( + snapshot_value=budget.requested_output_tokens, caller_value=submitted[key], + ) + submitted[key] = budget.requested_output_tokens + resolved = overlay_profile_parameters(profile, submitted, scope="outlet") if submitted else profile + if self.custom_overrides is not None: + from nexent.core.model_service.profiles.request_parameters import overlay_extra_api_parameters + snapshot = resolved.model_dump(mode="json") + snapshot["extra_api_parameters"] = overlay_extra_api_parameters(resolved, self.custom_overrides) + return ResolvedModelProfile.model_validate(snapshot) + return resolved diff --git a/sdk/nexent/core/model_service/compatibility/legacy_config.py b/sdk/nexent/core/model_service/compatibility/legacy_config.py new file mode 100644 index 0000000000..7f48902550 --- /dev/null +++ b/sdk/nexent/core/model_service/compatibility/legacy_config.py @@ -0,0 +1,98 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Pure interpretation of pre-profile model parameters, shared with one-time migration.""" + +from typing import Any, Dict, Optional + +from nexent.core.model_service.compatibility.legacy_reasoning import legacy_reasoning_wire_parameters + + +COMMON_REASONING_LEVELS = ("low", "medium", "high") +COMMON_REASONING_DEFAULT = "auto" + +def legacy_thinking_enabled(extra_params: Optional[Dict[str, Any]]) -> bool: + """Return whether the model explicitly or implicitly enables reasoning.""" + if not isinstance(extra_params, dict): + return False + if isinstance(extra_params.get("enable_thinking"), bool): + return extra_params["enable_thinking"] + return ( + isinstance(extra_params.get("reasoning_effort"), str) + or isinstance(extra_params.get("reasoning_budget_tokens"), int) + ) + + +def legacy_extra_body(extra_params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """Flatten historical custom fields, excluding dedicated reasoning values.""" + if not extra_params or not isinstance(extra_params, dict): + return None + extra_body = dict(extra_params) + # These are dedicated ModelConfig fields, not provider request-body keys. + extra_body.pop("reasoning_effort", None) + extra_body.pop("reasoning_budget_tokens", None) + custom = extra_body.pop("__custom__", None) + if custom and isinstance(custom, dict): + extra_body.update(custom) + return extra_body if extra_body else None + + +def legacy_reasoning_effort( + extra_params: Optional[Dict[str, Any]], + capability: Optional[Dict[str, Any]], +) -> Optional[str]: + """Resolve the enabled model's effort from its supported profile.""" + if not isinstance(extra_params, dict) or not legacy_thinking_enabled(extra_params): + return None + if extra_params.get("reasoning_effort") == "auto": + return None + capability = capability if isinstance(capability, dict) and capability.get("status") == "supported" else {} + levels = capability.get("levels") or list(COMMON_REASONING_LEVELS) + default = capability.get("default") + if default not in levels: + default = COMMON_REASONING_DEFAULT + saved = extra_params.get("reasoning_effort") + if saved in levels: + return saved + # Auto is the universal fallback: omit the provider-specific effort so the + # model can choose its own reasoning depth. + return None if saved is None or default == COMMON_REASONING_DEFAULT else default + + +def legacy_reasoning_budget( + extra_params: Optional[Dict[str, Any]], + capability: Optional[Dict[str, Any]], +) -> Optional[int]: + """Resolve a persisted numeric reasoning budget within the source range.""" + if not isinstance(extra_params, dict) or not legacy_thinking_enabled(extra_params): + return None + value = extra_params.get("reasoning_budget_tokens") + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + return None + controls = capability.get("controls") if isinstance(capability, dict) else None + budget_control = next( + (control for control in controls or [] + if isinstance(control, dict) and control.get("type") == "budget_tokens"), + None, + ) + if isinstance(budget_control, dict): + minimum = budget_control.get("min") + maximum = budget_control.get("max") + if isinstance(minimum, int) and isinstance(maximum, int): + return min(maximum, max(minimum, value)) + return value + + +def legacy_agent_request_parameters(record, *, model_id, capability): + """Resolve the old Agent's sampling/reasoning body without reading credentials.""" + if record.get("model_type") not in {"llm", "chat"}: + raise ValueError("LEGACY_MODEL_TYPE_UNSUPPORTED") + extra = record.get("extra_params") + if extra is not None and not isinstance(extra, dict): + raise ValueError("LEGACY_PARAMETERS_INVALID") + body = legacy_reasoning_wire_parameters( + model_id=model_id, base_url=record.get("base_url"), model_factory=record.get("model_factory"), + capability=capability, effort=legacy_reasoning_effort(extra, capability), + budget=legacy_reasoning_budget(extra, capability), extra_body=legacy_extra_body(extra), + ) + # The old Agent passes these kwargs even when SQL values are NULL; custom body wins. + return {"temperature": record.get("temperature"), "top_p": record.get("top_p"), **body} diff --git a/sdk/nexent/core/model_service/compatibility/legacy_reasoning.py b/sdk/nexent/core/model_service/compatibility/legacy_reasoning.py new file mode 100644 index 0000000000..1e7d1d7a94 --- /dev/null +++ b/sdk/nexent/core/model_service/compatibility/legacy_reasoning.py @@ -0,0 +1,35 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Export historical request fields using the existing legacy translation methods.""" + +from copy import deepcopy + +from nexent.core.models.openai_llm import OpenAIModel + + +class LegacyReasoningPolicy: + """Share only pure translation methods, without constructing a model or client.""" + + _reasoning_wire_profile = OpenAIModel._reasoning_wire_profile + _base_reasoning_wire_profile = OpenAIModel._base_reasoning_wire_profile + _translate_thinking_flag = OpenAIModel._translate_thinking_flag + _apply_reasoning_control = OpenAIModel._apply_reasoning_control + + +def legacy_reasoning_wire_parameters(*, model_id, base_url, model_factory, capability=None, + effort=None, budget=None, extra_body=None): + """Export actual legacy body fields without allocating a client or sending a request.""" + policy = LegacyReasoningPolicy() + policy.model_id = model_id + policy.api_base_url = base_url + policy.model_factory = (model_factory or "").lower() + policy.reasoning_capability = deepcopy(capability) + policy.reasoning_effort = effort + policy.reasoning_budget_tokens = budget + parameters = {} + if extra_body: + parameters["extra_body"] = policy._translate_thinking_flag(deepcopy(extra_body)) + policy._apply_reasoning_control(parameters) + body = parameters.pop("extra_body", {}) + # The OpenAI SDK merges extra_body over ordinary completion parameters. + return deepcopy({**parameters, **body}) diff --git a/sdk/nexent/core/model_service/http_sender.py b/sdk/nexent/core/model_service/http_sender.py new file mode 100644 index 0000000000..978f4ca90b --- /dev/null +++ b/sdk/nexent/core/model_service/http_sender.py @@ -0,0 +1,322 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""One-attempt sync/async HTTP sender with owned resource lifetime.""" + +import asyncio +import socket +import ssl +import threading +from collections.abc import AsyncIterator, Callable, Iterator +from contextlib import asynccontextmanager, contextmanager +from dataclasses import replace + +import httpx + +from nexent.core.concurrency.cancellation import RunCancellationScope, RunTerminated +from nexent.core.model_service.http_contracts import ( + ModelTransportError, + PreparedHttpRequest, + RawHttpResponse, + validate_headers, +) +from nexent.core.model_service.profiles.resolved_profile import CredentialReference +from nexent.core.model_service.sse import SSEByteDecoder, SSEDecodeError, SSEEvent + + +CredentialResolver = Callable[[CredentialReference], dict[str, str]] + + +def _check_cancelled(scope: RunCancellationScope | None) -> None: + if scope is not None and scope.cancelled: + raise RunTerminated() + + +class HttpRequestSender: + """No retries, provider policy, environment reads or tool execution here.""" + + def __init__( + self, + resolve_credentials: CredentialResolver, + *, + max_response_bytes: int = 32 * 1024 * 1024, + max_error_bytes: int = 1024 * 1024, + max_event_bytes: int = 1024 * 1024, + ): + if any( + type(limit) is not int or limit <= 0 for limit in (max_response_bytes, max_error_bytes, max_event_bytes) + ): + raise ValueError("HTTP response limits must be positive integers") + self._resolve_credentials = resolve_credentials + self._response_limit = max_response_bytes + self._error_limit = max_error_bytes + self._event_limit = max_event_bytes + + def _headers(self, request: PreparedHttpRequest) -> dict[str, str]: + try: + credentials = validate_headers(self._resolve_credentials(request.credential_ref), credentials=True) + except Exception: + raise ModelTransportError("CREDENTIAL_RESOLUTION_FAILED") from None + if credentials.keys() & request.headers.keys(): + raise ModelTransportError("CREDENTIAL_HEADER_CONFLICT") + return {"content-type": "application/json", **request.headers, **credentials} + + def freeze_credentials(self, request: PreparedHttpRequest) -> "HttpRequestSender": + """Resolve once for a logical call; retries retain the same credential headers.""" + try: + headers = validate_headers(self._resolve_credentials(request.credential_ref), credentials=True) + except Exception: + raise ModelTransportError("CREDENTIAL_RESOLUTION_FAILED") from None + if headers.keys() & request.headers.keys(): + raise ModelTransportError("CREDENTIAL_HEADER_CONFLICT") + + def resolve(reference): + if reference != request.credential_ref: + raise ValueError("Credential reference does not match the frozen request") + return dict(headers) + + return HttpRequestSender( + resolve, + max_response_bytes=self._response_limit, + max_error_bytes=self._error_limit, + max_event_bytes=self._event_limit, + ) + + @staticmethod + def _failure(error: httpx.HTTPError) -> ModelTransportError: + cause = error + for _ in range(8): + if isinstance(cause, ssl.SSLError): + return ModelTransportError("HTTP_TLS_ERROR") + cause = cause.__cause__ or cause.__context__ + if cause is None: + break + category = "HTTP_TIMEOUT" if isinstance(error, httpx.TimeoutException) else "HTTP_NETWORK_ERROR" + return ModelTransportError(category) + + @staticmethod + def _metadata(response: httpx.Response, body: bytes) -> RawHttpResponse: + try: + headers = response.request.headers + except RuntimeError: + headers = {} + public_headers = {"host", "accept", "accept-encoding", "connection", "content-length", "content-type", "user-agent"} + secrets = [] + for name, value in headers.items(): + if name.lower() not in public_headers: + secrets.append(value) + if name.lower() == "authorization" and " " in value: + secrets.append(value.split(" ", 1)[1]) + return RawHttpResponse(response.status_code, dict(response.headers), body, tuple(secrets)) + + def _read(self, response: httpx.Response, limit: int) -> bytes: + parts, size = [], 0 + for part in response.iter_bytes(): + size += len(part) + if size > limit: + raise ModelTransportError("HTTP_RESPONSE_TOO_LARGE") + parts.append(part) + return b"".join(parts) + + async def _aread(self, response: httpx.Response, limit: int) -> bytes: + parts, size = [], 0 + async for part in response.aiter_bytes(): + size += len(part) + if size > limit: + raise ModelTransportError("HTTP_RESPONSE_TOO_LARGE") + parts.append(part) + return b"".join(parts) + + @contextmanager + def _response(self, request: PreparedHttpRequest, scope: RunCancellationScope | None): + _check_cancelled(scope) + headers = self._headers(request) + client = httpx.Client(verify=request.ssl_verify, timeout=request.timeout_seconds, trust_env=False) + lock = threading.Lock() + closed = False + aborted = False + streams = [] + + def shutdown_stream(stream): + try: + connection = stream.get_extra_info("socket") + if isinstance(connection, socket.socket): + connection.shutdown(socket.SHUT_RDWR) + except (AttributeError, OSError): + pass + + def trace(event, info): + if event not in {"connection.connect_tcp.complete", "connection.start_tls.complete"}: + return + stream = info.get("return_value") + if stream is None: + return + with lock: + already_closed = aborted + if not already_closed: + streams.append(stream) + if already_closed: + shutdown_stream(stream) + stream.close() + _check_cancelled(scope) + + def abort_once(): + nonlocal aborted + with lock: + if aborted: + return + aborted = True + owned_streams = tuple(streams) + for stream in owned_streams: + shutdown_stream(stream) + + def close_once(): + nonlocal closed + abort_once() + with lock: + if closed: + return + closed = True + # Only the reader closes the client after its socket read unwinds. + client.close() + + token = scope.register_closer(abort_once) if scope else None + try: + _check_cancelled(scope) + with client.stream( + request.method, + request.url, + headers=headers, + content=request.body_bytes, + extensions={"trace": trace}, + ) as response: + _check_cancelled(scope) + yield response + except httpx.HTTPError as error: + _check_cancelled(scope) + raise self._failure(error) from None + finally: + if scope and token: + scope.unregister_closer(token) + close_once() + + @asynccontextmanager + async def _aresponse(self, request: PreparedHttpRequest, scope: RunCancellationScope | None): + _check_cancelled(scope) + headers = self._headers(request) + loop, task = asyncio.get_running_loop(), asyncio.current_task() + + active = True + + def cancel_if_active(): + if active: + task.cancel() + + def cancel_task(): + loop.call_soon_threadsafe(cancel_if_active) + + token = scope.register_closer(cancel_task) if scope else None + try: + _check_cancelled(scope) + async with httpx.AsyncClient( + verify=request.ssl_verify, timeout=request.timeout_seconds, trust_env=False + ) as client: + async with client.stream( + request.method, request.url, headers=headers, content=request.body_bytes + ) as response: + _check_cancelled(scope) + yield response + except asyncio.CancelledError: + _check_cancelled(scope) + raise + except httpx.HTTPError as error: + _check_cancelled(scope) + raise self._failure(error) from None + finally: + active = False + if scope and token: + scope.unregister_closer(token) + + def invoke(self, request: PreparedHttpRequest, *, scope: RunCancellationScope | None = None) -> RawHttpResponse: + with self._response(request, scope) as response: + body = self._read(response, self._response_limit if response.is_success else self._error_limit) + _check_cancelled(scope) + raw = self._metadata(response, body) + if not response.is_success: + raise ModelTransportError("HTTP_STATUS_ERROR", response=raw) + return raw + + async def ainvoke( + self, request: PreparedHttpRequest, *, scope: RunCancellationScope | None = None + ) -> RawHttpResponse: + async with self._aresponse(request, scope) as response: + body = await self._aread(response, self._response_limit if response.is_success else self._error_limit) + _check_cancelled(scope) + raw = self._metadata(response, body) + if not response.is_success: + raise ModelTransportError("HTTP_STATUS_ERROR", response=raw) + return raw + + def _stream_decoder(self, response: httpx.Response, request: PreparedHttpRequest) -> SSEByteDecoder: + if ( + request.stream_protocol != "sse" + or response.headers.get("content-type", "").split(";")[0].strip() != "text/event-stream" + ): + raise ModelTransportError("HTTP_STREAM_FORMAT_INVALID") + return SSEByteDecoder(max_event_bytes=self._event_limit) + + @contextmanager + def stream( + self, request: PreparedHttpRequest, *, scope: RunCancellationScope | None = None + ) -> Iterator[Iterator[SSEEvent]]: + with self._response(request, scope) as response: + if not response.is_success: + raise ModelTransportError( + "HTTP_STATUS_ERROR", response=self._metadata(response, self._read(response, self._error_limit)) + ) + decoder = self._stream_decoder(response, request) + secrets = self._metadata(response, b"").sensitive_values + + def events(): + try: + for text in response.iter_bytes(): + _check_cancelled(scope) + for event in decoder.feed(text): + _check_cancelled(scope) + yield replace(event, sensitive_values=secrets) + decoder.finish() + except SSEDecodeError as error: + raise ModelTransportError(error.category) from None + + iterator = events() + try: + yield iterator + finally: + iterator.close() + + @asynccontextmanager + async def astream( + self, request: PreparedHttpRequest, *, scope: RunCancellationScope | None = None + ) -> AsyncIterator[AsyncIterator[SSEEvent]]: + async with self._aresponse(request, scope) as response: + if not response.is_success: + body = await self._aread(response, self._error_limit) + raise ModelTransportError("HTTP_STATUS_ERROR", response=self._metadata(response, body)) + decoder = self._stream_decoder(response, request) + secrets = self._metadata(response, b"").sensitive_values + + async def events(): + try: + async for text in response.aiter_bytes(): + _check_cancelled(scope) + for event in decoder.feed(text): + _check_cancelled(scope) + yield replace(event, sensitive_values=secrets) + decoder.finish() + except SSEDecodeError as error: + raise ModelTransportError(error.category) from None + + iterator = events() + try: + yield iterator + finally: + await iterator.aclose() diff --git a/sdk/nexent/core/model_service/profiles/outlet_capabilities.py b/sdk/nexent/core/model_service/profiles/outlet_capabilities.py new file mode 100644 index 0000000000..4eed9e1190 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/outlet_capabilities.py @@ -0,0 +1,142 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Version-one capability partition for the existing model-record row.""" + +import json +from dataclasses import dataclass +from typing import Mapping + +from pydantic import ValidationError + +from nexent.core.model_service.profiles.contracts import ProfileCapabilities +from nexent.core.model_service.profiles.parameter_schema import _json_copy + + +_CONTEXT_COLUMNS = { + "combined_window": "context_window_tokens", + "input_limit": "max_input_tokens", + "output_limit": "max_output_tokens", +} +_STATUS_COLUMNS = { + "reasoning": "reasoning_supported", + "tools": "native_tool_call_supported", + "structured_output": "structured_output_supported", + "temperature": "temperature_supported", + "top_p": "top_p_supported", +} +CAPABILITY_COLUMN_KEYS = frozenset( + { + *_CONTEXT_COLUMNS.values(), + *_STATUS_COLUMNS.values(), + "reasoning_controls", + "input_modalities", + "output_modalities", + } +) + + +class CapabilityStorageError(ValueError): + """A fixed diagnostic without arbitrary stored values or Pydantic inputs.""" + + def __init__(self): + super().__init__("PROFILE_STORAGE_INVALID") + + +@dataclass +class StoredOutletCapabilities: + """Detached columns and remaining details; neither contains duplicate values.""" + + columns: dict[str, object] + capability_details: dict[str, object] + + +def _clone(value: object): + try: + copied = _json_copy(value) + encoded = json.dumps(copied, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8") + if len(encoded) > 1024 * 1024: + raise CapabilityStorageError() + return copied + except (ValueError, TypeError, RecursionError, UnicodeError): + raise CapabilityStorageError() from None + + +def _validate(value: object) -> dict: + try: + result = ProfileCapabilities.model_validate(value).model_dump(mode="json") + for number in result["context"].values(): + if number is not None and number >= 2**31: + raise CapabilityStorageError() + return result + except (ValidationError, ValueError, TypeError, UnicodeError): + raise CapabilityStorageError() from None + + +def _status(value: object) -> str: + if value is None: + return "unknown" + if type(value) is not bool: + raise CapabilityStorageError() + return "supported" if value else "unsupported" + + +def split_outlet_capabilities(capabilities: ProfileCapabilities) -> StoredOutletCapabilities: + """Partition a validated complete capability object using fixed field ownership.""" + if not isinstance(capabilities, ProfileCapabilities): + raise CapabilityStorageError() + value = _validate(_clone(capabilities.model_dump(mode="json"))) + columns = {column: value["context"][field] for field, column in _CONTEXT_COLUMNS.items()} + for group, column in _STATUS_COLUMNS.items(): + status = value["sampling"][group]["status"] if group in {"temperature", "top_p"} else value[group]["status"] + columns[column] = {"supported": True, "unsupported": False, "unknown": None}[status] + columns["reasoning_controls"] = value["reasoning"]["controls"] + columns["input_modalities"] = value["modalities"]["input"] + columns["output_modalities"] = value["modalities"]["output"] + details = { + "reasoning": {"response_paths": value["reasoning"]["response_paths"]}, + "tools": {key: value["tools"][key] for key in ("schema_constraints", "result_format")}, + "response": value["response"], + "usage": value["usage"], + } + return StoredOutletCapabilities(columns, details) + + +def restore_outlet_capabilities( + *, + columns: Mapping[str, object], + capability_details: Mapping[str, object], +) -> ProfileCapabilities: + """Restore strictly from row-owned fields; no catalog or default composition.""" + columns, details = _clone(columns), _clone(capability_details) + if not isinstance(columns, dict) or set(columns) != CAPABILITY_COLUMN_KEYS: + raise CapabilityStorageError() + if not isinstance(details, dict) or set(details) != {"reasoning", "tools", "response", "usage"}: + raise CapabilityStorageError() + expected = { + "reasoning": {"response_paths"}, + "tools": {"schema_constraints", "result_format"}, + "response": {"supported_finish_reasons", "stream_format"}, + "usage": {"semantics_id", "input_includes_cached", "output_includes_reasoning"}, + } + for group, keys in expected.items(): + if not isinstance(details[group], dict) or set(details[group]) != keys: + raise CapabilityStorageError() + value = { + "context": {key: columns[column] for key, column in _CONTEXT_COLUMNS.items()}, + "reasoning": { + "status": _status(columns["reasoning_supported"]), + "controls": columns["reasoning_controls"], + **details["reasoning"], + }, + "tools": {"status": _status(columns["native_tool_call_supported"]), **details["tools"]}, + "structured_output": {"status": _status(columns["structured_output_supported"])}, + "sampling": { + key: {"status": _status(columns[column])} + for key, column in _STATUS_COLUMNS.items() + if key in {"temperature", "top_p"} + }, + "modalities": {"input": columns["input_modalities"], "output": columns["output_modalities"]}, + "response": details["response"], + "usage": details["usage"], + } + return ProfileCapabilities.model_validate(_validate(_clone(value))) diff --git a/sdk/nexent/core/model_service/profiles/outlet_parameters.py b/sdk/nexent/core/model_service/profiles/outlet_parameters.py new file mode 100644 index 0000000000..2c25540fc2 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/outlet_parameters.py @@ -0,0 +1,102 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Single-location parameter storage, independent of database sessions and TOML.""" + +import math +from dataclasses import dataclass +from typing import Mapping + +from nexent.core.model_service.profiles.parameter_rules import ( + CompiledParameterRules, + validate_saved_parameter_values, +) +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError + + +DEDICATED_PARAMETER_KEYS = frozenset( + {"temperature", "top_p", "enable_thinking", "reasoning_effort", "reasoning_budget_tokens", "max_output_tokens"} +) +PARAMETER_COLUMNS = {key: "default_max_output_tokens" if key == "max_output_tokens" else key + for key in DEDICATED_PARAMETER_KEYS} + + +@dataclass +class StoredOutletParameters: + """Detached fields to embed in a row, with no duplicate parameter values.""" + + columns: dict[str, object] + parameter_values: dict[str, object] + explicit_null_parameters: list[str] + + +def _validate_column(key: str, value: object) -> None: + if value is None: + return + valid = False + if key in {"temperature", "top_p"}: + try: + valid = type(value) in {int, float} and math.isfinite(value) + if valid and type(value) is int: + valid = float(value) == value + except OverflowError: + valid = False + elif key == "enable_thinking": + valid = type(value) is bool + elif key == "reasoning_effort": + valid = isinstance(value, str) and bool(value.strip()) and len(value) <= 30 + elif key == "reasoning_budget_tokens": + valid = type(value) is int and -(2**31) <= value < 2**31 + elif key == "max_output_tokens": + valid = type(value) is int and 0 < value < 2**31 + if not valid: + raise ParameterValidationError("PARAMETER_COLUMN_INVALID", "/" + key) + + +def split_outlet_parameters( + compiled: CompiledParameterRules, + values: Mapping[str, object], +) -> StoredOutletParameters: + """Split already resolved values; applying creation defaults is the caller's job.""" + validated = validate_saved_parameter_values(compiled, values) + columns = {PARAMETER_COLUMNS[key]: None for key in sorted(DEDICATED_PARAMETER_KEYS)} + remaining, nulls = {}, [] + for key, value in validated.items(): + if key in DEDICATED_PARAMETER_KEYS: + _validate_column(key, value) + columns[PARAMETER_COLUMNS[key]] = value + if value is None: + nulls.append(key) + else: + remaining[key] = value + return StoredOutletParameters(columns, remaining, sorted(nulls)) + + +def restore_outlet_parameters( + compiled: CompiledParameterRules, + *, + columns: Mapping[str, object], + parameter_values: Mapping[str, object], + explicit_null_parameters: list[str], +) -> dict: + """Restore exactly the saved key presence; reject corruption instead of repairing it.""" + if not isinstance(columns, Mapping) or set(columns) != set(PARAMETER_COLUMNS.values()): + raise ParameterValidationError("PARAMETER_COLUMNS_INVALID") + if ( + not isinstance(explicit_null_parameters, list) + or any(type(key) is not str for key in explicit_null_parameters) + or len(explicit_null_parameters) != len(set(explicit_null_parameters)) + or not set(explicit_null_parameters) <= DEDICATED_PARAMETER_KEYS + ): + raise ParameterValidationError("PARAMETER_NULL_MARKERS_INVALID") + if not isinstance(parameter_values, Mapping) or DEDICATED_PARAMETER_KEYS & parameter_values.keys(): + raise ParameterValidationError("PARAMETER_STORAGE_DUPLICATE") + values = dict(parameter_values) + nulls = set(explicit_null_parameters) + for key, column in PARAMETER_COLUMNS.items(): + value = columns[column] + _validate_column(key, value) + if key in nulls and value is not None: + raise ParameterValidationError("PARAMETER_NULL_MARKERS_INVALID", "/" + key) + if value is not None or key in nulls: + values[key] = value + return validate_saved_parameter_values(compiled, values) diff --git a/sdk/nexent/core/model_service/profiles/outlet_resolution.py b/sdk/nexent/core/model_service/profiles/outlet_resolution.py new file mode 100644 index 0000000000..39b8897f00 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/outlet_resolution.py @@ -0,0 +1,143 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Construct complete outlet snapshots from trusted save-time composition.""" + +from collections.abc import Mapping, Sequence + +from pydantic import ValidationError + +from nexent.core.model_service.profiles.composition import COMPOSER_REF, ProfileCatalog, ProviderModelPolicy +from nexent.core.model_service.profiles.contracts import InstructionMappingDefinition, VersionedReference +from nexent.core.model_service.profiles.instruction_mapping import ( + InstructionMappingError, + validate_instruction_mapping, +) +from nexent.core.model_service.profiles.outlet_storage import ProfileStorageError, encode_resolved_model_profile +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError, _json_copy +from nexent.core.model_service.profiles.resolved_profile import ( + ConnectionRules, + ContextConfiguration, + OutletConnection, + OutletIdentity, + ResolvedModelProfile, +) +from nexent.core.model_service.profiles.resolver import PARSER_REF +from nexent.core.model_service.profiles.supplements import SUPPLEMENT_SCHEMA_REF + + +class OutletResolutionError(ValueError): + """A construction diagnostic contains categories, never submitted values.""" + + def __init__(self, category): + self.category = category + super().__init__(category) + + +def _selected_atoms(policy, catalog): + if policy.catalog_revision != catalog.revision: + raise OutletResolutionError("PROFILE_CATALOG_REVISION_MISMATCH") + model = catalog.models.get(policy.model_profile_key) if policy.model_profile_key else None + provider = catalog.providers.get(policy.provider_key) if policy.provider_key else None + if (policy.model_profile_key and model is None) or (policy.provider_key and provider is None): + raise OutletResolutionError("PROFILE_SELECTION_INVALID") + combination = catalog.provider_models.get(f"{policy.provider_key}/{policy.request_model_id}") if provider else None + if combination and combination.model_profile_key and combination.model_profile_key != policy.model_profile_key: + raise OutletResolutionError("PROFILE_SELECTION_INVALID") + return {"model": model, "provider": provider, "provider_model": combination} + + +def _provenance(policy, atoms): + references, origins, metadata = {}, [], {} + for kind, atom in atoms.items(): + references[kind] = None + if atom is None: + continue + candidates = [reference for reference in atom.source.source_refs if reference.key == atom.key] + if len(candidates) != 1: + raise OutletResolutionError("PROFILE_SOURCE_REFERENCE_INVALID") + references[kind] = candidates[0].model_dump(mode="json") + for origin in atom.source.field_origins.values(): + mapped = origin.mapping + origins.append({ + "target_path": origin.target_path, + "source_path": mapped.source.file_path + "#" + mapped.source.source_path, + "atom_kind": mapped.source.source_kind, + "rule_id": mapped.rule_id, + "rule_version": mapped.rule_version, + }) + if atom.source.reference_metadata: + metadata[kind] = _json_copy(atom.source.reference_metadata) + return { + "atomic_refs": references, + "interpretation_versions": { + "source_parser": {"ref": PARSER_REF, "version": 1}, + "source_mapping": {"ref": "models_dev_source_v1", "version": 1}, + "supplement_schema": {"ref": SUPPLEMENT_SCHEMA_REF, "version": 1}, + "composer": {"ref": COMPOSER_REF, "version": 4}, + }, + "overrides": _json_copy(policy.overrides), + "field_sources": origins, + "provider_metadata": metadata, + } + + +def construct_resolved_model_profile( + policy: ProviderModelPolicy, + catalog: ProfileCatalog, + *, + tenant_id: str, + display_name: str, + model_type: str, + provider_id: str, + connection: OutletConnection, + connection_rules: ConnectionRules, + context_configuration: ContextConfiguration, + registered_references: Mapping[str, Sequence[VersionedReference]], + extra_api_parameters: Sequence[Mapping] = (), + protected_paths: Sequence[str] = (), +) -> ResolvedModelProfile: + """Build a new outlet from a server-composed draft; never accept a frontend-authored policy. + + No defaults are applied here. The caller provides registry/connection policy + and owns the subsequent database transaction and credential assignment. + """ + if not isinstance(policy, ProviderModelPolicy) or not isinstance(catalog, ProfileCatalog): + raise OutletResolutionError("PROFILE_SELECTION_INVALID") + atoms = _selected_atoms(policy, catalog) + try: + snapshot = ResolvedModelProfile( + schema_version=1, + outlet_id=None, + config_revision=1, + identity=OutletIdentity( + tenant_id=tenant_id, + display_name=display_name, + model_type=model_type, + provider_id=provider_id, + request_model_id=policy.request_model_id, + provider_key=policy.provider_key, + model_profile_key=policy.model_profile_key, + ), + connection=connection.model_dump(mode="json"), + connection_rules=connection_rules.model_dump(mode="json"), + context_configuration=context_configuration.model_dump(mode="json"), + capabilities=_json_copy(policy.capabilities), + instruction_mapping=_json_copy(policy.instruction_mapping), + parameter_rules=_json_copy(policy.parameter_rules.declarations), + parameter_values=_json_copy(policy.parameter_values), + extra_api_parameters=_json_copy(extra_api_parameters), + bindings={ + **_json_copy(policy.bindings), + "role_mapping": validate_instruction_mapping( + InstructionMappingDefinition.model_validate(_json_copy(policy.instruction_mapping)) + ).model_dump(mode="json"), + }, + provenance=_provenance(policy, atoms), + ) + # Reuse the save boundary for availability, column and ownership checks. + encode_resolved_model_profile( + snapshot, registered_references=registered_references, protected_paths=protected_paths + ) + return snapshot + except (ValidationError, ProfileStorageError, InstructionMappingError, ParameterValidationError, TypeError, AttributeError): + raise OutletResolutionError("PROFILE_RESOLUTION_INVALID") from None diff --git a/sdk/nexent/core/model_service/profiles/outlet_rules.py b/sdk/nexent/core/model_service/profiles/outlet_rules.py new file mode 100644 index 0000000000..0d50d1fb8e --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/outlet_rules.py @@ -0,0 +1,132 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Store rule constraints as explicit references to same-row control domains.""" + +import json +from typing import Mapping + +from pydantic import ValidationError + +from nexent.core.model_service.profiles.contracts import ReasoningCapabilities +from nexent.core.model_service.profiles.parameter_rules import ( + CompiledParameterRules, + compile_parameter_rules, + validate_saved_parameter_values, +) +from nexent.core.model_service.profiles.parameter_schema import _json_copy, json_values_equal + + +class RuleStorageError(ValueError): + """A fixed storage diagnostic that never includes private constraint values.""" + + def __init__(self): + super().__init__("PROFILE_STORAGE_INVALID") + + +def _clone(value): + try: + copied = _json_copy(value) + encoded = json.dumps(copied, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8") + if len(encoded) > 1024 * 1024: + raise RuleStorageError() + return copied + except (ValueError, TypeError, RecursionError, UnicodeError): + raise RuleStorageError() from None + + +def _domains(controls): + try: + reasoning = ReasoningCapabilities.model_validate({"controls": _clone(controls)}) + except (ValidationError, ValueError, TypeError): + raise RuleStorageError() from None + domains = {} + for control in reasoning.controls or []: + value = control.model_dump(mode="json") + if control.mode in {"toggle", "effort"}: + keywords = {"enum": value["values"]} + else: + keywords = { + keyword: value[field] + for keyword, field in (("minimum", "min"), ("maximum", "max")) + if value[field] is not None + } + domains[control.parameter_key] = (control.mode, keywords) + return domains + + +def store_parameter_rules( + compiled: CompiledParameterRules, + *, + reasoning_controls: object, + saved_values: Mapping[str, object], +) -> list[dict]: + """Remove only exact duplicate domain/fixed values, preserving extra constraints.""" + try: + saved = validate_saved_parameter_values(compiled, saved_values) + rules = _clone(compiled.declarations) + domains = _domains(reasoning_controls) + for rule in rules: + key = rule["key"] + if key in domains: + mode, domain = domains[key] + keywords = sorted( + keyword + for keyword, value in domain.items() + if keyword in rule["schema"] and json_values_equal(rule["schema"][keyword], value) + ) + if keywords: + rule["value_domain_ref"] = {"mode": mode, "keywords": keywords} + for keyword in keywords: + del rule["schema"][keyword] + if "fixed_value" in rule and key in saved: + rule.pop("fixed_value") + rule["fixed_to_saved_value"] = True + return _clone(rules) + except (ValueError, TypeError, KeyError, AttributeError): + raise RuleStorageError() from None + + +def restore_parameter_rules( + declarations: object, + *, + reasoning_controls: object, + saved_values: Mapping[str, object], +) -> CompiledParameterRules: + """Restore constraints from row-owned domains; never apply creation defaults.""" + try: + rules, saved = _clone(declarations), _clone(saved_values) + if not isinstance(rules, list) or not isinstance(saved, dict): + raise RuleStorageError() + domains = _domains(reasoning_controls) + for rule in rules: + if not isinstance(rule, dict) or not isinstance(rule.get("schema"), dict): + raise RuleStorageError() + key = rule.get("key") + has_reference = "value_domain_ref" in rule + reference = rule.pop("value_domain_ref", None) + if has_reference: + if not isinstance(reference, dict) or set(reference) != {"mode", "keywords"} or key not in domains: + raise RuleStorageError() + mode, domain = domains[key] + keywords = reference["keywords"] + if ( + reference["mode"] != mode + or not isinstance(keywords, list) + or not keywords + or any(type(word) is not str for word in keywords) + or len(set(keywords)) != len(keywords) + or not set(keywords) <= domain.keys() + or set(keywords) & rule["schema"].keys() + ): + raise RuleStorageError() + for keyword in keywords: + rule["schema"][keyword] = domain[keyword] + if "fixed_to_saved_value" in rule: + if rule.pop("fixed_to_saved_value") is not True or "fixed_value" in rule or key not in saved: + raise RuleStorageError() + rule["fixed_value"] = saved[key] + compiled = compile_parameter_rules(_clone(rules)) + validate_saved_parameter_values(compiled, saved) + return compiled + except (ValueError, TypeError, KeyError): + raise RuleStorageError() from None diff --git a/sdk/nexent/core/model_service/profiles/outlet_storage.py b/sdk/nexent/core/model_service/profiles/outlet_storage.py new file mode 100644 index 0000000000..f18cd68c79 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/outlet_storage.py @@ -0,0 +1,217 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Fixed single-row storage codec; never loads source catalogs or credentials.""" + +from typing import Mapping, Sequence + +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.outlet_capabilities import ( + CAPABILITY_COLUMN_KEYS, + restore_outlet_capabilities, + split_outlet_capabilities, +) +from nexent.core.model_service.profiles.outlet_parameters import ( + DEDICATED_PARAMETER_KEYS, + PARAMETER_COLUMNS, + split_outlet_parameters, +) +from nexent.core.model_service.profiles.outlet_rules import restore_parameter_rules, store_parameter_rules +from nexent.core.model_service.profiles.parameter_bindings import validate_parameter_bindings +from nexent.core.model_service.profiles.parameter_rules import compile_parameter_rules +from nexent.core.model_service.profiles.parameter_schema import _json_copy +from nexent.core.model_service.profiles.request_parameters import apply_extra_api_parameters +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile + + +_IDENTITY = { + "tenant_id": "tenant_id", + "display_name": "display_name", + "model_type": "model_type", + "provider_id": "model_factory", + "request_model_id": "model_name", +} +_CONNECTION = ("base_url", "ssl_verify", "timeout_seconds", "concurrency_limit") +_CONTEXT = { + "output_reserve_tokens": "default_output_reserve_tokens", + "tokenizer_family": "tokenizer_family", + "capacity_source": "capacity_source", + "capability_profile_version": "capability_profile_version", +} +_REMAINDER = { + "parameter_rules", + "instruction_mapping", + "capability_details", + "bindings", + "provenance", + "connection_rules", + "explicit_null_parameters", +} + + +class ProfileStorageError(ValueError): + def __init__(self): + super().__init__("PROFILE_STORAGE_INVALID") + + +def _validate_available(profile, registered_references, protected_paths): + for key, reference in profile.bindings: + if key == "role_mapping": + continue # The executable local instruction registry already validates this declaration. + if reference not in registered_references.get(key, ()): + raise ProfileStorageError() + compiled = compile_parameter_rules(profile.parameter_rules) + bindings = validate_parameter_bindings( + compiled, registered_references.get("parameter_transform", ()), protected_paths=protected_paths + ) + apply_extra_api_parameters( + {}, + profile.extra_api_parameters, + parameter_paths=[binding["path"] for binding in bindings.values()], + protected_paths=protected_paths, + ) + return compiled + + +def encode_resolved_model_profile( + profile: ResolvedModelProfile, + *, + registered_references: Mapping[str, Sequence[VersionedReference]], + protected_paths: Sequence[str] = (), +) -> dict: + """Return row-owned fields; credential values and audit fields belong to the caller.""" + try: + profile = ResolvedModelProfile.model_validate(_json_copy(profile.model_dump(mode="json"))) + compiled = _validate_available(profile, registered_references, protected_paths) + capabilities = split_outlet_capabilities(profile.capabilities) + parameters = split_outlet_parameters(compiled, profile.parameter_values) + values = profile.model_dump(mode="json") + bindings = values["bindings"] + protocol = bindings.pop("protocol") + bindings["protocol_version"] = protocol["version"] + remainder = { + "parameter_rules": store_parameter_rules( + compiled, + reasoning_controls=capabilities.columns["reasoning_controls"], + saved_values=profile.parameter_values, + ), + "instruction_mapping": values["instruction_mapping"], + "capability_details": capabilities.capability_details, + "bindings": bindings, + "provenance": values["provenance"], + "connection_rules": values["connection_rules"], + "explicit_null_parameters": parameters.explicit_null_parameters, + } + row = {column: values["identity"][field] for field, column in _IDENTITY.items()} + row.update({key: values["connection"][key] for key in _CONNECTION}) + row.update({column: values["context_configuration"][field] for field, column in _CONTEXT.items()}) + row.update(capabilities.columns) + row.update(parameters.columns) + row.update( + profile_schema_version=profile.schema_version, + config_revision=profile.config_revision, + protocol_id=protocol["ref"], + resolved_profile=remainder, + extra_params={ + "parameter_values": parameters.parameter_values, + "api_parameters": values["extra_api_parameters"], + }, + ) + if profile.outlet_id is not None: + row["model_id"] = profile.outlet_id + return _json_copy(row) + except (ValueError, TypeError, KeyError, AttributeError, UnicodeError): + raise ProfileStorageError() from None + + +def decode_resolved_model_profile( + row: Mapping[str, object], + *, + registered_references: Mapping[str, Sequence[VersionedReference]], + protected_paths: Sequence[str] = (), +) -> ResolvedModelProfile: + """Restore this row only; reject incomplete migration states and unavailable versions.""" + try: + if type(row["model_id"]) is not int or not 0 < row["model_id"] < 2**31: + raise ProfileStorageError() + remainder, extra = _json_copy(row["resolved_profile"]), _json_copy(row["extra_params"]) + if not isinstance(remainder, dict) or set(remainder) != _REMAINDER: + raise ProfileStorageError() + if not isinstance(extra, dict) or set(extra) != {"parameter_values", "api_parameters"}: + raise ProfileStorageError() + capability_columns = {key: row[key] for key in CAPABILITY_COLUMN_KEYS} + capabilities = restore_outlet_capabilities( + columns=capability_columns, capability_details=remainder["capability_details"] + ) + # Recover presence first; the restored declaration will validate schemas and fixed constraints. + saved = _json_copy(extra["parameter_values"]) + nulls = remainder["explicit_null_parameters"] + if ( + not isinstance(saved, dict) + or DEDICATED_PARAMETER_KEYS & saved.keys() + or not isinstance(nulls, list) + or any(type(key) is not str for key in nulls) + or len(nulls) != len(set(nulls)) + or not set(nulls) <= DEDICATED_PARAMETER_KEYS + ): + raise ProfileStorageError() + for key in DEDICATED_PARAMETER_KEYS: + value = row[PARAMETER_COLUMNS[key]] + if key in nulls and value is not None: + raise ProfileStorageError() + if value is not None or key in nulls: + saved[key] = value + for declaration in remainder["parameter_rules"]: + key = declaration.get("key") + if key not in {"temperature", "top_p"}: + continue + declared_type = declaration.get("schema", {}).get("type") + integer_only = declared_type == "integer" or ( + isinstance(declared_type, list) + and "integer" in declared_type + and set(declared_type) <= {"integer", "null"} + ) + value = saved.get(key) + if integer_only and type(value) is float and value.is_integer(): + saved[key] = int(value) + compiled = restore_parameter_rules( + remainder["parameter_rules"], + reasoning_controls=capability_columns["reasoning_controls"], + saved_values=saved, + ) + # The common parameter codec enforces SQL column types, in addition to the restored schemas. + split_outlet_parameters(compiled, saved) + bindings = remainder["bindings"] + if not isinstance(bindings, dict) or "protocol" in bindings or "protocol_version" not in bindings: + raise ProfileStorageError() + bindings["protocol"] = {"ref": row["protocol_id"], "version": bindings.pop("protocol_version")} + connection = {key: row[key] for key in _CONNECTION} + connection["credential_ref"] = { + "kind": remainder["connection_rules"]["credential_kind"], + "model_id": row["model_id"], + } + identity = {key: row[column] for key, column in _IDENTITY.items()} + for field, atom in (("provider_key", "provider"), ("model_profile_key", "model")): + reference = remainder["provenance"]["atomic_refs"][atom] + identity[field] = reference["key"] if reference is not None else None + profile = ResolvedModelProfile.model_validate( + { + "schema_version": row["profile_schema_version"], + "outlet_id": row["model_id"], + "config_revision": row["config_revision"], + "identity": identity, + "connection": connection, + "connection_rules": remainder["connection_rules"], + "context_configuration": {key: row[column] for key, column in _CONTEXT.items()}, + "capabilities": capabilities.model_dump(mode="json"), + "instruction_mapping": remainder["instruction_mapping"], + "parameter_rules": compiled.declarations, + "parameter_values": saved, + "extra_api_parameters": extra["api_parameters"], + "bindings": bindings, + "provenance": remainder["provenance"], + } + ) + _validate_available(profile, registered_references, protected_paths) + return profile + except (ValueError, TypeError, KeyError, AttributeError, UnicodeError): + raise ProfileStorageError() from None diff --git a/sdk/nexent/core/model_service/profiles/protocol_parameters.py b/sdk/nexent/core/model_service/profiles/protocol_parameters.py new file mode 100644 index 0000000000..a5e6c2d903 --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/protocol_parameters.py @@ -0,0 +1,72 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Fallback request declarations for the registered OpenAI chat protocol.""" + + +# Reasoning budgets are intentionally not length limits owned by the outlet. +OUTLET_TOKEN_LIMIT_KEYS = frozenset({ + "max_output_tokens", "max_tokens", "max_completion_tokens", "max_input_tokens", + "context_window_tokens", "context_window", "context_length", "max_context_tokens", + "combined_window", "input_limit", "output_limit", "output_reserve_tokens", "default_output_reserve_tokens", +}) + + +def without_outlet_token_limits(values): + """Ignore retired Agent/conversation length overrides at historical reads.""" + return {key: value for key, value in values.items() if key not in OUTLET_TOKEN_LIMIT_KEYS} + + +def openai_chat_parameter_rules(): + """Declare wire fields without assigning provider capability or request defaults.""" + fields = { + "temperature": ({"type": ["number", "null"]}, "/temperature", True), + "top_p": ({"type": ["number", "null"], "minimum": 0, "maximum": 1}, "/top_p", True), + "max_output_tokens": ({"type": "integer", "minimum": 1}, "/max_tokens", True), + "stop": ({"type": ["string", "array"], "items": {"type": "string"}}, "/stop", False), + "response_format": ({"type": "object"}, "/response_format", False), + } + return [{"key": key, "schema": schema, "visible": visible, "editable": True, "required": False, + "allowed_scopes": ["outlet", "conversation"] if visible and key not in OUTLET_TOKEN_LIMIT_KEYS else ["outlet"], + "ui_group": "advanced" if visible else "runtime", "owner": "provider", + "source": {"ref": "openai_chat_protocol_parameters_v2", "version": 2}, + "api_binding": {"path": path, "transform_ref": "identity_v1", "transform_version": 1}} + for key, (schema, path, visible) in fields.items()] + + +def ensure_outlet_sampling_rules(profile): + """Add only registered sampling declarations for older outlet or session edits.""" + if profile.bindings.protocol.ref != "openai_chat_completions": + return profile + from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile + + data = profile.model_dump(mode="json") + # Upgrade only the known v1 protocol UI restriction. Model/provider rules, + # fixed values, schema bounds and wire bindings remain authoritative. + for rule in data["parameter_rules"]: + if rule["key"] in OUTLET_TOKEN_LIMIT_KEYS: + rule["allowed_scopes"] = ["outlet"] + if (rule["key"] in {"temperature", "top_p"} + and rule.get("source") == {"ref": "openai_chat_protocol_parameters_v1", "version": 1} + and rule.get("editable") is True and "fixed_value" not in rule + and rule.get("api_binding") == {"path": "/" + rule["key"], + "transform_ref": "identity_v1", "transform_version": 1}): + rule["visible"] = True + rule["allowed_scopes"] = list(dict.fromkeys([*rule["allowed_scopes"], "conversation"])) + rule["source"] = {"ref": "openai_chat_protocol_parameters_v2", "version": 2} + keys = {rule["key"] for rule in data["parameter_rules"]} + data["parameter_rules"].extend(rule for rule in openai_chat_parameter_rules() + if rule["key"] in {"temperature", "top_p"} and rule["key"] not in keys) + return ResolvedModelProfile.model_validate(data) + + +def connectivity_request(profile): + """Compare effective requests with the production adapter, without sending HTTP.""" + from nexent.core.model_service.contracts import ModelMessage, ModelRequest + from nexent.core.model_service.registry import AdapterRegistry + + call = ModelRequest(request_id="connectivity-comparison", outlet_id=profile.outlet_id, + config_revision=profile.config_revision, stream=True, purpose="connection_validation", + messages=[ModelMessage(semantic_role="user", content_parts=[{"type": "text", "text": "Hello"}])]) + prepared = AdapterRegistry().resolve(profile.bindings.adapter).prepare_http_request(call, profile) + return (prepared.method, prepared.url, prepared.headers, prepared.json_body, + prepared.timeout_seconds, prepared.ssl_verify, profile.bindings.auth.model_dump(mode="json")) diff --git a/sdk/nexent/core/model_service/profiles/thinking_configuration.py b/sdk/nexent/core/model_service/profiles/thinking_configuration.py new file mode 100644 index 0000000000..43a77d2f2d --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/thinking_configuration.py @@ -0,0 +1,161 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Finite operator-selected thinking switch bindings; never infer them from model names.""" + +from copy import deepcopy + +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile + + +THINKING_FORMATS = { + "enable_thinking": ("/enable_thinking", "identity_v1"), + "thinking_type": ("/thinking/type", "thinking_toggle_v1"), + "chat_template": ("/chat_template_kwargs/enable_thinking", "identity_v1"), +} + + +def configure_thinking(profile, preset): + """Apply a declared operator format while retaining the frozen original for reset.""" + if preset is None: + return profile + if preset != "profile_default" and preset not in THINKING_FORMATS: + raise ParameterValidationError("THINKING_FORMAT_UNKNOWN", "/thinking_control") + data = profile.model_dump(mode="json") + metadata = data["provenance"]["provider_metadata"] + reasoning = data["capabilities"]["reasoning"] + saved = metadata.get("operator_thinking") + if saved is None and preset == "profile_default": + return profile + if saved is None: + saved = {"original_rule_index": next((index for index, rule in enumerate(data["parameter_rules"]) if rule["key"] == "enable_thinking"), len(data["parameter_rules"])), + "original_rule": next((deepcopy(rule) for rule in data["parameter_rules"] + if rule["key"] == "enable_thinking"), None), + "original_status": reasoning["status"], "original_controls": deepcopy(reasoning.get("controls"))} + data["provenance"]["overrides"] = [item for item in data["provenance"]["overrides"] + if item["source"] != "operator_thinking_v1"] + data["parameter_rules"] = [rule for rule in data["parameter_rules"] if rule["key"] != "enable_thinking"] + if preset == "profile_default": + if saved["original_rule"] is not None: + data["parameter_rules"].insert(saved.get("original_rule_index", len(data["parameter_rules"])), saved["original_rule"]) + else: + data["parameter_values"].pop("enable_thinking", None) + reasoning.update(status=saved["original_status"], controls=saved["original_controls"]) + metadata.pop("operator_thinking", None) + else: + path, transform = THINKING_FORMATS[preset] + rule = deepcopy(saved["original_rule"]) if saved["original_rule"] is not None else { + "key": "enable_thinking", "schema": {"type": "boolean"}, "visible": True, + "editable": True, "required": False, "allowed_scopes": ["outlet", "conversation"], + "ui_group": "reasoning", "owner": "combination", + } + rule["source"] = {"ref": "operator_thinking_v1", "version": 1, "preset": preset} + rule["api_binding"] = {"path": path, "transform_ref": transform, "transform_version": 1} + data["parameter_rules"].insert(saved.get("original_rule_index", len(data["parameter_rules"])), rule) + controls = [control for control in reasoning.get("controls") or [] if control["mode"] != "toggle"] + toggle_values = next((control["values"] for control in saved["original_controls"] or [] if control["mode"] == "toggle"), [False, True]) + reasoning.update(status="supported", controls=[{"mode": "toggle", "parameter_key": "enable_thinking", "values": toggle_values}, *controls]) + metadata["operator_thinking"] = {**saved, "preset": preset, "version": 1} + data["provenance"]["overrides"].append({"path": "/reasoning/controls", "source": "operator_thinking_v1", + "original_value": saved["original_controls"], "reason": "User explicitly selected a registered thinking field format", + "evidence": "operator_thinking_v1:" + preset}) + return ResolvedModelProfile.model_validate(data) + + +def configure_thinking_degree(profile, mode): + """Register an operator-selected degree preset, preserving declared wire mappings.""" + if mode is None: + return profile + if mode not in {"default", "effort", "budget_tokens"}: + raise ParameterValidationError("THINKING_MODE_UNKNOWN", "/thinking_mode") + data = profile.model_dump(mode="json") + metadata = data["provenance"]["provider_metadata"] + reasoning = data["capabilities"]["reasoning"] + keys = {"reasoning_effort", "reasoning_budget_tokens"} + saved = metadata.get("operator_thinking_degree") + if saved is None: + saved = { + "rules": [{"index": i, "rule": deepcopy(rule)} + for i, rule in enumerate(data["parameter_rules"]) if rule["key"] in keys], + "controls": [deepcopy(control) for control in reasoning.get("controls") or [] + if control["mode"] in {"effort", "budget_tokens"}], + "status": reasoning["status"], + } + original_keys = {item["rule"]["key"] for item in saved["rules"]} + for key in keys - original_keys: + data["parameter_values"].pop(key, None) + data["parameter_rules"] = [rule for rule in data["parameter_rules"] if rule["key"] not in keys] + for original in saved["rules"]: + data["parameter_rules"].insert(original["index"], deepcopy(original["rule"])) + controls = [control for control in reasoning.get("controls") or [] + if control["mode"] not in {"effort", "budget_tokens"}] + controls.extend(deepcopy(saved["controls"])) + reasoning["controls"] = controls or None + data["provenance"]["overrides"] = [item for item in data["provenance"]["overrides"] + if item["source"] != "operator_thinking_degree_v1"] + if mode == "default": + original_keys = {item["rule"]["key"] for item in saved["rules"]} + for key in keys - original_keys: + data["parameter_values"].pop(key, None) + if "operator_thinking" not in metadata: + reasoning["status"] = saved["status"] + metadata["operator_thinking_degree"] = {**saved, "mode": "default", "version": 1} + return ResolvedModelProfile.model_validate(data) + + key = "reasoning_effort" if mode == "effort" else "reasoning_budget_tokens" + opposite = "reasoning_budget_tokens" if mode == "effort" else "reasoning_effort" + if opposite in data["parameter_values"]: + original = next((item["rule"] for item in saved["rules"] if item["rule"]["key"] == opposite), None) + if original and (not original["editable"] or original["required"]): + raise ParameterValidationError("FIXED_PARAMETER", "/" + opposite) + data["parameter_values"].pop(opposite) + existing = next((rule for rule in data["parameter_rules"] if rule["key"] == key), None) + # A pinned provider binding and its fixed domain always win over generic presets. + if existing is None or not existing.get("api_binding"): + control = next((control for control in controls if control["mode"] == mode), None) + if mode == "effort": + values = control["values"] if control else ["low", "medium", "high"] + schema = {"type": ["string", "null"] if None in values else "string", "enum": values} + control = {"mode": mode, "parameter_key": key, "values": values} + path = "/reasoning_effort" + else: + schema = {"type": "integer"} + if control: + if control.get("min") is not None: + schema["minimum"] = control["min"] + if control.get("max") is not None: + schema["maximum"] = control["max"] + else: + schema["minimum"] = 1 + control = control or {"mode": mode, "parameter_key": key, "min": 1} + path = "/thinking_budget" + rule = {"key": key, "schema": schema, "visible": True, "editable": True, + "required": False, "allowed_scopes": ["outlet", "conversation"], + "ui_group": "reasoning", "owner": "combination", + "source": {"ref": "operator_thinking_degree_v1", "version": 1, "preset": mode}, + "condition": {"key": "enable_thinking", "op": "eq", "value": True}, + "api_binding": {"path": path, "transform_ref": "identity_v1", "transform_version": 1}} + data["parameter_rules"] = [item for item in data["parameter_rules"] if item["key"] != key] + [rule] + reasoning["controls"] = [item for item in controls if item["mode"] != mode] + [control] + reasoning["status"] = "supported" + metadata["operator_thinking_degree"] = {**saved, "mode": mode, "version": 1} + data["provenance"]["overrides"].append({ + "path": "/reasoning/controls", "source": "operator_thinking_degree_v1", + "original_value": saved["controls"], "reason": "User selected a registered thinking degree preset", + "evidence": "operator_thinking_degree_v1:" + mode}) + return ResolvedModelProfile.model_validate(data) + + +def configure_outlet_policy(profile, *, thinking_control=None, thinking_mode=None, has_credential=None): + """Apply the same finite allowed policy changes in services and persistence guards.""" + from nexent.core.model_service.profiles.protocol_parameters import ensure_outlet_sampling_rules + + result = configure_thinking_degree(configure_thinking(ensure_outlet_sampling_rules(profile), thinking_control), thinking_mode) + if has_credential is not None: + data = result.model_dump(mode="json") + data["bindings"]["auth"] = {"ref": "bearer_token_v1" if has_credential else "no_auth_v1", "version": 1} + kind = "model_record" if has_credential else "none" + data["connection"]["credential_ref"]["kind"] = kind + data["connection_rules"]["credential_kind"] = kind + result = ResolvedModelProfile.model_validate(data) + return result diff --git a/sdk/nexent/core/model_service/registry.py b/sdk/nexent/core/model_service/registry.py new file mode 100644 index 0000000000..5510692c83 --- /dev/null +++ b/sdk/nexent/core/model_service/registry.py @@ -0,0 +1,118 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Explicit versioned adapter registration, independent of persisted import strings.""" + +from types import MappingProxyType + +from nexent.core.model_service.adapters.openai_chat import ModelAdapterError, OpenAIChatAdapter +from nexent.core.model_service.http_contracts import ModelTransportError, validate_headers +from nexent.core.model_service.http_sender import HttpRequestSender +from nexent.core.model_service.parameter_transforms import parameter_transform_references +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.resource_conversion import ResourceConverterRegistry + + +class AdapterRegistry: + """A detached immutable directory of trusted, already-constructed implementations.""" + + def __init__(self, adapters=None): + instances = (OpenAIChatAdapter(),) if adapters is None else tuple(adapters) + entries = {} + for adapter in instances: + reference = getattr(adapter, "reference", None) + if not isinstance(reference, VersionedReference): + raise ModelAdapterError("MODEL_ADAPTER_REGISTRATION_INVALID") + if not isinstance(getattr(adapter, "protocol_reference", None), VersionedReference): + raise ModelAdapterError("MODEL_ADAPTER_REGISTRATION_INVALID") + key = (reference.ref, reference.version) + if key in entries: + raise ModelAdapterError("MODEL_ADAPTER_REGISTRATION_DUPLICATE") + methods = ("prepare_http_request", "decode_response", "decode_stream", "adecode_stream", "normalize_error") + if any(not callable(getattr(adapter, method, None)) for method in methods): + raise ModelAdapterError("MODEL_ADAPTER_REGISTRATION_INVALID") + entries[key] = adapter + self._entries = MappingProxyType(entries) + + def resolve(self, reference: VersionedReference): + if not isinstance(reference, VersionedReference): + raise ModelAdapterError("MODEL_ADAPTER_UNREGISTERED") + adapter = self._entries.get((reference.ref, reference.version)) + if adapter is None: + raise ModelAdapterError("MODEL_ADAPTER_UNREGISTERED") + return adapter + + @property + def references(self) -> tuple[VersionedReference, ...]: + return tuple(VersionedReference(ref=key, version=version) for key, version in self._entries) + + @property + def protocol_references(self) -> tuple[VersionedReference, ...]: + keys = sorted({(adapter.protocol_reference.ref, adapter.protocol_reference.version) + for adapter in self._entries.values()}) + return tuple(VersionedReference(ref=key, version=version) for key, version in keys) + + +def _bearer_headers(lookup, tenant_id, reference): + if reference.kind != "model_record": + raise ModelTransportError("CREDENTIAL_SCHEME_MISMATCH") + value = lookup(tenant_id, reference) + if not isinstance(value, str) or not value.strip(): + raise ModelTransportError("CREDENTIAL_RESOLUTION_FAILED") + return validate_headers({"authorization": "Bearer " + value}, credentials=True) + + +def _no_auth_headers(lookup, tenant_id, reference): + if reference.kind != "none": + raise ModelTransportError("CREDENTIAL_SCHEME_MISMATCH") + return {} + + +class AuthenticationRegistry: + """Trusted auth functions; credential lookup remains tenant-scoped and injected.""" + + def __init__(self): + self._entries = MappingProxyType({ + ("bearer_token_v1", 1): _bearer_headers, + ("no_auth_v1", 1): _no_auth_headers, + }) + + @property + def references(self) -> tuple[VersionedReference, ...]: + return tuple(VersionedReference(ref=key, version=version) for key, version in self._entries) + + def sender_for(self, profile, lookup, *, http_headers=None) -> HttpRequestSender: + reference = profile.bindings.auth + scheme = self._entries.get((reference.ref, reference.version)) + if scheme is None: + raise ModelAdapterError("MODEL_AUTH_UNREGISTERED") + credential = profile.connection.credential_ref + tenant_id = profile.identity.tenant_id + + def resolve(submitted_reference): + if submitted_reference != credential: + raise ModelTransportError("CREDENTIAL_REFERENCE_MISMATCH") + try: + from nexent.core.model_service.connection_headers import connection_headers + + headers = connection_headers(http_headers or {}) + generated = scheme(lookup, tenant_id, credential) + if headers.keys() & generated.keys(): + raise ModelTransportError("CREDENTIAL_HEADER_CONFLICT") + return {**headers, **generated} + except Exception: + raise ModelTransportError("CREDENTIAL_RESOLUTION_FAILED") from None + + return HttpRequestSender(resolve) + + +def model_service_references(): + """Report executable local registrations for composing and restoring outlets.""" + adapters, authentication, converters = AdapterRegistry(), AuthenticationRegistry(), ResourceConverterRegistry() + return MappingProxyType({ + "protocol": adapters.protocol_references, + "adapter": adapters.references, + "auth": authentication.references, + "code_converter": converters.references("code_converter"), + "native_converter": converters.references("native_converter"), + "parameter_transform": parameter_transform_references(), + }) diff --git a/sdk/nexent/core/models/capacity_budget.py b/sdk/nexent/core/models/capacity_budget.py index 8378c368a9..fbca90c05c 100644 --- a/sdk/nexent/core/models/capacity_budget.py +++ b/sdk/nexent/core/models/capacity_budget.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + from __future__ import annotations import hashlib @@ -540,3 +542,35 @@ def _uncertainty_reserve( reserve = math.ceil(capacity_snapshot.context_window_tokens * 0.10) return reserve, "context_window_10pct", ["uncertainty_reserve_active"] + + +def verify_context_budget_capacity( + *, budget_snapshot: ContextBudgetSnapshot, capacity_snapshot: Optional[Mapping[str, Any]], +) -> None: + """Reuse the legacy dispatch identity check without provider or HTTP dependencies.""" + if not capacity_snapshot: + return + w1_fingerprint = capacity_snapshot.get("capacity_fingerprint") + provider = capacity_snapshot.get("provider") + model_name = capacity_snapshot.get("model_name") + if not w1_fingerprint and not provider and not model_name: + return + if w1_fingerprint and w1_fingerprint != budget_snapshot.w1_fingerprint: + raise ContextBudgetCapacityMismatch( + field="w1_fingerprint", + expected=w1_fingerprint, + actual=budget_snapshot.w1_fingerprint, + ) + # Provider IDs are normalized when legacy records become resolved outlets. + if provider and str(provider).casefold() != budget_snapshot.provider.casefold(): + raise ContextBudgetCapacityMismatch( + field="provider", + expected=provider, + actual=budget_snapshot.provider, + ) + if model_name and model_name != budget_snapshot.model_name: + raise ContextBudgetCapacityMismatch( + field="model_name", + expected=model_name, + actual=budget_snapshot.model_name, + ) diff --git a/sdk/nexent/core/models/openai_long_context_model.py b/sdk/nexent/core/models/openai_long_context_model.py index 82be2e4ea7..b022cb5abb 100644 --- a/sdk/nexent/core/models/openai_long_context_model.py +++ b/sdk/nexent/core/models/openai_long_context_model.py @@ -5,42 +5,28 @@ import tiktoken from smolagents.models import ChatMessage -from ..models import OpenAIModel +from .openai_llm import OpenAIModel from .sampling import DEFAULT_TEMPERATURE, DEFAULT_TOP_P from ..utils.observer import MessageObserver logger = logging.getLogger("openai_long_context_model") -class OpenAILongContextModel(OpenAIModel): +class LongTextPreparation: """ Long context model class, used to process large text files Support automatic truncation of content exceeding context limits """ - def __init__(self, observer: MessageObserver, temperature=DEFAULT_TEMPERATURE, top_p=DEFAULT_TOP_P, - max_context_tokens=128000, truncation_strategy="start", ssl_verify: bool = True, *args, **kwargs): - """ - Initialize the long context model - - Args: - observer: Message observer - temperature: Temperature parameter - top_p: top_p parameter - max_context_tokens: Maximum context token number, default is 128k - truncation_strategy: Truncation strategy - - "start": Only keep the beginning part - - "middle": Keep the beginning and end parts - - "end": Only keep the end part - *args, **kwargs: Other parameters - """ - super().__init__(observer=observer, temperature=temperature, top_p=top_p, ssl_verify=ssl_verify, *args, **kwargs) - self.max_context_tokens = max_context_tokens - if truncation_strategy not in ["start", "middle", "end"]: + def initialize_long_text(self, max_context_tokens, truncation_strategy): + if type(max_context_tokens) is not int or max_context_tokens <= 0: + raise ValueError("Long-text context limit must be a positive integer") + if truncation_strategy not in ("start", "middle", "end"): raise ValueError("truncation_strategy must be 'start', 'middle' or 'end'") + self.max_context_tokens = max_context_tokens self.truncation_strategy = truncation_strategy self._tokenizer = None - + def _get_tokenizer(self): """Get tokenizer, used to calculate token number""" if self._tokenizer is None: @@ -220,3 +206,11 @@ def analyze_long_text(self, text_content: str, system_prompt: str, user_prompt: except Exception as e: logger.error(f"Error during long text analysis: {str(e)}") raise + +class OpenAILongContextModel(LongTextPreparation, OpenAIModel): + """Keep the public model constructor over shared text preparation.""" + + def __init__(self, observer: MessageObserver, temperature=DEFAULT_TEMPERATURE, top_p=DEFAULT_TOP_P, + max_context_tokens=128000, truncation_strategy="start", ssl_verify=True, *args, **kwargs): + super().__init__(observer=observer, temperature=temperature, top_p=top_p, ssl_verify=ssl_verify, *args, **kwargs) + self.initialize_long_text(max_context_tokens, truncation_strategy) diff --git a/sdk/nexent/monitor/monitoring.py b/sdk/nexent/monitor/monitoring.py index c3dafa51d8..7cd2954d1b 100644 --- a/sdk/nexent/monitor/monitoring.py +++ b/sdk/nexent/monitor/monitoring.py @@ -1979,8 +1979,8 @@ def record_token(self, token: str) -> None: "token_length": len(token) }) - def record_completion(self, input_tokens: int = 0, output_tokens: int = 0) -> None: - """Record completion metrics using OpenInference semantic conventions.""" + def record_completion(self, input_tokens: int = 0, output_tokens: int = 0, *, total_tokens: Any = ...) -> None: + """Preserve legacy inferred totals; explicit None means the provider did not report one.""" if not self.manager.is_enabled: return @@ -2007,18 +2007,22 @@ def record_completion(self, input_tokens: int = 0, output_tokens: int = 0) -> No usage_details = { "input": input_tokens, "output": output_tokens, - "total": input_tokens + output_tokens, } - self.span.set_attributes({ + total = input_tokens + output_tokens if total_tokens is ... else total_tokens + if total is not None: + usage_details["total"] = total + attributes = { "llm.token_count.prompt": input_tokens, "llm.token_count.completion": output_tokens, - "llm.token_count.total": input_tokens + output_tokens, "llm.usage_details": json.dumps( usage_details, ensure_ascii=False), "llm.generation_rate": generation_rate, "llm.duration.total": total_duration, "llm.time_to_first_token": self.first_token_time - self.start_time if self.first_token_time else 0 - }) + } + if total is not None: + attributes["llm.token_count.total"] = total + self.span.set_attributes(attributes) # --------------------------------------------------------------------------- diff --git a/test/sdk/core/agents/conftest.py b/test/sdk/core/agents/conftest.py new file mode 100644 index 0000000000..a7a9c5b97d --- /dev/null +++ b/test/sdk/core/agents/conftest.py @@ -0,0 +1,14 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Keep real configuration contracts available to legacy SDK namespace loaders.""" + +import sys + +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile # noqa: F401 +from nexent.core.models.capacity_budget import ContextBudgetSnapshot # noqa: F401 + +# Older agent tests replace SDK package roots with dependency doubles. These +# two pure contracts must retain their real definitions under either namespace. +for _name, _module in list(sys.modules.items()): + if _name.startswith("nexent.core.model_service") or _name.startswith("nexent.core.models.capacity_"): + sys.modules.setdefault(f"sdk.{_name}", _module) diff --git a/test/sdk/core/model_service/conftest.py b/test/sdk/core/model_service/conftest.py new file mode 100644 index 0000000000..92045a46c3 --- /dev/null +++ b/test/sdk/core/model_service/conftest.py @@ -0,0 +1,74 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Shared controlled outlet fixture for provider adapter unit boundaries.""" + +import pytest + + +@pytest.fixture(scope="module") +def openai_profile(): + # Keep unrelated contract tests collectible before adapter-specific fixtures are used. + from nexent.core.model_service.adapters.openai_chat import OpenAIChatAdapter + from nexent.core.model_service.profiles.composition import compose_provider_model_policy + from nexent.core.model_service.profiles.contracts import VersionedReference + from nexent.core.model_service.profiles.outlet_resolution import ( + construct_resolved_model_profile, + ) + from nexent.core.model_service.profiles.resolved_profile import ( + ConnectionRules, + ContextConfiguration, + OutletConnection, + ) + from nexent.core.model_service.profiles.resolver import load_profile_catalog + + catalog = load_profile_catalog() + ref = VersionedReference(ref="adapter_fixture_v1", version=1) + registry = { + key: [ref] + for key in ("protocol", "auth", "adapter", "code_converter", "native_converter") + } + registry["protocol"] = [OpenAIChatAdapter.protocol_reference] + registry["adapter"] = [OpenAIChatAdapter.reference] + registry["parameter_transform"] = [VersionedReference(ref="identity_v1", version=1)] + policy = compose_provider_model_policy( + catalog, + provider_key="alibaba-cn", + request_model_id="qwen3.5-plus", + model_profile_key=None, + base_bindings={ + key: references[0].model_dump() + for key, references in registry.items() + if key != "parameter_transform" + }, + base_instruction=VersionedReference(ref="system_only_v1", version=1), + registered_references=registry, + submitted_values={"enable_thinking": False}, + ) + return construct_resolved_model_profile( + policy, + catalog, + tenant_id="tenant", + display_name="outlet", + model_type="llm", + provider_id="alibaba", + connection=OutletConnection( + base_url="https://example.test/v1", + ssl_verify=True, + timeout_seconds=10, + concurrency_limit=None, + credential_ref={"kind": "model_record", "model_id": None}, + ), + connection_rules=ConnectionRules( + credential_kind="model_record", + relative_path="/chat/completions", + method="POST", + headers={}, + ), + context_configuration=ContextConfiguration( + output_reserve_tokens=None, + tokenizer_family=None, + capacity_source="profile", + capability_profile_version=None, + ), + registered_references=registry, + ) diff --git a/test/sdk/core/model_service/test_adapter_registry.py b/test/sdk/core/model_service/test_adapter_registry.py new file mode 100644 index 0000000000..c5e36ac424 --- /dev/null +++ b/test/sdk/core/model_service/test_adapter_registry.py @@ -0,0 +1,52 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Explicit versioned adapter registration without HTTP or service orchestration.""" + +import pytest +from nexent.core.model_service.adapters.openai_chat import ModelAdapterError, OpenAIChatAdapter +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.registry import AdapterRegistry + + +def test_default_registry_resolves_only_real_explicit_versioned_adapter(): + registry = AdapterRegistry() + adapter = registry.resolve(VersionedReference(ref="openai_chat_v1", version=1)) + assert isinstance(adapter, OpenAIChatAdapter) + assert registry.references == (adapter.reference,) + assert registry.protocol_references == (adapter.protocol_reference,) + for ref in [ + VersionedReference(ref="openai_chat_v1", version=2), + VersionedReference(ref="os.system", version=1), + "openai_chat_v1", + ]: + with pytest.raises(ModelAdapterError, match="^MODEL_ADAPTER_UNREGISTERED$"): + registry.resolve(ref) + + +def test_registration_directory_is_detached_and_duplicates_are_rejected(): + adapter = OpenAIChatAdapter() + source = [adapter] + registry = AdapterRegistry(source) + source.clear() + assert registry.resolve(adapter.reference) is adapter + with pytest.raises(TypeError): + registry._entries[("new", 1)] = adapter + with pytest.raises( + ModelAdapterError, match="^MODEL_ADAPTER_REGISTRATION_DUPLICATE$" + ): + AdapterRegistry([adapter, adapter]) + + +def test_empty_or_invalid_directory_does_not_claim_unimplemented_adapters(): + assert AdapterRegistry([]).references == () + assert AdapterRegistry([]).protocol_references == () + with pytest.raises(ModelAdapterError, match="^MODEL_ADAPTER_REGISTRATION_INVALID$"): + AdapterRegistry([object()]) + incomplete = type( + "Incomplete", + (), + {"reference": VersionedReference(ref="not_implemented", version=1)}, + )() + with pytest.raises(ModelAdapterError, match="^MODEL_ADAPTER_REGISTRATION_INVALID$"): + AdapterRegistry([incomplete]) + diff --git a/test/sdk/core/model_service/test_authentication_registry.py b/test/sdk/core/model_service/test_authentication_registry.py new file mode 100644 index 0000000000..884eacdb62 --- /dev/null +++ b/test/sdk/core/model_service/test_authentication_registry.py @@ -0,0 +1,121 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Auth registration and safe credential resolution, without network I/O.""" + +from dataclasses import replace + +import pytest +from nexent.core.model_service.adapters.openai_chat import ( + ModelAdapterError, + OpenAIChatAdapter, +) +from nexent.core.model_service.contracts import ModelMessage, ModelRequest +from nexent.core.model_service.http_contracts import ModelTransportError +from nexent.core.model_service.registry import AuthenticationRegistry + + +class CredentialStore: + def lookup(self, tenant_id, reference): + raise AssertionError("External store must be mocked") + + +@pytest.fixture +def lookup(mocker): + return mocker.patch.object(CredentialStore, "lookup", return_value="dummy-secret") + + +def configured(profile, auth="bearer_token_v1", version=1, kind="model_record"): + values = profile.model_dump() + values["outlet_id"] = 7 + values["connection"]["credential_ref"]["model_id"] = 7 + values["bindings"]["auth"] = {"ref": auth, "version": version} + values["connection"]["credential_ref"]["kind"] = kind + values["connection_rules"]["credential_kind"] = kind + return type(profile).model_validate(values) + + +def prepared(profile): + return OpenAIChatAdapter().prepare_http_request( + ModelRequest( + request_id="request", + outlet_id=profile.outlet_id, + config_revision=profile.config_revision, + messages=[ModelMessage(semantic_role="user", content_parts=[{"type": "text", "text": "hi"}])], + ), + profile, + ) + + +def test_bearer_reads_tenant_reference_once_and_freezes_headers(openai_profile, lookup): + profile = configured(openai_profile) + sender = AuthenticationRegistry().sender_for(profile, lookup) + request = prepared(profile) + lookup.assert_not_called() + frozen = sender.freeze_credentials(request) + lookup.assert_called_once_with("tenant", profile.connection.credential_ref) + lookup.return_value = "changed-secret" + for _ in range(3): + assert frozen._headers(request)["authorization"] == "Bearer dummy-secret" + lookup.assert_called_once() + + +def test_no_auth_never_reads_credentials(openai_profile, lookup): + profile = configured(openai_profile, auth="no_auth_v1", kind="none") + lookup.side_effect = AssertionError("Must not read credentials") + sender = AuthenticationRegistry().sender_for(profile, lookup) + assert sender._headers(prepared(profile)) == {"content-type": "application/json"} + lookup.assert_not_called() + + +@pytest.mark.parametrize("auth,version", [("unregistered", 1), ("bearer_token_v1", 2)]) +def test_unknown_auth_reference_rejected_before_lookup(openai_profile, lookup, auth, version): + with pytest.raises(ModelAdapterError, match="^MODEL_AUTH_UNREGISTERED$"): + AuthenticationRegistry().sender_for(configured(openai_profile, auth, version), lookup) + lookup.assert_not_called() + + +@pytest.mark.parametrize("value", [None, "", " ", "dummy\r\nInjected: header", "dummy\x00", "非ASCII"]) +def test_invalid_credentials_are_rejected_without_echo(openai_profile, lookup, value): + profile = configured(openai_profile) + lookup.return_value = value + sender = AuthenticationRegistry().sender_for(profile, lookup) + with pytest.raises(ModelTransportError, match="^CREDENTIAL_RESOLUTION_FAILED$") as caught: + sender.freeze_credentials(prepared(profile)) + assert caught.value.__cause__ is None + + +@pytest.mark.parametrize("auth,kind", [("bearer_token_v1", "none"), ("no_auth_v1", "model_record")]) +def test_auth_scheme_cannot_use_other_credential_kind(openai_profile, lookup, auth, kind): + profile = configured(openai_profile, auth=auth, kind=kind) + with pytest.raises(ModelTransportError, match="^CREDENTIAL_RESOLUTION_FAILED$"): + AuthenticationRegistry().sender_for(profile, lookup).freeze_credentials(prepared(profile)) + lookup.assert_not_called() + + +def test_lookup_exception_is_sanitized(openai_profile, lookup): + profile = configured(openai_profile) + lookup.side_effect = RuntimeError("dummy-private-value") + with pytest.raises(ModelTransportError, match="^CREDENTIAL_RESOLUTION_FAILED$") as caught: + AuthenticationRegistry().sender_for(profile, lookup).freeze_credentials(prepared(profile)) + assert caught.value.__cause__ is None + + +def test_sender_cannot_read_another_outlet_credential(openai_profile, lookup): + profile = configured(openai_profile) + sender = AuthenticationRegistry().sender_for(profile, lookup) + request = prepared(profile) + foreign = replace( + request, + json_body={}, + headers=dict(request.headers), + credential_ref=type(request.credential_ref)(kind="model_record", model_id=8), + ) + with pytest.raises(ModelTransportError, match="^CREDENTIAL_RESOLUTION_FAILED$"): + sender.freeze_credentials(foreign) + lookup.assert_not_called() + + +def test_registry_lists_only_implemented_auth_schemes(): + assert {(item.ref, item.version) for item in AuthenticationRegistry().references} == { + ("bearer_token_v1", 1), ("no_auth_v1", 1) + } diff --git a/test/sdk/core/model_service/test_composition.py b/test/sdk/core/model_service/test_composition.py new file mode 100644 index 0000000000..98b2476cd3 --- /dev/null +++ b/test/sdk/core/model_service/test_composition.py @@ -0,0 +1,467 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Exact profile selection preserves priority, conflicts and immutable drafts.""" + +from dataclasses import replace + +import pytest + +from nexent.core.model_service.profiles.composition import ( + ProfileCompositionError, + build_profile_catalog, + compose_provider_model_policy, +) +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.resolver import ( + SourceCatalog, + SourceCatalogEntry, +) +from nexent.core.model_service.profiles.supplements import ( + SupplementCatalog, + SupplementEntry, +) + + +REFERENCE = VersionedReference(ref="test_v1", version=1) +INSTRUCTION = VersionedReference(ref="system_only_v1", version=1) +BINDINGS = { + key: REFERENCE.model_dump() + for key in ("protocol", "auth", "adapter", "code_converter", "native_converter") +} + + +def source(kind, key, capabilities): + return SourceCatalogEntry(kind, key, {}, capabilities, {}, {}, {}, (), kind + key) + + +@pytest.fixture +def sources(): + return SourceCatalog( + "source-v1", + "upstream", + "parser", + "mapping", + 1, + { + "family/model": source( + "model", "family/model", {"tools": {"status": "supported"}} + ) + }, + {"provider": source("provider", "provider", {})}, + {"provider/exact": source("provider_model", "provider/exact", {})}, + ) + + +def catalog(sources, *, model=None, provider=None, combo=None): + declarations = { + "model": ("family/model", model or {}), + "provider": ("provider", provider or {}), + "provider_model": ( + "provider/exact", + {"model_profile_key": "family/model", **(combo or {})}, + ), + } + entries = { + kind: SupplementEntry( + "rules/" + kind + ".toml", + kind, + { + "target_kind": kind, + "target_key": key, + **declaration, + }, + ) + for kind, (key, declaration) in declarations.items() + } + return build_profile_catalog( + sources, SupplementCatalog("rules-v1", "schema-v1", entries, sources.revision) + ) + + +def test_top_p_override_has_explicit_support_and_versioned_revision( + sources, monkeypatch +): + from nexent.core.model_service.profiles import composition + + override = { + "path": "/sampling/top_p", + "value": {"status": "unsupported"}, + "reason": "Service-specific support", + "evidence": ["fixture"], + } + current = catalog(sources, combo={"capability_overrides": [override]}) + assert ( + current.provider_models["provider/exact"].capabilities["sampling"]["top_p"][ + "status" + ] + == "unsupported" + ) + assert ( + current.models["family/model"].capabilities["sampling"]["top_p"]["status"] + == "unknown" + ) + assert composition.COMPOSER_REF == "profile_policy_composer_v6" + monkeypatch.setattr(composition, "COMPOSER_REF", "profile_policy_composer_v1") + previous = catalog(sources, combo={"capability_overrides": [override]}) + assert current.revision != previous.revision + assert ( + current.models["family/model"].revision + != previous.models["family/model"].revision + ) + + +def compose(catalog, **changes): + options = { + "provider_key": "provider", + "request_model_id": "exact", + "model_profile_key": None, + "base_bindings": BINDINGS, + "base_instruction": INSTRUCTION, + "registered_references": { + key: [REFERENCE] for key in (*BINDINGS, "parameter_transform") + }, + "submitted_values": {}, + } + options.update(changes) + return compose_provider_model_policy(catalog, **options) + + +def override(value): + return { + "path": "/tools/status", + "value": value, + "reason": "Service constraint", + "evidence": "fixture", + } + + +def test_exact_combination_override_takes_priority_over_provider(sources): + value = compose( + catalog( + sources, + provider={"capability_overrides": [override("unsupported")]}, + combo={"capability_overrides": [override("supported")]}, + ) + ) + assert value.capabilities["tools"]["status"] == "supported" + assert value.overrides[-1]["original_value"] == "unsupported" + assert value.model_profile_key == "family/model" + with pytest.raises(TypeError): + value.capabilities["tools"]["status"] = "unknown" + + +def test_provider_experience_takes_priority_over_hosted_source_fact(sources): + hosted = replace( + sources.provider_models["provider/exact"], + capabilities={"tools": {"status": "supported"}}, + ) + changed = replace(sources, provider_models={"provider/exact": hosted}) + value = compose( + catalog(changed, provider={"capability_overrides": [override("unsupported")]}) + ) + assert value.capabilities["tools"]["status"] == "unsupported" + assert value.overrides[-1]["original_value"] == "supported" + + +def test_unknown_exact_id_does_not_select_similar_combination(sources): + value = compose(catalog(sources), request_model_id="exact-v2") + assert value.model_profile_key is None + assert value.capabilities["tools"]["status"] == "unknown" + assert value.capabilities["context"]["combined_window"] is None + + +def test_explicit_model_selection_conflict_is_rejected(sources): + with pytest.raises(ProfileCompositionError, match="PROFILE_CONFLICT"): + compose(catalog(sources), model_profile_key="different/model") + + +def test_provider_instruction_overrides_model_instruction(sources): + value = compose(catalog( + sources, + model={"instruction_mapping": {"ref": "developer_then_system_v1", "version": 1}}, + provider={"instruction_mapping": INSTRUCTION.model_dump()}, + )) + assert value.instruction_mapping["strategy_id"] == "system_only" + combination = compose(catalog(sources, + model={"instruction_mapping": INSTRUCTION.model_dump()}, + provider={"instruction_mapping": {"ref": "unavailable_v1", "version": 1}}, + combo={"instruction_mapping": INSTRUCTION.model_dump()}, + )) + assert combination.instruction_mapping["strategy_id"] == "system_only" + + +def test_provider_binding_overrides_model_but_still_requires_registration(sources): + different = {"ref": "different_v1", "version": 1} + definitions = catalog(sources, + model={"bindings": {"adapter": different}}, + provider={"bindings": {"adapter": REFERENCE.model_dump()}}, + ) + assert compose(definitions).bindings["adapter"]["ref"] == "test_v1" + combination = catalog(sources, + model={"bindings": {"adapter": REFERENCE.model_dump()}}, + provider={"bindings": {"adapter": different}}, + combo={"bindings": {"adapter": REFERENCE.model_dump()}}, + ) + assert compose(combination).bindings["adapter"]["ref"] == "test_v1" + with pytest.raises(ProfileCompositionError, match="PROFILE_BINDING_UNREGISTERED"): + compose(catalog(sources, model={"bindings": {"adapter": REFERENCE.model_dump()}}, + provider={"bindings": {"adapter": different}})) + + +def test_unavailable_binding_does_not_publish_draft(sources): + with pytest.raises(ProfileCompositionError, match="PROFILE_BINDING_UNREGISTERED"): + compose(catalog(sources), registered_references={}) + + +def test_supplements_cannot_be_paired_with_another_source_snapshot(sources): + supplements = SupplementCatalog("rules", "schema", {}, "obsolete") + with pytest.raises( + ProfileCompositionError, match="PROFILE_SOURCE_REVISION_MISMATCH" + ): + build_profile_catalog(sources, supplements) + + +def test_conflicting_capability_overrides_are_rejected(sources): + with pytest.raises(ProfileCompositionError, match="PROFILE_CONFLICT"): + catalog( + sources, + combo={ + "capability_overrides": [override("supported"), override("unsupported")] + }, + ) + + +@pytest.mark.parametrize("value", ["", " ", "x" * 101, None, True]) +def test_request_model_id_is_not_coerced(sources, value): + with pytest.raises(ProfileCompositionError, match="REQUEST_MODEL_ID_INVALID"): + compose(catalog(sources), request_model_id=value) + + +def test_nullable_default_is_materialized_once_and_detached(sources): + rule = { + "key": "effort", + "schema": {"enum": [None, "low"]}, + "visible": True, + "editable": True, + "required": False, + "allowed_scopes": ["outlet"], + "ui_group": "reasoning", + "owner": "model", + "source": {"ref": "fixture"}, + "default_value": None, + "api_binding": { + "path": "/effort", + "transform_ref": "test_v1", + "transform_version": 1, + }, + } + definitions = catalog(sources, model={"parameters": [rule]}) + value = compose(definitions) + assert dict(value.parameter_values) == {"effort": None} + rule["default_value"] = "low" + assert definitions.models["family/model"].parameters[0]["default_value"] is None + assert value.parameter_values["effort"] is None + declarations = value.parameter_rules.declarations + declarations[0]["schema"]["enum"].append("high") + assert value.parameter_rules.declarations[0]["schema"]["enum"] == [None, "low"] + + +def test_oversized_atomic_definition_is_rejected_in_utf8_bytes(sources): + with pytest.raises(ProfileCompositionError, match="PROFILE_SIZE_LIMIT"): + catalog(sources, model={"aliases": ["中" * 350000]}) + + +def test_declared_thinking_control_requires_parameter_binding(sources): + model = replace( + sources.models["family/model"], + capabilities={ + "reasoning": { + "status": "supported", + "controls": [{"mode": "toggle", "values": [False, True]}], + }, + }, + ) + changed = replace(sources, models={"family/model": model}) + with pytest.raises( + ProfileCompositionError, match="REASONING_PARAMETER_BINDING_MISSING" + ): + compose(catalog(changed)) + + +def test_thinking_control_values_must_fit_declared_parameter_schema(sources): + model = replace( + sources.models["family/model"], + capabilities={ + "reasoning": { + "status": "supported", + "controls": [{"mode": "toggle", "values": [False, True]}], + }, + }, + ) + changed = replace(sources, models={"family/model": model}) + parameter = { + "key": "enable_thinking", + "schema": {"enum": [False]}, + "visible": True, + "editable": True, + "required": False, + "allowed_scopes": ["outlet"], + "ui_group": "reasoning", + "owner": "combination", + "source": {"ref": "fixture"}, + "api_binding": { + "path": "/enable_thinking", + "transform_ref": "test_v1", + "transform_version": 1, + }, + } + with pytest.raises( + ProfileCompositionError, match="REASONING_PARAMETER_SCHEMA_CONFLICT" + ): + compose(catalog(changed, combo={"parameters": [parameter]})) + parameter["schema"] = {"type": "boolean"} + value = compose(catalog(changed, combo={"parameters": [parameter]})) + assert value.parameter_bindings["enable_thinking"]["path"] == "/enable_thinking" + + +def test_composition_propagates_adapter_owned_path_protection(sources): + parameter = { + "key": "effort", + "schema": {"type": "string"}, + "visible": True, + "editable": True, + "required": False, + "allowed_scopes": ["outlet"], + "ui_group": "reasoning", + "owner": "provider", + "source": {"ref": "fixture"}, + "api_binding": { + "path": "/options/effort", + "transform_ref": "test_v1", + "transform_version": 1, + }, + } + from nexent.core.model_service.profiles.parameter_schema import ( + ParameterValidationError, + ) + + with pytest.raises(ParameterValidationError, match="RESERVED_API_PATH"): + compose( + catalog(sources, provider={"parameters": [parameter]}), + protected_api_paths=["/options"], + ) + + +def test_invalid_unicode_model_id_is_rejected_without_raw_values(sources): + with pytest.raises( + ProfileCompositionError, match="PROFILE_JSON_INVALID" + ) as captured: + compose(catalog(sources), request_model_id="private-\ud800") + assert "private" not in str(captured.value) + + +def test_resolved_policy_size_includes_submitted_parameter_values(sources): + parameter = { + "key": "custom", + "schema": {"type": "string"}, + "visible": True, + "editable": True, + "required": False, + "allowed_scopes": ["outlet"], + "ui_group": "advanced", + "owner": "provider", + "source": {"ref": "fixture"}, + "api_binding": { + "path": "/custom", + "transform_ref": "test_v1", + "transform_version": 1, + }, + } + definitions = catalog(sources, provider={"parameters": [parameter]}) + with pytest.raises(ProfileCompositionError, match="PROFILE_SIZE_LIMIT"): + compose(definitions, submitted_values={"custom": "中" * 350000}) + value = compose(definitions, submitted_values={"custom": "中" * 1000}) + assert len(value.parameter_values["custom"]) == 1000 + + +def test_frozen_supplement_instruction_reference_is_materialized(sources): + from types import MappingProxyType + + value = compose( + catalog( + sources, + model={"instruction_mapping": MappingProxyType(INSTRUCTION.model_dump())}, + ) + ) + assert value.instruction_mapping["strategy_id"] == "system_only" + + +def test_top_p_is_explicit_without_claiming_unknown_capability(sources): + from nexent.core.model_service.profiles.protocol_parameters import openai_chat_parameter_rules + + value = compose(catalog(sources), protocol_parameter_rules=openai_chat_parameter_rules(), + registered_references={**{key: [REFERENCE] for key in BINDINGS}, + "parameter_transform": [VersionedReference(ref="identity_v1", version=1)]}) + top_p = next(rule for rule in value.parameter_rules.declarations if rule["key"] == "top_p") + assert top_p["visible"] is True + assert top_p["allowed_scopes"] == ["outlet", "conversation"] + assert top_p["schema"]["minimum"] == 0 + assert top_p["schema"]["maximum"] == 1 + assert "top_p" not in value.parameter_values + assert value.capabilities["sampling"]["top_p"]["status"] == "unknown" + + +@pytest.mark.parametrize("value", [0, 1, 0.85]) +def test_top_p_accepts_explicit_values(sources, value): + from nexent.core.model_service.profiles.protocol_parameters import openai_chat_parameter_rules + + policy = compose(catalog(sources), protocol_parameter_rules=openai_chat_parameter_rules(), + registered_references={**{key: [REFERENCE] for key in BINDINGS}, + "parameter_transform": [VersionedReference(ref="identity_v1", version=1)]}, + submitted_values={"top_p": value}) + assert policy.parameter_values["top_p"] == value + + +@pytest.mark.parametrize("value", [-0.01, 1.01]) +def test_top_p_rejects_out_of_range_values(sources, value): + from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError + from nexent.core.model_service.profiles.protocol_parameters import openai_chat_parameter_rules + + with pytest.raises(ParameterValidationError): + compose(catalog(sources), protocol_parameter_rules=openai_chat_parameter_rules(), + registered_references={**{key: [REFERENCE] for key in BINDINGS}, + "parameter_transform": [VersionedReference(ref="identity_v1", version=1)]}, + submitted_values={"top_p": value}) + + +def test_explicitly_unsupported_top_p_keeps_configuration(sources): + from nexent.core.model_service.profiles.protocol_parameters import openai_chat_parameter_rules + + current = catalog(sources, combo={"capability_overrides": [{ + "path": "/sampling/top_p", "value": {"status": "unsupported"}, + "reason": "Service does not accept top_p", "evidence": "fixture", + }]}) + value = compose(current, protocol_parameter_rules=openai_chat_parameter_rules(), + registered_references={**{key: [REFERENCE] for key in BINDINGS}, + "parameter_transform": [VersionedReference(ref="identity_v1", version=1)]}) + assert any(rule["key"] == "top_p" for rule in value.parameter_rules.declarations) + assert value.capabilities["sampling"]["top_p"]["status"] == "unsupported" + + +@pytest.mark.parametrize("provider", [None, "volcengine-coding-plan"]) +@pytest.mark.parametrize("submitted,expected", [({}, 0.95), ({"top_p": 0.8}, 0.8)]) +def test_kimi_sampling_preference_is_composed_without_fixing_user_values(provider, submitted, expected): + from nexent.core.model_service.profiles.resolver import load_profile_catalog + from nexent.core.model_service.registry import model_service_references + from nexent.core.model_service.profiles.protocol_parameters import openai_chat_parameter_rules + + refs = model_service_references() + policy = compose_provider_model_policy( + load_profile_catalog(), provider_key=provider, request_model_id="kimi-k3", + model_profile_key="moonshotai/kimi-k3", + base_bindings={key: values[0].model_dump() for key, values in refs.items() if key != "parameter_transform"}, + base_instruction=VersionedReference(ref="system_only_v1", version=1), + registered_references=refs, submitted_values=submitted, + protocol_parameter_rules=openai_chat_parameter_rules(), + ) + assert policy.parameter_values["top_p"] == expected diff --git a/test/sdk/core/model_service/test_errors_registry.py b/test/sdk/core/model_service/test_errors_registry.py new file mode 100644 index 0000000000..b1fadc21af --- /dev/null +++ b/test/sdk/core/model_service/test_errors_registry.py @@ -0,0 +1,268 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Safe error and explicit registration boundaries, without actual provider calls.""" + +import json + +import pytest +from nexent.core.model_service.adapters.openai_chat import ( + ModelAdapterError, + OpenAIChatAdapter, +) +from nexent.core.model_service.contracts import ModelError +from nexent.core.model_service.errors import normalize_model_error +from nexent.core.model_service.http_contracts import ( + ModelTransportError, + RawHttpResponse, +) +from pydantic import ValidationError + + +@pytest.mark.parametrize( + "status,category,retryable", + [ + (401, "MODEL_AUTHENTICATION_ERROR", False), + (403, "MODEL_AUTHENTICATION_ERROR", False), + (404, "MODEL_NOT_FOUND", False), + (429, "MODEL_RATE_LIMITED", True), + (500, "MODEL_SERVICE_UNAVAILABLE", True), + (503, "MODEL_SERVICE_UNAVAILABLE", True), + (400, "MODEL_REQUEST_INVALID", False), + (422, "MODEL_REQUEST_INVALID", False), + (301, "MODEL_HTTP_ERROR", False), + ], +) +def test_status_classification_includes_readable_http_detail( + status, category, retryable +): + error = ModelTransportError( + "HTTP_STATUS_ERROR", response=RawHttpResponse(status, {}, b"dummy-private") + ) + public = normalize_model_error( + error, request_id="r1", attempt=1, max_attempts=3, will_retry=True + ) + assert public.category == category and public.retryable is retryable + assert public.will_retry is retryable and public.scope == "provider" + assert "dummy-private" in public.safe_message + + +@pytest.mark.parametrize( + "category,expected,retryable", + [ + ("HTTP_TIMEOUT", "MODEL_TIMEOUT", True), + ("HTTP_NETWORK_ERROR", "MODEL_NETWORK_ERROR", True), + ("HTTP_TLS_ERROR", "MODEL_TLS_ERROR", False), + ("CREDENTIAL_RESOLUTION_FAILED", "MODEL_CREDENTIAL_ERROR", False), + ("CREDENTIAL_HEADER_CONFLICT", "MODEL_CREDENTIAL_ERROR", False), + ("HTTP_STREAM_FORMAT_INVALID", "MODEL_PROTOCOL_ERROR", False), + ], +) +def test_transport_classification_has_explicit_retry_policy( + category, expected, retryable +): + public = normalize_model_error( + ModelTransportError(category), request_id="r1", max_attempts=3, will_retry=True + ) + assert public.category == expected and public.retryable is retryable + assert public.will_retry is retryable + + +@pytest.mark.parametrize( + "status,code,expected,retryable", + [ + (429, "insufficient_quota", "MODEL_QUOTA_EXHAUSTED", False), + (400, "context_length_exceeded", "MODEL_CONTEXT_EXCEEDED", False), + (200, "invalid_api_key", "MODEL_AUTHENTICATION_ERROR", False), + (200, "rate_limit_exceeded", "MODEL_RATE_LIMITED", True), + (401, "rate_limit_exceeded", "MODEL_AUTHENTICATION_ERROR", False), + (404, "rate_limit_exceeded", "MODEL_NOT_FOUND", False), + (200, "model_not_found", "MODEL_NOT_FOUND", False), + ], +) +def test_only_known_provider_codes_affect_control_and_auth_status_never_retries( + status, code, expected, retryable +): + raw = RawHttpResponse( + status, + {}, + json.dumps({"error": {"code": code, "message": "dummy-private"}}).encode(), + ) + public = normalize_model_error( + ModelTransportError("HTTP_STATUS_ERROR", response=raw), + request_id="r1", + max_attempts=3, + will_retry=True, + ) + assert public.category == expected and public.retryable is retryable + assert public.provider_code == code + assert "dummy-private" not in public.safe_message + assert public.message_source == "system" + + +@pytest.mark.parametrize( + "body", + [ + b'{"error":{"code":"dummy-private","message":"dummy-private"}}', + b'{"error":{"code":2}}', + b'{"error":false}', + b"\xff", + b"{broken", + ], +) +def test_unknown_or_malformed_provider_body_keeps_safe_status_classification(body): + public = normalize_model_error( + ModelTransportError( + "HTTP_STATUS_ERROR", response=RawHttpResponse(500, {}, body) + ), + request_id="r1", + ) + assert public.category == "MODEL_SERVICE_UNAVAILABLE" + assert public.provider_code is None + assert public.safe_message.startswith("(500) ") + + +def test_retryable_is_distinct_from_remaining_attempts_and_visible_output_decision(): + error = ModelTransportError("HTTP_TIMEOUT") + exhausted = normalize_model_error( + error, request_id="r1", attempt=3, max_attempts=3, will_retry=True + ) + visible = normalize_model_error( + error, request_id="r1", attempt=1, max_attempts=3, will_retry=False + ) + assert exhausted.retryable and not exhausted.will_retry + assert visible.retryable and not visible.will_retry + + +def test_unknown_exception_and_protocol_text_are_not_public_diagnostics(): + for error in [ValueError("dummy-private"), ModelAdapterError("dummy-private")]: + public = normalize_model_error(error, request_id="r1") + assert public.category == "MODEL_PROTOCOL_ERROR" + assert "dummy-private" not in str(public.model_dump()) + + +def test_probe_can_read_safe_http_status_and_parameter_without_provider_message(): + raw = RawHttpResponse(400, {}, b'{"error":{"param":"temperature","message":"dummy-private"}}') + error = normalize_model_error(ModelTransportError("HTTP_STATUS_ERROR", response=raw), request_id="probe") + assert error.http_status == 400 and error.parameter_name == "temperature" + assert error.safe_message == "(400) dummy-private" + assert "parameter_name" not in error.model_dump() + + +def test_attempt_invariants_do_not_allow_impossible_retry_claims(): + values = { + "category": "MODEL_TIMEOUT", + "scope": "transport", + "retryable": True, + "will_retry": True, + "attempt": 1, + "max_attempts": 3, + "safe_message": "Request timed out.", + "request_id": "r1", + } + for update in [{"attempt": 4}, {"attempt": 3}, {"retryable": False}]: + with pytest.raises(ValidationError): + ModelError(**(values | update)) + + +def test_adapter_error_normalization_uses_the_shared_safe_contract(): + public = OpenAIChatAdapter().normalize_error( + ModelTransportError("HTTP_TIMEOUT"), + request_id="r1", + attempt=1, + max_attempts=3, + will_retry=True, + ) + assert public.category == "MODEL_TIMEOUT" and public.will_retry + + +def test_success_status_error_envelopes_are_not_completed_messages(openai_profile): + from nexent.core.model_service.adapters.openai_stream import OpenAIStreamDecoder + from nexent.core.model_service.contracts import ModelMessage, ModelRequest + from nexent.core.model_service.sse import SSEEvent + + req = ModelRequest( + request_id="r1", + outlet_id=openai_profile.outlet_id, + config_revision=openai_profile.config_revision, + messages=[ModelMessage(semantic_role="user")], + stream=True, + ) + body = b'{"error":{"code":"insufficient_quota","message":"dummy-private"}}' + raw = RawHttpResponse(200, {}, body) + for decode in [ + lambda: OpenAIChatAdapter().decode_response(raw, req, openai_profile), + lambda: OpenAIStreamDecoder(req, openai_profile).push(SSEEvent(body.decode())), + ]: + with pytest.raises(ModelAdapterError, match="^MODEL_PROVIDER_ERROR$") as caught: + decode() + error = normalize_model_error( + caught.value, request_id="r1", max_attempts=3, will_retry=True + ) + assert error.category == "MODEL_QUOTA_EXHAUSTED" and not error.will_retry + assert "dummy-private" not in repr(caught.value) + assert error.message_source == "system" + assert "dummy-private" not in error.safe_message + + +@pytest.mark.parametrize("body,expected", [ + (b'{"detail":"top_p must be 0.95"}', "top_p must be 0.95"), + (b'{"detail":[{"msg":"field is required"}]}', "field is required"), + (b'gateway failed', "gateway failed"), + (b'', "The model service is temporarily unavailable."), +]) +def test_http_detail_shapes_and_empty_body_keep_status(body, expected): + error = normalize_model_error(ModelTransportError("HTTP_STATUS_ERROR", response=RawHttpResponse(500, {}, body)), request_id="r1") + assert error.safe_message == "(500) " + expected + + +def test_detail_is_bounded_and_not_used_as_retry_instructions(): + raw = RawHttpResponse(401, {}, b'{"error":{"message":"retry this rate limit now"}}') + error = normalize_model_error(ModelTransportError("HTTP_STATUS_ERROR", response=raw), request_id="r1") + assert not error.retryable and error.category == "MODEL_AUTHENTICATION_ERROR" + assert len(RawHttpResponse(500, {}, b'a' * 10000).error_details()["message"]) == 2048 + + +def test_known_protocol_failures_keep_specific_diagnostic(): + error = normalize_model_error(ModelAdapterError("MODEL_USAGE_CONFLICT"), request_id="r1") + assert error.category == "MODEL_PROTOCOL_ERROR" + assert "conflicting token usage" in error.safe_message + + +@pytest.mark.parametrize("code", ["MODEL_STREAM_AFTER_FINISH", "MODEL_STREAM_FINISH_CONFLICT", + "MODEL_RESPONSE_INVALID", "HTTP_STREAM_FORMAT_INVALID", "MODEL_USAGE_CONFLICT"]) +def test_known_diagnostic_messages_are_bilingual_and_not_raw(code): + from nexent.core.model_service.errors import model_error_message + + error = normalize_model_error(ModelAdapterError(code), request_id="r1") + assert error.diagnostic_code == code + assert error.message_source == "system" + assert model_error_message(error, "zh-CN") != model_error_message(error, "en") + assert "模型服务原始错误" not in model_error_message(error, "zh") + assert code not in model_error_message(error, "zh") + + +def test_unknown_provider_diagnostic_keeps_source_label_and_known_code_hides_vendor_text(): + from nexent.core.model_service.errors import model_error_message + + response = RawHttpResponse(400, {}, b'{"error":{"message":"unsupported custom setting","param":"top_p"}}') + error = normalize_model_error(ModelTransportError("HTTP_STATUS_ERROR", response=response), request_id="r1") + assert model_error_message(error, "zh") == "模型服务原始错误:(400) unsupported custom setting" + assert model_error_message(error, "en") == "Original model service error: (400) unsupported custom setting" + known = RawHttpResponse(401, {}, b'{"error":{"code":"invalid_api_key","message":"vendor-only wording"}}') + error = normalize_model_error(ModelTransportError("HTTP_STATUS_ERROR", response=known), request_id="r2") + assert error.message_source == "system" + assert "vendor-only" not in model_error_message(error, "zh") + assert "认证失败" in model_error_message(error, "zh") + assert "(401)" in model_error_message(error, "en") + + +def test_all_known_categories_and_diagnostics_have_both_locales(): + from nexent.core.model_service.errors import _CATEGORIES, _CATEGORY_ZH, _DIAGNOSTICS, model_error_message + + assert set(_CATEGORIES) == set(_CATEGORY_ZH) + for code, (english, chinese) in _DIAGNOSTICS.items(): + assert english and chinese and english != chinese + error = ModelError(category="MODEL_EMPTY_RESPONSE", scope="protocol", retryable=False, + safe_message="Legacy internal English", request_id="empty") + assert model_error_message(error, "zh") == "模型没有返回有效正文或工具调用。" + assert model_error_message(error, "en") == "The model returned no answer content or tool calls." diff --git a/test/sdk/core/model_service/test_http_sender.py b/test/sdk/core/model_service/test_http_sender.py new file mode 100644 index 0000000000..e1b094ef9a --- /dev/null +++ b/test/sdk/core/model_service/test_http_sender.py @@ -0,0 +1,499 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Isolated transport branches; actual network collaboration is a formal D3.""" + +import asyncio +import json +import socket +from types import SimpleNamespace +from unittest.mock import Mock + +import httpx +import pytest +from nexent.core.concurrency.cancellation import RunCancellationScope, RunTerminated +from nexent.core.model_service.http_contracts import ( + ModelTransportError, + PreparedHttpRequest, +) +from nexent.core.model_service.http_sender import HttpRequestSender +from nexent.core.model_service.profiles.resolved_profile import CredentialReference +from nexent.core.model_service.sse import SSEEvent + + +def request(**changes): + values = { + "request_id": "r1", + "method": "POST", + "base_url": "https://example.test/v1?api-version=1", + "relative_path": "chat/completions", + "headers": {}, + "json_body": {"model": "exact", "temperature": 0}, + "timeout_seconds": 10, + "ssl_verify": False, + "credential_ref": CredentialReference(kind="model_record", model_id=3), + } + return PreparedHttpRequest(**(values | changes)) + + +def test_frozen_credentials_resolve_once_and_keep_detached_headers(): + headers = {"Authorization": "Bearer dummy-original"} + calls = [] + + def credentials(reference): + calls.append(reference) + return headers + + wire = request() + frozen = HttpRequestSender(credentials).freeze_credentials(wire) + headers["Authorization"] = "Bearer dummy-changed" + assert frozen._headers(wire)["authorization"] == "Bearer dummy-original" + assert frozen._headers(wire)["authorization"] == "Bearer dummy-original" + assert len(calls) == 1 + + +@pytest.mark.parametrize("late", [False, True]) +@pytest.mark.parametrize( + "event", ["connection.connect_tcp.complete", "connection.start_tls.complete"] +) +def test_sync_cancel_shuts_down_owned_or_late_network_socket(clients, late, event): + scope = RunCancellationScope() + connection = Mock(spec=socket.socket) + stream = SimpleNamespace(get_extra_info=lambda _: connection, close=Mock()) + + def handler(wire): + if late: + scope.cancel() + wire.extensions["trace"](event, {"return_value": stream}) + scope.cancel() + return httpx.Response(200, json={"unused": True}) + + owned = clients(handler) + with pytest.raises(RunTerminated): + HttpRequestSender(lambda _: {}).invoke(request(), scope=scope) + connection.shutdown.assert_called_once_with(socket.SHUT_RDWR) + assert stream.close.call_count == int(late) + assert len(owned) == 1 and owned[0].is_closed + + +@pytest.fixture +def clients(monkeypatch): + original_sync, original_async = httpx.Client, httpx.AsyncClient + captured = [] + + def install(handler): + transport = httpx.MockTransport(handler) + + def sync(**kwargs): + assert kwargs == {"verify": False, "timeout": 10, "trust_env": False} + client = original_sync(transport=transport, **kwargs) + captured.append(client) + return client + + def asynchronous(**kwargs): + assert kwargs == {"verify": False, "timeout": 10, "trust_env": False} + client = original_async(transport=transport, **kwargs) + captured.append(client) + return client + + monkeypatch.setattr(httpx, "Client", sync) + monkeypatch.setattr(httpx, "AsyncClient", asynchronous) + return captured + + return install + + +@pytest.mark.asyncio +async def test_sync_async_exact_request_auth_and_owned_close(clients): + seen = [] + + def handler(req): + seen.append(req) + assert str(req.url) == "https://example.test/v1/chat/completions?api-version=1" + assert req.headers["authorization"] == "Bearer dummy-private" + assert req.headers["content-type"] == "application/json" + assert json.loads(req.content) == {"model": "exact", "temperature": 0} + return httpx.Response(200, json={"choices": []}) + + owned = clients(handler) + sender = HttpRequestSender( + lambda ref: ( + {"Authorization": "Bearer dummy-private"} if ref.model_id == 3 else {} + ) + ) + assert sender.invoke(request()).body == b'{"choices":[]}' + assert (await sender.ainvoke(request())).status_code == 200 + assert len(seen) == 2 + assert all(client.is_closed for client in owned) + + +@pytest.mark.parametrize("status", [301, 400, 401, 429, 500]) +@pytest.mark.asyncio +async def test_http_status_exposes_body_only_to_adapter_and_never_retries( + clients, status +): + seen = [] + + def handler(req): + seen.append(req) + return httpx.Response( + status, text="dummy-private", headers={"Location": "https://other.test"} + ) + + owned = clients(handler) + sender = HttpRequestSender(lambda ref: {}) + for asynchronous in [False, True]: + with pytest.raises(ModelTransportError, match="^HTTP_STATUS_ERROR$") as caught: + if asynchronous: + await sender.ainvoke(request()) + else: + sender.invoke(request()) + assert caught.value.response.status_code == status + assert caught.value.response.body == b"dummy-private" + assert "dummy-private" not in repr(caught.value.response) + assert "dummy-private" not in repr(caught.value) + assert len(seen) == 2 + assert all(client.is_closed for client in owned) + + +@pytest.mark.parametrize( + "error,category", + [(httpx.ReadTimeout, "HTTP_TIMEOUT"), (httpx.ConnectError, "HTTP_NETWORK_ERROR")], +) +@pytest.mark.asyncio +async def test_transport_errors_are_safe_and_close_owned_clients( + clients, error, category +): + def handler(req): + raise error("dummy-private", request=req) + + owned = clients(handler) + sender = HttpRequestSender(lambda ref: {}) + for asynchronous in [False, True]: + with pytest.raises(ModelTransportError, match=f"^{category}$") as caught: + if asynchronous: + await sender.ainvoke(request()) + else: + sender.invoke(request()) + assert caught.value.__suppress_context__ + assert all(client.is_closed for client in owned) + + +@pytest.mark.asyncio +async def test_sync_async_stream_frames_and_early_close(clients): + owned = clients( + lambda req: httpx.Response( + 200, + text="data: first\n\ndata: second\n\n", + headers={"Content-Type": "text/event-stream; charset=utf-8"}, + ) + ) + sender = HttpRequestSender(lambda ref: {}) + with sender.stream(request(stream_protocol="sse")) as events: + assert next(events) == SSEEvent("first") + async with sender.astream(request(stream_protocol="sse")) as events: + assert [event async for event in events] == [ + SSEEvent("first"), + SSEEvent("second"), + ] + assert all(client.is_closed for client in owned) + + +@pytest.mark.parametrize( + "body,content_type,category", + [ + ("data: truncated", "text/event-stream", "SSE_INCOMPLETE_EVENT"), + ("data: " + "x" * 30, "text/event-stream", "SSE_EVENT_TOO_LARGE"), + ("data: wrong\n\n", "application/json", "HTTP_STREAM_FORMAT_INVALID"), + ], +) +@pytest.mark.asyncio +async def test_invalid_streams_fail_explicitly_and_close( + clients, body, content_type, category +): + owned = clients( + lambda req: httpx.Response( + 200, text=body, headers={"content-type": content_type} + ) + ) + sender = HttpRequestSender(lambda ref: {}, max_event_bytes=20) + with ( + pytest.raises(ModelTransportError, match=f"^{category}$"), + sender.stream(request(stream_protocol="sse")) as events, + ): + list(events) + with pytest.raises(ModelTransportError, match=f"^{category}$"): + async with sender.astream(request(stream_protocol="sse")) as events: + [event async for event in events] + assert all(client.is_closed for client in owned) + + +@pytest.mark.asyncio +async def test_whole_response_limits_are_not_silent_truncation(clients): + owned = clients(lambda req: httpx.Response(200, content=b"12345")) + sender = HttpRequestSender(lambda ref: {}, max_response_bytes=4) + with pytest.raises(ModelTransportError, match="^HTTP_RESPONSE_TOO_LARGE$"): + sender.invoke(request()) + with pytest.raises(ModelTransportError, match="^HTTP_RESPONSE_TOO_LARGE$"): + await sender.ainvoke(request()) + assert all(client.is_closed for client in owned) + + +def test_pre_cancelled_scope_does_not_resolve_credentials_or_send(): + scope = RunCancellationScope() + scope.cancel() + sender = HttpRequestSender( + lambda ref: pytest.fail("Credential resolution should not occur") + ) + with pytest.raises(RunTerminated): + sender.invoke(request(), scope=scope) + + +def test_bad_credential_callback_is_sanitized_before_creating_client(): + def resolver(ref): + raise ValueError("dummy-private") + + with pytest.raises(ModelTransportError, match="^CREDENTIAL_RESOLUTION_FAILED$"): + HttpRequestSender(resolver).invoke(request()) + with pytest.raises(ModelTransportError, match="^CREDENTIAL_HEADER_CONFLICT$"): + HttpRequestSender(lambda ref: {"Accept": "private"}).invoke( + request(headers={"Accept": "application/json"}) + ) + + +@pytest.mark.asyncio +async def test_scope_cancellation_interrupts_blocked_async_stream_and_closes(clients): + entered = asyncio.Event() + + class Blocked(httpx.AsyncByteStream): + closed = False + + async def __aiter__(self): + entered.set() + await asyncio.Event().wait() + yield b"unreachable" + + async def aclose(self): + self.closed = True + + stream = Blocked() + owned = clients( + lambda req: httpx.Response( + 200, stream=stream, headers={"content-type": "text/event-stream"} + ) + ) + scope = RunCancellationScope() + sender = HttpRequestSender(lambda ref: {}) + + async def run(): + async with sender.astream( + request(stream_protocol="sse"), scope=scope + ) as events: + return [event async for event in events] + + task = asyncio.create_task(run()) + await asyncio.wait_for(entered.wait(), 2) + scope.cancel() + with pytest.raises(RunTerminated): + await asyncio.wait_for(task, 2) + assert stream.closed + assert all(client.is_closed for client in owned) + assert scope._closers == {} + + +@pytest.mark.parametrize( + "changes", + [{"max_response_bytes": 0}, {"max_error_bytes": True}, {"max_event_bytes": -1}], +) +def test_sender_limits_require_positive_integers(changes): + with pytest.raises(ValueError, match="positive integers"): + HttpRequestSender(lambda ref: {}, **changes) + + +@pytest.mark.asyncio +async def test_closed_stream_iterators_cannot_emit_buffered_events(clients): + owned = clients( + lambda req: httpx.Response( + 200, + content=b"data: one\n\ndata: two\n\n", + headers={"content-type": "text/event-stream"}, + ) + ) + sender = HttpRequestSender(lambda ref: {}) + with sender.stream(request(stream_protocol="sse")) as sync_events: + assert next(sync_events) == SSEEvent("one") + assert list(sync_events) == [] + async with sender.astream(request(stream_protocol="sse")) as async_events: + assert await anext(async_events) == SSEEvent("one") + assert [event async for event in async_events] == [] + assert all(client.is_closed for client in owned) + + +def test_scope_cancel_stops_even_already_buffered_sync_events(clients): + owned = clients( + lambda req: httpx.Response( + 200, + content=b"data: one\n\ndata: two\n\n", + headers={"content-type": "text/event-stream"}, + ) + ) + sender = HttpRequestSender(lambda ref: {}) + scope = RunCancellationScope() + with ( + pytest.raises(RunTerminated), + sender.stream(request(stream_protocol="sse"), scope=scope) as events, + ): + assert next(events) == SSEEvent("one") + scope.cancel() + next(events) + assert all(client.is_closed for client in owned) + assert scope._closers == {} + + +@pytest.mark.asyncio +async def test_error_streams_preserve_status_and_private_body_without_replay(clients): + calls = [] + + def handler(req): + calls.append(req) + return httpx.Response(429, text="dummy-private") + + owned = clients(handler) + sender = HttpRequestSender(lambda ref: {}) + with ( + pytest.raises(ModelTransportError, match="^HTTP_STATUS_ERROR$") as sync_error, + sender.stream(request(stream_protocol="sse")), + ): + pytest.fail("Status error must precede iteration") + with pytest.raises(ModelTransportError, match="^HTTP_STATUS_ERROR$") as async_error: + async with sender.astream(request(stream_protocol="sse")): + pytest.fail("Status error must precede iteration") + assert ( + sync_error.value.response.body + == async_error.value.response.body + == b"dummy-private" + ) + assert len(calls) == 2 + assert all(client.is_closed for client in owned) + + +@pytest.mark.asyncio +async def test_tls_cause_is_classified_without_message_matching_or_reducing_verification( + clients, +): + import ssl + + def handler(req): + try: + raise ssl.SSLCertVerificationError("dummy-private") + except ssl.SSLError as cause: + raise httpx.ConnectError("dummy-private", request=req) from cause + + owned = clients(handler) + sender = HttpRequestSender(lambda ref: {}) + with pytest.raises(ModelTransportError, match="^HTTP_TLS_ERROR$"): + sender.invoke(request()) + with pytest.raises(ModelTransportError, match="^HTTP_TLS_ERROR$"): + await sender.ainvoke(request()) + assert all(client.is_closed for client in owned) + + +def test_sync_cancel_wakes_reader_but_owner_thread_closes_client(clients): + """Keep the socket descriptor valid until its blocking reader has unwound.""" + from concurrent.futures import ThreadPoolExecutor + from threading import Event + + ready, release, shutdown = Event(), Event(), Event() + scope = RunCancellationScope() + connection = Mock(spec=socket.socket) + connection.shutdown.side_effect = lambda _: shutdown.set() + stream = SimpleNamespace(get_extra_info=lambda _: connection, close=Mock()) + + class BlockingBody(httpx.SyncByteStream): + def __iter__(self): + ready.set() + assert release.wait(3) + yield b'{}' + + def handler(wire): + wire.extensions['trace']('connection.connect_tcp.complete', {'return_value': stream}) + return httpx.Response(200, stream=BlockingBody()) + + owned = clients(handler) + + def invoke(): + try: + HttpRequestSender(lambda _: {}).invoke(request(), scope=scope) + except RunTerminated: + return 'cancelled' + + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(invoke) + assert ready.wait(3) + try: + scope.cancel() + assert shutdown.is_set() + assert not owned[0].is_closed + finally: + release.set() + assert future.result(timeout=3) == 'cancelled' + assert owned[0].is_closed + connection.shutdown.assert_called_once_with(socket.SHUT_RDWR) + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize("structured", [False, True]) +@pytest.mark.asyncio +async def test_http_error_details_are_shared_and_redact_request_credentials(clients, asynchronous, streaming, structured): + from nexent.core.model_service.errors import normalize_model_error + + reason = "invalid top_p 1; expected 0.95; secret-key; header-secret" + def handler(wire): + if structured: + return httpx.Response(400, json={"error": {"message": reason, "param": "top_p"}}) + return httpx.Response(400, text=reason) + + clients(handler) + sender = HttpRequestSender(lambda _: {"authorization": "Bearer secret-key"}) + wire = request(headers={"x-private": "header-secret"}, stream_protocol="sse" if streaming else None) + with pytest.raises(ModelTransportError) as caught: + if asynchronous: + if streaming: + async with sender.astream(wire): + pytest.fail("HTTP failure must raise before yielding") + else: + await sender.ainvoke(wire) + elif streaming: + with sender.stream(wire): + pytest.fail("HTTP failure must raise before yielding") + else: + sender.invoke(wire) + error = normalize_model_error(caught.value, request_id="r1") + assert error.safe_message.startswith("(400) invalid top_p 1; expected 0.95;") + assert "secret-key" not in error.safe_message and "header-secret" not in error.safe_message + assert error.category == "MODEL_REQUEST_INVALID" and not error.retryable + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.asyncio +async def test_stream_error_frame_retains_credential_redaction_context(clients, asynchronous, openai_profile): + from nexent.core.model_service.adapters.openai_chat import OpenAIChatAdapter, ModelAdapterError + from nexent.core.model_service.errors import normalize_model_error + + from nexent.core.model_service.contracts import ModelMessage, ModelRequest + stream_request = ModelRequest(request_id="r1", outlet_id=openai_profile.outlet_id, + config_revision=openai_profile.config_revision, stream=True, messages=[ModelMessage(semantic_role="user")]) + payload = b'data: {"error":{"message":"invalid header-secret"}}\n\n' + clients(lambda _: httpx.Response(200, headers={"content-type": "text/event-stream"}, content=payload)) + sender = HttpRequestSender(lambda _: {"x-api-key": "header-secret"}) + wire = request(stream_protocol="sse") + adapter = OpenAIChatAdapter() + with pytest.raises(ModelAdapterError) as caught: + if asynchronous: + async with sender.astream(wire) as frames: + async for _ in adapter.adecode_stream(frames, stream_request, openai_profile): + pass + else: + with sender.stream(wire) as frames: + list(adapter.decode_stream(frames, stream_request, openai_profile)) + assert normalize_model_error(caught.value, request_id="r1").safe_message == "invalid [redacted]" diff --git a/test/sdk/core/model_service/test_legacy_configuration_export.py b/test/sdk/core/model_service/test_legacy_configuration_export.py new file mode 100644 index 0000000000..b85279e443 --- /dev/null +++ b/test/sdk/core/model_service/test_legacy_configuration_export.py @@ -0,0 +1,48 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Old persisted values are interpreted by the same pure functions used by Agent construction.""" + +from copy import deepcopy + +import pytest +from nexent.core.model_service.compatibility.legacy_config import legacy_agent_request_parameters + + +def test_export_preserves_null_sampling_custom_overrides_and_old_effort_selection(): + row = {"model_type": "llm", "base_url": "https://custom.invalid/v1", "model_factory": "custom", + "temperature": None, "top_p": 0, "extra_params": {"enable_thinking": True, + "reasoning_effort": "high", "__custom__": {"reasoning_effort": "medium", "flag": False}}} + before = deepcopy(row) + body = legacy_agent_request_parameters(row, model_id="exact", capability=None) + assert body == {"temperature": None, "top_p": 0, "enable_thinking": True, + "reasoning_effort": "medium", "flag": False} + assert row == before + + +def test_budget_range_uses_existing_clamp_and_alibaba_wire_mapping(): + row = {"model_type": "llm", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "model_factory": "dashscope", "temperature": 0.2, "top_p": None, + "extra_params": {"enable_thinking": True, "reasoning_budget_tokens": 8}} + capability = {"source": "models_dev", "status": "supported", "provider_id": "alibaba-cn", + "controls": [{"type": "budget_tokens", "min": 32, "max": 64}]} + assert legacy_agent_request_parameters(row, model_id="qwen3.5-plus", capability=capability) == { + "temperature": 0.2, "top_p": None, "enable_thinking": True, "thinking_budget": 32} + assert row["extra_params"]["reasoning_budget_tokens"] == 8 + + +def test_credential_columns_are_never_read_by_exporter(): + class Row(dict): + def get(self, key, *args): + assert key not in {"api_key", "access_token", "model_appid"} + return super().get(key, *args) + + row = Row(model_type="llm", model_factory="custom", base_url="https://custom.invalid/v1", extra_params=None) + assert legacy_agent_request_parameters(row, model_id="exact", capability=None) == { + "temperature": None, "top_p": None} + + +@pytest.mark.parametrize("row", [{"model_type": "vlm"}, {"model_type": "embedding"}, + {"model_type": "llm", "extra_params": []}]) +def test_out_of_scope_or_invalid_records_are_rejected(row): + with pytest.raises(ValueError): + legacy_agent_request_parameters(row, model_id="exact", capability=None) diff --git a/test/sdk/core/model_service/test_legacy_invocation_options.py b/test/sdk/core/model_service/test_legacy_invocation_options.py new file mode 100644 index 0000000000..31e3f75468 --- /dev/null +++ b/test/sdk/core/model_service/test_legacy_invocation_options.py @@ -0,0 +1,123 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Legacy aliases preserve declared bindings without changing persisted defaults.""" + +import pytest +from nexent.core.model_service.adapters.openai_chat import OpenAIChatAdapter +from nexent.core.model_service.compatibility.invocation_options import ( + LegacyInvocationOptions, +) +from nexent.core.model_service.contracts import ModelMessage, ModelRequest +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile +from smolagents.models import CODEAGENT_RESPONSE_FORMAT + + +@pytest.fixture +def declared_profile(openai_profile): + snapshot = openai_profile.model_dump() + for key, schema in ( + ("response_format", {"type": "object", "required": ["type"], "properties": { + "type": {"enum": ["json_object", "json_schema"]}, + }}), + ): + snapshot["parameter_rules"].append({ + "key": key, "schema": schema, "visible": False, "editable": True, + "required": False, "allowed_scopes": ["outlet"], "ui_group": "legacy", + "owner": "provider", "source": {"ref": "controlled_protocol_fixture", "version": 1}, + "api_binding": {"path": "/" + key, "transform_ref": "identity_v1", "transform_version": 1}, + }) + return ResolvedModelProfile.model_validate(snapshot) + + +def test_code_stop_and_structured_format_use_existing_adapter_bindings(declared_profile): + stops = ["Observation:", "Calling tools:"] + profile = LegacyInvocationOptions({}, stops, CODEAGENT_RESPONSE_FORMAT).resolve(declared_profile) + request = ModelRequest( + request_id="legacy", outlet_id=profile.outlet_id, config_revision=profile.config_revision, + messages=[ModelMessage(semantic_role="user", content_parts=[{"type": "text", "text": "code"}])], + ) + body = OpenAIChatAdapter().prepare_http_request(request, profile).json_body + assert body["stop"] == stops and body["response_format"] == CODEAGENT_RESPONSE_FORMAT + assert "stop" not in declared_profile.parameter_values + assert "response_format" not in declared_profile.parameter_values + stops.append("changed") + assert profile.parameter_values["stop"] == ["Observation:", "Calling tools:"] + + +def test_none_aliases_preserve_saved_defaults_and_absence(declared_profile): + snapshot = declared_profile.model_dump() + snapshot["parameter_values"]["stop"] = ["saved"] + original = ResolvedModelProfile.model_validate(snapshot) + result = LegacyInvocationOptions({}).resolve(original) + assert result.parameter_values["stop"] == ["saved"] + assert "response_format" not in result.parameter_values + + +@pytest.mark.parametrize("stops", [["x"], []]) +def test_identical_duplicate_alias_is_allowed_but_conflict_rejected(declared_profile, stops): + result = LegacyInvocationOptions({"stop": stops}, stops).resolve(declared_profile) + assert result.parameter_values["stop"] == stops + with pytest.raises(ParameterValidationError, match="LEGACY_PARAMETER_CONFLICT"): + LegacyInvocationOptions({"stop": ["other"]}, stops).resolve(declared_profile) + + +@pytest.mark.parametrize("options", [ + LegacyInvocationOptions({}, [1]), LegacyInvocationOptions({}, [""]), + LegacyInvocationOptions({}, response_format={"type": "arbitrary"}), + LegacyInvocationOptions({"messages": []}), +]) +def test_invalid_or_undeclared_options_are_rejected(declared_profile, options): + with pytest.raises(ParameterValidationError): + options.resolve(declared_profile) + + +def test_protocol_capability_does_not_invent_stop_or_format_rules(openai_profile): + data = openai_profile.model_dump() + data["parameter_rules"] = [rule for rule in data["parameter_rules"] if rule["key"] != "stop"] + openai_profile = ResolvedModelProfile.model_validate(data) + for options in (LegacyInvocationOptions({}, ["stop"]), LegacyInvocationOptions({}, response_format={"type": "json_object"})): + with pytest.raises(ParameterValidationError, match="PARAMETER_UNKNOWN"): + options.resolve(openai_profile) + + +@pytest.mark.parametrize("stops", [["Observation:", "Calling tools:"], [""], "END", []]) +def test_builtin_profile_accepts_actual_code_and_planning_stops(openai_profile, stops): + profile = LegacyInvocationOptions({}, stop_sequences=stops).resolve(openai_profile) + request = ModelRequest(request_id="builtin-stop", outlet_id=profile.outlet_id, + config_revision=profile.config_revision, + messages=[ModelMessage(semantic_role="user", content_parts=[{"type": "text", "text": "hello"}])]) + assert OpenAIChatAdapter().prepare_http_request(request, profile).json_body["stop"] == stops + assert "stop" not in openai_profile.parameter_values + + +@pytest.mark.parametrize("stops", [["ok", 1], [""], "", {"stop": "END"}]) +def test_builtin_stop_rule_rejects_invalid_code_values(openai_profile, stops): + with pytest.raises(ParameterValidationError): + LegacyInvocationOptions({}, stop_sequences=stops).resolve(openai_profile) + + +def test_builtin_qwen35_does_not_claim_code_json_schema_support(openai_profile): + with pytest.raises(ParameterValidationError, match="PARAMETER_UNKNOWN"): + LegacyInvocationOptions({}, response_format=CODEAGENT_RESPONSE_FORMAT).resolve(openai_profile) + + +@pytest.mark.parametrize("custom", [[], 1, {1: True}, {"": True}]) +def test_invalid_legacy_custom_container_rejected(declared_profile, custom): + with pytest.raises(ParameterValidationError, match="LEGACY_CUSTOM_PARAMETERS_INVALID"): + LegacyInvocationOptions({}, custom_overrides=custom).resolve(declared_profile) + + +def test_legacy_custom_overrides_preserve_false_zero_and_explicit_removal(declared_profile): + data = declared_profile.model_dump() + data["extra_api_parameters"] = [{"path": "/old", "value": "remove"}] + original = ResolvedModelProfile.model_validate(data) + updated = LegacyInvocationOptions({}, custom_overrides={"old": None, "flag": False, "count": 0}).resolve(original) + assert updated.extra_api_parameters == [{"path": "/flag", "value": False}, {"path": "/count", "value": 0}] + assert original.extra_api_parameters == [{"path": "/old", "value": "remove"}] + + +@pytest.mark.parametrize("custom", [{"messages": []}, {"api_key": "blocked"}, {"enable_thinking": False}]) +def test_legacy_custom_cannot_override_protected_or_declared_paths(declared_profile, custom): + with pytest.raises(ParameterValidationError): + LegacyInvocationOptions({}, custom_overrides=custom).resolve(declared_profile) diff --git a/test/sdk/core/model_service/test_legacy_reasoning_export.py b/test/sdk/core/model_service/test_legacy_reasoning_export.py new file mode 100644 index 0000000000..2d7ecd0a55 --- /dev/null +++ b/test/sdk/core/model_service/test_legacy_reasoning_export.py @@ -0,0 +1,56 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Migration reads legacy request fields without initializing inference clients.""" + +from copy import deepcopy + +import pytest +from nexent.core.model_service.compatibility.legacy_reasoning import ( + legacy_reasoning_wire_parameters, +) + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_deepseek_explicit_toggle_uses_existing_thinking_object(enabled): + extra = {"enable_thinking": enabled, "custom": {"flag": False}} + before = deepcopy(extra) + result = legacy_reasoning_wire_parameters(model_id="deepseek-chat", base_url="https://api.deepseek.com/v1", + model_factory="deepseek", capability={"source": "models_dev", "status": "supported", "provider_id": "deepseek"}, + extra_body=extra) + assert result == {"thinking": {"type": "enabled" if enabled else "disabled"}, "custom": {"flag": False}} + assert extra == before + + +def test_alibaba_numeric_budget_keeps_legacy_priority_over_effort(): + result = legacy_reasoning_wire_parameters(model_id="qwen3.5-plus", + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", model_factory="alibaba-cn", + capability={"source": "models_dev", "status": "supported", "provider_id": "alibaba-cn", + "controls": [{"type": "budget_tokens"}]}, + effort="high", budget=64, extra_body={"enable_thinking": True}) + assert result == {"enable_thinking": True, "thinking_budget": 64} + assert "reasoning_effort" not in result + + +def test_self_hosted_qwen_retains_old_chat_template_mapping(): + result = legacy_reasoning_wire_parameters(model_id="Qwen/ExactModel", base_url="https://custom.invalid/v1", + model_factory="OpenAI-API-Compatible", extra_body={"enable_thinking": False, + "chat_template_kwargs": {"custom": 0}}) + assert result == {"chat_template_kwargs": {"custom": 0, "enable_thinking": False}} + + +def test_custom_body_override_wins_exactly_as_in_openai_client_merge(): + result = legacy_reasoning_wire_parameters(model_id="custom", base_url="https://custom.invalid/v1", + model_factory="custom", effort="high", extra_body={"reasoning_effort": "medium", "custom": [False, 0]}) + assert result == {"reasoning_effort": "medium", "custom": [False, 0]} + + +def test_export_does_not_allocate_http_clients_or_modify_input(monkeypatch): + import httpx + + monkeypatch.setattr(httpx, "Client", lambda *args, **kwargs: pytest.fail("Migration must not allocate HTTP clients")) + monkeypatch.setattr(httpx, "AsyncClient", lambda *args, **kwargs: pytest.fail("Migration must not allocate HTTP clients")) + body = {"nested": {"values": [False, 0]}} + result = legacy_reasoning_wire_parameters(model_id="custom", base_url="https://custom.invalid/v1", + model_factory="custom", extra_body=body) + result["nested"]["values"].append("changed") + assert body == {"nested": {"values": [False, 0]}} diff --git a/test/sdk/core/model_service/test_openai_chat_adapter.py b/test/sdk/core/model_service/test_openai_chat_adapter.py new file mode 100644 index 0000000000..b87bae1837 --- /dev/null +++ b/test/sdk/core/model_service/test_openai_chat_adapter.py @@ -0,0 +1,464 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Isolated request/response protocol proof, without sending or executing tools.""" + +import json +from uuid import UUID + +import pytest +from nexent.core.model_service.adapters.openai_chat import ( + ModelAdapterError, + OpenAIChatAdapter, +) +from nexent.core.model_service.contracts import ( + ModelMessage, + ModelRequest, + ToolCall, + ToolDefinition, +) +from nexent.core.model_service.http_contracts import RawHttpResponse + + +@pytest.fixture +def profile(openai_profile): + return openai_profile + + +def request(profile, **changes): + return ModelRequest( + **( + { + "request_id": "r1", + "outlet_id": profile.outlet_id, + "config_revision": profile.config_revision, + "messages": [ + ModelMessage( + semantic_role="user_input", + content_parts=[{"type": "text", "text": "Hello"}], + ) + ], + } + | changes + ) + ) + + +def response(message=None, finish="stop", **changes): + body = { + "id": "response-1", + "model": "exact-response-model", + "choices": [ + { + "index": 0, + "message": message + if message is not None + else {"role": "assistant", "content": "Hello"}, + "finish_reason": finish, + } + ], + } + body.update(changes) + return RawHttpResponse(200, {}, json.dumps(body).encode()) + + +def test_prepare_exact_profile_parameters_roles_and_media_without_mutation(profile): + media = [ + {"type": "text", "text": "View", "cache_control": {"type": "ephemeral"}}, + {"type": "image_url", "image_url": {"url": "https://example.test/image.png"}}, + ] + req = request( + profile, + messages=[ + ModelMessage( + semantic_role="platform_instruction", + content_parts=[{"type": "text", "text": "rules"}], + ), + ModelMessage(semantic_role="user_input", content_parts=media), + ], + ) + prepared = OpenAIChatAdapter().prepare_http_request(req, profile) + body = json.loads(prepared.body_bytes) + assert prepared.url == "https://example.test/v1/chat/completions" + assert body["model"] == "qwen3.5-plus" + assert body["messages"] == [ + {"role": "system", "content": "rules"}, + {"role": "user", "content": media}, + ] + assert body["enable_thinking"] is False + assert req.messages[1].content_parts == media + assert "api_key" not in body + + +def test_multiple_text_parts_and_cache_metadata_remain_distinct(profile): + parts = [{"type": "text", "text": "one"}, {"type": "text", "text": "two"}] + req = request( + profile, messages=[ModelMessage(semantic_role="user", content_parts=parts)] + ) + assert ( + json.loads(OpenAIChatAdapter().prepare_http_request(req, profile).body_bytes)[ + "messages" + ][0]["content"] + == parts + ) + + +def test_decode_separates_content_reasoning_refusal_and_usage_without_double_counting( + profile, +): + raw = response( + { + "role": "assistant", + "content": "body", + "reasoning_content": "thought", + "refusal": "refused", + "extra_field": "preserved", + }, + usage={ + "prompt_tokens": 10, + "completion_tokens": 3, + "total_tokens": 13, + "prompt_tokens_details": {"cached_tokens": 4}, + "completion_tokens_details": {"reasoning_tokens": 2}, + }, + ) + result = OpenAIChatAdapter().decode_response(raw, request(profile), profile) + assert result.content_parts == [{"type": "text", "text": "body"}] + assert result.reasoning_parts == [{"type": "text", "text": "thought"}] + assert result.refusal == "refused" + assert result.usage.input_tokens == 10 and result.usage.output_tokens == 3 + assert ( + result.usage.total_tokens == 13 + and result.usage.cached_input_tokens == 4 + and result.usage.reasoning_tokens == 2 + ) + assert result.provider_metadata["unmapped_message_fields"] == { + "extra_field": "preserved" + } + assert "thought" not in repr(result) + assert "body" not in repr(result) + + +def test_unknown_finish_and_missing_usage_stay_unknown(profile): + result = OpenAIChatAdapter().decode_response( + response(finish="new_provider_finish"), request(profile), profile + ) + assert result.finish_reason == "new_provider_finish" + assert result.usage.origin == "unknown" and result.usage.total_tokens is None + partial = OpenAIChatAdapter().decode_response( + response(usage={"prompt_tokens": 0}), request(profile), profile + ) + assert partial.usage.input_tokens == 0 + assert partial.usage.total_tokens is None + + +@pytest.mark.parametrize( + "arguments,parsed,error", + [ + ("{}", {}, None), + ('{"value":0,"flag":false}', {"value": 0, "flag": False}, None), + ("[1]", None, "INVALID_TOOL_ARGUMENTS"), + ("{broken", None, "INVALID_TOOL_ARGUMENTS"), + ('{"x":1,"x":2}', None, "INVALID_TOOL_ARGUMENTS"), + ], +) +def test_complete_tools_preserve_raw_provider_ids_and_invalid_arguments( + profile, arguments, parsed, error +): + raw = response( + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "provider-call-id-longer-than-local-uuid-" * 2, + "type": "function", + "function": {"name": "search", "arguments": arguments}, + } + ], + }, + finish="tool_calls", + ) + result = OpenAIChatAdapter().decode_response(raw, request(profile), profile) + call = result.tool_calls[0] + assert isinstance(call.local_call_id, UUID) + assert call.provider_call_id == "provider-call-id-longer-than-local-uuid-" * 2 + assert ( + call.raw_arguments == arguments + and call.parsed_arguments == parsed + and call.argument_error == error + ) + assert result.content_parts == [] + + +@pytest.mark.parametrize( + "usage", + [ + {"prompt_tokens": True}, + {"completion_tokens": -1}, + {"total_tokens": 2.0}, + {"prompt_tokens_details": []}, + {"completion_tokens_details": {"reasoning_tokens": "2"}}, + False, + ], +) +def test_invalid_usage_is_not_coerced_or_echoed(profile, usage): + with pytest.raises(ModelAdapterError, match="^MODEL_USAGE_INVALID$"): + OpenAIChatAdapter().decode_response( + response(usage=usage), request(profile), profile + ) + + +@pytest.mark.parametrize( + "body,category", + [ + (b'{"choices":[]}', "MODEL_RESPONSE_INVALID"), + (b'{"choices":[],"choices":[]}', "MODEL_RESPONSE_INVALID"), + ( + b'{"choices":[{"index":0,"message":{"role":"assistant"},"finish_reason":null}]}', + "MODEL_RESPONSE_INCOMPLETE", + ), + ( + b'{"choices":[{"index":false,"message":{"role":"assistant"},"finish_reason":"stop"}]}', + "MODEL_RESPONSE_INVALID", + ), + ( + b'{"choices":[{"index":0,"message":{"role":"user"},"finish_reason":"stop"}]}', + "MODEL_RESPONSE_INVALID", + ), + ( + b'{"choices":[{"index":0,"message":{"role":"assistant","tool_calls":false},"finish_reason":"stop"}]}', + "MODEL_TOOL_CALL_INVALID", + ), + (b"dummy-private", "MODEL_RESPONSE_INVALID"), + (b"\xff", "MODEL_RESPONSE_INVALID"), + ], +) +def test_invalid_response_is_explicit_and_sanitized(profile, body, category): + with pytest.raises(ModelAdapterError, match=f"^{category}$") as caught: + OpenAIChatAdapter().decode_response( + RawHttpResponse(200, {}, body), request(profile), profile + ) + assert caught.value.__cause__ is None + + +@pytest.mark.parametrize( + "role,category", + [("developer", "MODEL_ROLE_UNSUPPORTED"), ("tool", "MODEL_MESSAGE_INVALID")], +) +def test_unsupported_roles_or_missing_tool_link_are_not_silently_rewritten( + profile, role, category +): + with pytest.raises(ModelAdapterError, match=f"^{category}$"): + OpenAIChatAdapter().prepare_http_request( + request(profile, messages=[ModelMessage(semantic_role=role)]), profile + ) + + +def test_stale_snapshot_cannot_prepare_request(profile): + with pytest.raises(ModelAdapterError, match="^MODEL_SNAPSHOT_MISMATCH$"): + OpenAIChatAdapter().prepare_http_request( + request(profile, config_revision=2), profile + ) + + +@pytest.mark.parametrize("ref,version", [("other_protocol", 1), ("openai_chat_completions", 2)]) +def test_protocol_reference_must_match_the_registered_adapter(profile, ref, version): + snapshot = profile.model_dump() + snapshot["bindings"]["protocol"] = {"ref": ref, "version": version} + mismatched = type(profile).model_validate(snapshot) + with pytest.raises(ModelAdapterError, match="^MODEL_PROTOCOL_UNSUPPORTED$"): + OpenAIChatAdapter().prepare_http_request(request(mismatched), mismatched) + + +def test_unknown_native_tool_support_does_not_try_native_protocol(profile): + snapshot = profile.model_dump() + snapshot["capabilities"]["tools"]["status"] = "unknown" + unknown = type(profile).model_validate(snapshot) + with pytest.raises(ModelAdapterError, match="^NATIVE_TOOL_PROTOCOL_UNAVAILABLE$"): + OpenAIChatAdapter().prepare_http_request( + request( + unknown, + tools=[ + ToolDefinition( + name="search", + description="test", + input_schema={"type": "object"}, + ) + ], + ), + unknown, + ) + + +@pytest.mark.parametrize("status", ["unknown", "unsupported"]) +@pytest.mark.parametrize("history_kind", ["call", "result"]) +def test_native_history_cannot_bypass_capability_check_without_tool_definitions( + profile, status, history_kind +): + snapshot = profile.model_dump() + snapshot["capabilities"]["tools"]["status"] = status + unavailable = type(profile).model_validate(snapshot) + call = ToolCall( + provider_call_id="provider-original", index=0, name="search", raw_arguments="{}" + ) + message = ( + ModelMessage(semantic_role="assistant_history", tool_calls=[call]) + if history_kind == "call" + else ModelMessage( + semantic_role="tool_result", + tool_call_id="provider-original", + content_parts=[{"type": "text", "text": "result"}], + ) + ) + req = request(unavailable, messages=[message]) + assert req.tools == [] + with pytest.raises(ModelAdapterError, match="^NATIVE_TOOL_PROTOCOL_UNAVAILABLE$"): + OpenAIChatAdapter().prepare_http_request(req, unavailable) + assert req.messages == [message] + + +def test_tool_history_keeps_exact_links_and_reasoning_replay_requires_declared_adapter( + profile, +): + snapshot = profile.model_dump() + snapshot["capabilities"]["tools"]["status"] = "supported" + profile = type(profile).model_validate(snapshot) + call = ToolCall( + provider_call_id="provider-original", + index=0, + name="search", + raw_arguments='{"query":"x"}', + parsed_arguments={"query": "x"}, + ) + messages = [ + ModelMessage( + semantic_role="assistant_history", + tool_calls=[call], + reasoning_parts=[{"type": "text", "text": "thought"}], + ), + ModelMessage( + semantic_role="tool_result", + tool_call_id="provider-original", + content_parts=[{"type": "text", "text": "result"}], + ), + ] + req = request(profile, messages=messages) + with pytest.raises(ModelAdapterError, match="^MODEL_REASONING_FORMAT_UNSUPPORTED$"): + OpenAIChatAdapter().prepare_http_request(req, profile) + body = json.loads( + OpenAIChatAdapter(reasoning_replay=True) + .prepare_http_request(req, profile) + .body_bytes + ) + assert body["messages"][0]["tool_calls"][0]["id"] == "provider-original" + assert body["messages"][0]["reasoning_content"] == "thought" + assert body["messages"][0]["content"] is None + assert body["messages"][1] == { + "role": "tool", + "tool_call_id": "provider-original", + "content": "result", + } + + +def test_native_tool_definitions_are_only_wire_schemas_and_stream_usage_is_requested( + profile, +): + snapshot = profile.model_dump() + snapshot["capabilities"]["tools"]["status"] = "supported" + supported = type(profile).model_validate(snapshot) + tool = ToolDefinition( + name="search", + description="Search", + input_schema={"type": "object", "properties": {"query": {"type": "string"}}}, + ) + prepared = OpenAIChatAdapter().prepare_http_request( + request(supported, tools=[tool], stream=True), supported + ) + body = json.loads(prepared.body_bytes) + assert body["tools"] == [ + { + "type": "function", + "function": { + "name": "search", + "description": "Search", + "parameters": tool.input_schema, + }, + } + ] + assert body["stream_options"] == {"include_usage": True} + assert prepared.stream_protocol == "sse" + + +@pytest.mark.parametrize("arguments", ['{"x":NaN}', '{"x":1e1000}']) +def test_nonfinite_tool_arguments_are_not_treated_as_valid_json(profile, arguments): + raw = response( + { + "role": "assistant", + "tool_calls": [ + { + "id": "call", + "type": "function", + "function": {"name": "search", "arguments": arguments}, + } + ], + }, + finish="tool_calls", + ) + call = ( + OpenAIChatAdapter() + .decode_response(raw, request(profile), profile) + .tool_calls[0] + ) + assert call.argument_error == "INVALID_TOOL_ARGUMENTS" + assert call.parsed_arguments is None + assert call.raw_arguments == arguments + + +def test_duplicate_complete_tool_ids_are_explicit_protocol_failure(profile): + call = { + "id": "same", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } + with pytest.raises(ModelAdapterError, match="^MODEL_RESPONSE_INVALID$"): + OpenAIChatAdapter().decode_response( + response( + {"role": "assistant", "tool_calls": [call, call]}, finish="tool_calls" + ), + request(profile), + profile, + ) + + +@pytest.mark.parametrize( + "part", [{"type": "unsupported", "text": "x"}, {"type": "text", "text": 1}] +) +def test_unsupported_or_invalid_content_is_not_silently_flattened(profile, part): + with pytest.raises(ModelAdapterError, match="^MODEL_CONTENT_UNSUPPORTED$"): + OpenAIChatAdapter().prepare_http_request( + request( + profile, + messages=[ModelMessage(semantic_role="user", content_parts=[part])], + ), + profile, + ) + + +@pytest.mark.parametrize("status", ["unknown", "unsupported", "supported"]) +@pytest.mark.parametrize("key,value", [("temperature", 0.7), ("top_p", 1)]) +def test_sampling_config_is_preserved_but_explicitly_unsupported_values_are_not_sent(profile, status, key, value): + from nexent.core.model_service.profiles.protocol_parameters import ensure_outlet_sampling_rules + from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile + + data = ensure_outlet_sampling_rules(profile).model_dump(mode="json") + data["capabilities"]["sampling"][key]["status"] = status + data["parameter_values"][key] = value + current = ResolvedModelProfile.model_validate(data) + before = current.model_dump(mode="json") + body = OpenAIChatAdapter().prepare_http_request(request(current), current).json_body + if status == "unsupported": + assert key not in body + else: + assert body[key] == value + assert current.model_dump(mode="json") == before + assert current.parameter_values[key] == value diff --git a/test/sdk/core/model_service/test_openai_stream.py b/test/sdk/core/model_service/test_openai_stream.py new file mode 100644 index 0000000000..aa3b2c55c0 --- /dev/null +++ b/test/sdk/core/model_service/test_openai_stream.py @@ -0,0 +1,413 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Controlled stream state without network, retry or tool execution.""" + +import json + +import pytest +from nexent.core.model_service.adapters.openai_chat import ( + ModelAdapterError, + OpenAIChatAdapter, +) +from nexent.core.model_service.adapters.openai_stream import OpenAIStreamDecoder +from nexent.core.model_service.contracts import ModelMessage, ModelRequest +from nexent.core.model_service.sse import SSEEvent + + +@pytest.fixture +def stream_request(openai_profile): + return ModelRequest( + request_id="r1", + outlet_id=openai_profile.outlet_id, + config_revision=openai_profile.config_revision, + messages=[ + ModelMessage( + semantic_role="user", content_parts=[{"type": "text", "text": "hello"}] + ) + ], + stream=True, + ) + + +def frame(delta=None, finish=None, **extra): + body = { + "id": "response-id", + "model": "provider-exact", + "choices": [{"index": 0, "delta": delta or {}, "finish_reason": finish}], + } + return SSEEvent(json.dumps(body | extra)) + + +def tool(index, name=None, arguments=None, call_id=None): + raw = {"index": index, "function": {}} + if name is not None: + raw["function"]["name"] = name + if arguments is not None: + raw["function"]["arguments"] = arguments + if call_id is not None: + raw["id"] = call_id + return raw + + +def test_interleaved_reasoning_text_and_tools_complete_with_stable_ids( + openai_profile, stream_request +): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + events = decoder.push(frame({"role": "assistant", "reasoning_content": "thinking"})) + events += decoder.push( + frame( + { + "content": "body", + "tool_calls": [ + tool(2, "sea", '{"q":', "call-two"), + tool(0, "calc", '{"n":', "call-zero"), + ], + } + ) + ) + events += decoder.push( + frame( + { + "tool_calls": [ + tool(0, arguments="0}"), + tool(2, name="rch", arguments='"x"}'), + ] + }, + finish="tool_calls", + ) + ) + assert not any(event.type == "message_completed" for event in events) + assert all( + event.tool_call_delta is None + or not hasattr(event.tool_call_delta, "parsed_arguments") + for event in events + ) + events += decoder.push( + SSEEvent( + json.dumps( + { + "choices": [], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 4, + "total_tokens": 14, + }, + } + ) + ) + ) + events += decoder.push(SSEEvent("[DONE]")) + assert [event.sequence for event in events] == list(range(1, len(events) + 1)) + assert len({event.message_id for event in events}) == 1 + assert events[0].type == "reasoning_delta" and events[0].delta == "thinking" + assert events[1].type == "text_delta" and events[1].content_kind == "text" + complete = events[-1].message + assert ( + events[-1].type == "message_completed" + and complete.message_id == events[-1].message_id + ) + assert complete.reasoning_parts == [{"type": "text", "text": "thinking"}] + assert complete.content_parts == [{"type": "text", "text": "body"}] + assert [ + (call.index, call.name, call.provider_call_id, call.parsed_arguments) + for call in complete.tool_calls + ] == [(0, "calc", "call-zero", {"n": 0}), (2, "search", "call-two", {"q": "x"})] + ids = { + event.tool_call_delta.index: event.tool_call_delta.local_call_id + for event in events + if event.type == "tool_call_delta" + } + assert all(call.local_call_id == ids[call.index] for call in complete.tool_calls) + assert complete.usage.total_tokens == 14 + assert decoder.finish() == [] + with pytest.raises(ModelAdapterError, match="^MODEL_STREAM_CLOSED$"): + decoder.push(frame({"content": "late"})) + + +def test_refusal_and_unknown_delta_do_not_become_body_or_tool_calls( + openai_profile, stream_request +): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + events = decoder.push( + frame( + {"refusal": "no", "future_field": {"data": "retained"}}, + finish="content_filter", + ) + ) + assert events[0].content_kind == "refusal" + complete = decoder.push(SSEEvent("[DONE]"))[0].message + assert ( + complete.refusal == "no" + and complete.content_parts == [] + and complete.tool_calls == [] + ) + assert complete.provider_metadata["unmapped_delta_fields"] == [ + {"future_field": {"data": "retained"}} + ] + + +@pytest.mark.parametrize( + "mode", + [ + "no_finish", + "only_done", + "tool_missing_name", + "tool_missing_id", + "tool_finish_without_calls", + ], +) +def test_incomplete_stream_never_promotes_tools_to_completion( + openai_profile, stream_request, mode +): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + if mode == "no_finish": + assert decoder.push(frame({"content": "partial"}))[0].type == "text_delta" + elif mode.startswith("tool_missing"): + decoder.push( + frame( + { + "tool_calls": [ + tool( + 0, + name="search" if mode.endswith("id") else None, + arguments="{}", + call_id="id" if mode.endswith("name") else None, + ) + ] + }, + finish="tool_calls", + ) + ) + elif mode == "tool_finish_without_calls": + decoder.push(frame({}, finish="tool_calls")) + with pytest.raises(ModelAdapterError): + if mode == "no_finish": + decoder.finish() + else: + decoder.push(SSEEvent("[DONE]")) + with pytest.raises(ModelAdapterError, match="^MODEL_STREAM_CLOSED$"): + decoder.push(frame({"content": "repair"}, finish="stop")) + + +def test_explicit_optional_done_still_requires_finish(openai_profile, stream_request): + decoder = OpenAIStreamDecoder(stream_request, openai_profile, require_done=False) + decoder.push(frame({"content": "complete"}, finish="provider_new_finish")) + complete = decoder.finish()[0] + assert complete.message.finish_reason == "provider_new_finish" + assert complete.message.usage.total_tokens is None + missing = OpenAIStreamDecoder(stream_request, openai_profile, require_done=False) + with pytest.raises(ModelAdapterError, match="^MODEL_RESPONSE_INCOMPLETE$"): + missing.finish() + + +@pytest.mark.parametrize( + "delta", + [ + {"content": 2}, + {"reasoning_content": []}, + {"role": "user"}, + {"tool_calls": False}, + {"tool_calls": [{"index": False}]}, + {"tool_calls": [{"index": 0, "function": {"arguments": 1}}]}, + ], +) +def test_invalid_delta_types_are_not_coerced(openai_profile, stream_request, delta): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + with pytest.raises(ModelAdapterError): + decoder.push(frame(delta)) + + +@pytest.mark.parametrize( + "data", + [ + '{"choices":[],"choices":[]}', + '{"choices":[false]}', + '{"choices":[],"x":NaN}', + "dummy-private", + "[]", + ], +) +def test_invalid_json_and_envelope_are_sanitized(openai_profile, stream_request, data): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + with pytest.raises(ModelAdapterError, match="^MODEL_RESPONSE_INVALID$") as caught: + decoder.push(SSEEvent(data)) + assert "dummy-private" not in str(caught.value) + + +def test_identity_conflicts_and_post_finish_delta_fail(openai_profile, stream_request): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + decoder.push(frame({"content": "visible"})) + with pytest.raises(ModelAdapterError, match="^MODEL_STREAM_IDENTITY_CONFLICT$"): + decoder.push(frame({"content": "different"}, id="other-response")) + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + decoder.push(frame({}, finish="stop")) + with pytest.raises(ModelAdapterError, match="^MODEL_STREAM_AFTER_FINISH$"): + decoder.push(frame({"content": "late"})) + + +def test_tool_id_conflict_is_not_a_second_execution_identity( + openai_profile, stream_request +): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + decoder.push(frame({"tool_calls": [tool(0, "search", "{", "original")]})) + with pytest.raises(ModelAdapterError, match="^MODEL_STREAM_IDENTITY_CONFLICT$"): + decoder.push(frame({"tool_calls": [tool(0, call_id="other")]})) + + +def test_total_stream_and_tool_limits_cannot_be_bypassed_by_small_chunks( + openai_profile, stream_request +): + sample = frame({"content": "中"}) + decoder = OpenAIStreamDecoder( + stream_request, openai_profile, max_message_bytes=len(sample.data.encode()) * 2 + ) + decoder.push(sample) + decoder.push(sample) + with pytest.raises(ModelAdapterError, match="^MODEL_RESPONSE_TOO_LARGE$"): + decoder.push(sample) + decoder = OpenAIStreamDecoder(stream_request, openai_profile, max_tool_calls=1) + decoder.push(frame({"tool_calls": [tool(100000, "one", "{}", "one")]})) + with pytest.raises(ModelAdapterError, match="^MODEL_TOOL_CALL_LIMIT$"): + decoder.push(frame({"tool_calls": [tool(2, "two", "{}", "two")]})) + + +def test_duplicate_usage_is_explicit_without_replacing_reported_values( + openai_profile, stream_request +): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + first = decoder.push(frame({}, finish="stop", usage={"prompt_tokens": 0})) + assert first[0].usage.input_tokens == 0 + with pytest.raises(ModelAdapterError, match="^MODEL_USAGE_CONFLICT$"): + decoder.push( + SSEEvent(json.dumps({"choices": [], "usage": {"prompt_tokens": 1}})) + ) + + +@pytest.mark.parametrize( + "options", [{"require_done": 1}, {"max_message_bytes": 0}, {"max_tool_calls": True}] +) +def test_invalid_limits_or_protocol_declarations_fail( + openai_profile, stream_request, options +): + with pytest.raises(ValueError): + OpenAIStreamDecoder(stream_request, openai_profile, **options) + + +@pytest.mark.asyncio +async def test_sync_async_adapter_interfaces_share_ordered_completion_and_stop_at_done( + openai_profile, stream_request +): + frames = [frame({"content": "body"}, finish="stop"), SSEEvent("[DONE]")] + + def sync_frames(): + yield from frames + pytest.fail("The adapter should stop at the declared completion marker") + + async def async_frames(): + for item in frames: + yield item + pytest.fail("The adapter should stop at the declared completion marker") + + adapter = OpenAIChatAdapter() + sync = list(adapter.decode_stream(sync_frames(), stream_request, openai_profile)) + asynchronous = [ + event + async for event in adapter.adecode_stream( + async_frames(), stream_request, openai_profile + ) + ] + assert ( + [event.type for event in sync] + == [event.type for event in asynchronous] + == ["text_delta", "message_completed"] + ) + assert ( + sync[-1].message.content_parts + == asynchronous[-1].message.content_parts + == [{"type": "text", "text": "body"}] + ) + + +@pytest.mark.asyncio +async def test_both_adapter_interfaces_report_truncated_stream( + openai_profile, stream_request +): + adapter = OpenAIChatAdapter() + with pytest.raises(ModelAdapterError, match="^MODEL_RESPONSE_INCOMPLETE$"): + list( + adapter.decode_stream( + [frame({"content": "partial"})], stream_request, openai_profile + ) + ) + + async def partial(): + yield frame({"content": "partial"}) + + with pytest.raises(ModelAdapterError, match="^MODEL_RESPONSE_INCOMPLETE$"): + [ + event + async for event in adapter.adecode_stream( + partial(), stream_request, openai_profile + ) + ] + + +def test_duplicate_usage_and_normal_eof_complete_once(openai_profile, stream_request): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + usage = {"prompt_tokens": 2, "completion_tokens": 3, "total_tokens": 5} + events = decoder.push(frame({"content": "answer"}, finish="stop", usage=usage)) + events += decoder.push(SSEEvent(json.dumps({"choices": [], "usage": usage}))) + events += decoder.finish() + assert [event.type for event in events].count("usage") == 1 + assert [event.type for event in events].count("message_completed") == 1 + assert events[-1].message.usage.total_tokens == 5 + + +def test_explicit_strict_done_still_rejects_eof(openai_profile, stream_request): + decoder = OpenAIStreamDecoder(stream_request, openai_profile, require_done=True) + decoder.push(frame({"content": "answer"}, finish="stop")) + with pytest.raises(ModelAdapterError, match="MODEL_RESPONSE_INCOMPLETE"): + decoder.finish() + + +def test_vllm_023_user_sample_accepts_empty_delta_usage_trailer(openai_profile, stream_request): + from pathlib import Path + + sample = Path(__file__).resolve().parents[3] / "fixtures/model_service/vllm_023_glm_tail.sse" + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + events = [] + for packet in sample.read_text().strip().split("\n\n"): + events += decoder.push(SSEEvent(packet.removeprefix("data: "))) + assert [e.type for e in events].count("usage") == 1 + complete = [e.message for e in events if e.type == "message_completed"] + assert len(complete) == 1 + assert complete[0].content_parts == [{"type": "text", "text": "上的疑问,还是日常生活中的好奇,我都很乐意为你提供帮助。"}] + assert complete[0].usage.input_tokens == 13 + assert complete[0].usage.output_tokens == 231 + assert complete[0].usage.total_tokens == 244 + assert complete[0].usage.reasoning_tokens == 182 + assert complete[0].finish_reason == "stop" + + +@pytest.mark.parametrize("delta", [{"content": "late"}, {"reasoning_content": "late"}, + {"refusal": "late"}, {"tool_calls": [tool(0, "late", "{}", "call-late")]}]) +def test_post_finish_actual_generation_remains_rejected(openai_profile, stream_request, delta): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + decoder.push(frame({}, finish="stop")) + with pytest.raises(ModelAdapterError, match="^MODEL_STREAM_AFTER_FINISH$"): + decoder.push(frame(delta)) + + +@pytest.mark.parametrize("finish", [None, "stop"]) +def test_empty_finish_trailer_is_idempotent(openai_profile, stream_request, finish): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + decoder.push(frame({"content": "ok"}, finish="stop")) + assert decoder.push(frame({"content": "", "tool_calls": []}, finish=finish)) == [] + assert len(decoder.push(SSEEvent("[DONE]"))) == 1 + + +def test_conflicting_finish_trailer_has_precise_error(openai_profile, stream_request): + decoder = OpenAIStreamDecoder(stream_request, openai_profile) + decoder.push(frame({"content": "ok"}, finish="stop")) + with pytest.raises(ModelAdapterError, match="^MODEL_STREAM_FINISH_CONFLICT$"): + decoder.push(frame({}, finish="length")) diff --git a/test/sdk/core/model_service/test_outlet_capabilities.py b/test/sdk/core/model_service/test_outlet_capabilities.py new file mode 100644 index 0000000000..7284c81a0c --- /dev/null +++ b/test/sdk/core/model_service/test_outlet_capabilities.py @@ -0,0 +1,238 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Capability storage ownership preserves tri-state declarations and rejects corruption.""" + +from copy import deepcopy + +import pytest +from nexent.core.model_service.profiles.contracts import ProfileCapabilities +from nexent.core.model_service.profiles.outlet_capabilities import ( + CapabilityStorageError, + restore_outlet_capabilities, + split_outlet_capabilities, +) + + +def restore(stored): + return restore_outlet_capabilities( + columns=stored.columns, capability_details=stored.capability_details + ) + + +@pytest.mark.parametrize( + "status,column_value", + [("supported", True), ("unsupported", False), ("unknown", None)], +) +def test_all_support_columns_preserve_tri_state(status, column_value): + value = ProfileCapabilities.model_validate( + { + "reasoning": {"status": status}, + "tools": {"status": status}, + "structured_output": {"status": status}, + "sampling": { + "temperature": {"status": status}, + "top_p": {"status": status}, + }, + } + ) + stored = split_outlet_capabilities(value) + for column in ( + "reasoning_supported", + "native_tool_call_supported", + "structured_output_supported", + "temperature_supported", + "top_p_supported", + ): + assert stored.columns[column] is column_value + assert restore(stored).model_dump() == value.model_dump() + + +def test_round_trip_has_no_duplicate_capability_values_and_is_detached(): + value = ProfileCapabilities.model_validate( + { + "context": { + "combined_window": 200000, + "input_limit": 180000, + "output_limit": 10000, + }, + "reasoning": { + "status": "supported", + "controls": [{"mode": "toggle", "values": [False, True]}], + "response_paths": ["reasoning_content"], + }, + "tools": { + "status": "supported", + "schema_constraints": {"ref": "tools_v1", "version": 1}, + "result_format": "tool", + }, + "modalities": {"input": ["text", "image"], "output": ["text"]}, + "response": { + "supported_finish_reasons": ["stop", "tool_calls"], + "stream_format": "sse", + }, + "usage": { + "semantics_id": "tokens_v1", + "input_includes_cached": "supported", + "output_includes_reasoning": "unknown", + }, + } + ) + original = value.model_dump() + stored = split_outlet_capabilities(value) + assert stored.columns["context_window_tokens"] == 200000 + assert stored.columns["reasoning_controls"][0]["values"] == [False, True] + assert set(stored.capability_details) == {"reasoning", "tools", "response", "usage"} + assert set(stored.capability_details["reasoning"]) == {"response_paths"} + assert set(stored.capability_details["tools"]) == { + "schema_constraints", + "result_format", + } + assert restore(stored).model_dump() == original + restored = restore(stored) + restored.modalities.input.append("audio") + restored.reasoning.controls[0].values.pop() + assert value.model_dump() == original + assert stored.columns["input_modalities"] == ["text", "image"] + value.modalities.input.append("video") + assert stored.columns["input_modalities"] == ["text", "image"] + + +@pytest.mark.parametrize("controls,modalities", [(None, None), ([], [])]) +def test_unknown_and_explicit_empty_domains_remain_distinct(controls, modalities): + value = ProfileCapabilities.model_validate( + {"reasoning": {"controls": controls}, "modalities": {"input": modalities}} + ) + restored = restore(split_outlet_capabilities(value)) + assert restored.reasoning.controls == controls + assert restored.modalities.input == modalities + + +@pytest.mark.parametrize( + "column", + [ + "reasoning_supported", + "native_tool_call_supported", + "structured_output_supported", + "temperature_supported", + "top_p_supported", + ], +) +@pytest.mark.parametrize("bad", [0, 1, "false", "unknown", {}, []]) +def test_boolean_columns_never_coerce_unknown_or_false(column, bad): + stored = split_outlet_capabilities(ProfileCapabilities()) + stored.columns[column] = bad + with pytest.raises(CapabilityStorageError, match="^PROFILE_STORAGE_INVALID$"): + restore(stored) + + +@pytest.mark.parametrize( + "group,duplicate", + [ + ("reasoning", "status"), + ("reasoning", "controls"), + ("tools", "status"), + ("response", "unexpected"), + ("usage", "unexpected"), + ], +) +def test_duplicate_or_undeclared_detail_fields_fail(group, duplicate): + stored = split_outlet_capabilities(ProfileCapabilities()) + stored.capability_details[group][duplicate] = "secret-value" + with pytest.raises(CapabilityStorageError) as captured: + restore(stored) + assert "secret-value" not in str(captured.value) + + +@pytest.mark.parametrize( + "target,key", + [ + ("columns", "context_window_tokens"), + ("columns", "reasoning_controls"), + ("capability_details", "usage"), + ], +) +def test_missing_storage_fields_are_not_reconstructed_from_defaults(target, key): + stored = split_outlet_capabilities(ProfileCapabilities()) + getattr(stored, target).pop(key) + with pytest.raises(CapabilityStorageError): + restore(stored) + + +@pytest.mark.parametrize( + "column,value", + [ + ("context_window_tokens", 0), + ("max_input_tokens", True), + ("max_output_tokens", 2**31), + ("input_modalities", ["text", "text"]), + ("reasoning_controls", [{"mode": "toggle", "values": [0, 1]}]), + ], +) +def test_invalid_column_values_fail_before_profile_publication(column, value): + stored = split_outlet_capabilities(ProfileCapabilities()) + stored.columns[column] = value + original = deepcopy(stored) + with pytest.raises(CapabilityStorageError): + restore(stored) + assert stored == original + + +def test_unsupported_reasoning_with_saved_controls_fails(): + stored = split_outlet_capabilities(ProfileCapabilities()) + stored.columns["reasoning_supported"] = False + stored.columns["reasoning_controls"] = [{"mode": "toggle", "values": [True]}] + with pytest.raises(CapabilityStorageError): + restore(stored) + + +def test_capacity_relationships_are_validated_after_restore(): + stored = split_outlet_capabilities(ProfileCapabilities()) + stored.columns["context_window_tokens"] = 100 + stored.columns["max_input_tokens"] = 101 + with pytest.raises(CapabilityStorageError): + restore(stored) + + +@pytest.mark.parametrize("details", [None, [], {"unexpected": "secret"}]) +def test_invalid_detail_container_is_rejected(details): + stored = split_outlet_capabilities(ProfileCapabilities()) + with pytest.raises(CapabilityStorageError): + restore_outlet_capabilities(columns=stored.columns, capability_details=details) + + +def test_invalid_detail_group_is_rejected(): + stored = split_outlet_capabilities(ProfileCapabilities()) + stored.capability_details["usage"] = None + with pytest.raises(CapabilityStorageError): + restore(stored) + + +def test_int4_capacity_bound_and_configuration_size_are_checked_on_save(): + with pytest.raises(CapabilityStorageError): + split_outlet_capabilities( + ProfileCapabilities(context={"combined_window": 2**31}) + ) + value = ProfileCapabilities(context={"combined_window": 2**31 - 1}) + assert ( + restore(split_outlet_capabilities(value)).context.combined_window == 2**31 - 1 + ) + with pytest.raises(CapabilityStorageError): + split_outlet_capabilities( + ProfileCapabilities(usage={"semantics_id": "x" * (1024 * 1024)}) + ) + + +@pytest.mark.parametrize("bad", [None, {}, "secret"]) +def test_save_requires_a_complete_typed_object(bad): + with pytest.raises(CapabilityStorageError): + split_outlet_capabilities(bad) + + +def test_restore_rejects_non_json_and_cyclic_inputs(): + stored = split_outlet_capabilities(ProfileCapabilities()) + stored.capability_details["usage"]["semantics_id"] = float("nan") + with pytest.raises(CapabilityStorageError): + restore(stored) + stored.capability_details["usage"]["semantics_id"] = stored.capability_details + with pytest.raises(CapabilityStorageError): + restore(stored) diff --git a/test/sdk/core/model_service/test_outlet_parameters.py b/test/sdk/core/model_service/test_outlet_parameters.py new file mode 100644 index 0000000000..4f0b192b3c --- /dev/null +++ b/test/sdk/core/model_service/test_outlet_parameters.py @@ -0,0 +1,248 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Persisted parameter presence survives the single-location storage boundary.""" + +from copy import deepcopy + +import pytest +from nexent.core.model_service.profiles.outlet_parameters import ( + DEDICATED_PARAMETER_KEYS, + PARAMETER_COLUMNS, + restore_outlet_parameters, + split_outlet_parameters, +) +from nexent.core.model_service.profiles.parameter_rules import ( + apply_parameter_values, + compile_parameter_rules, + validate_saved_parameter_values, +) +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError + + +def declaration(key, **changes): + value = { + "key": key, + "schema": {}, + "visible": True, + "editable": True, + "required": False, + "allowed_scopes": ["outlet", "conversation"], + "ui_group": "common", + "owner": "provider", + "source": {"rule_id": "test"}, + } + value.update(changes) + return value + + +def restore(compiled, stored): + return restore_outlet_parameters( + compiled, + columns=stored.columns, + parameter_values=stored.parameter_values, + explicit_null_parameters=stored.explicit_null_parameters, + ) + + +def test_creation_defaults_are_not_reapplied_on_saved_snapshot_load(): + rules = compile_parameter_rules( + [declaration("enable_thinking", default_value=True)] + ) + assert apply_parameter_values(rules, {}, scope="outlet", saved_values={}) == { + "enable_thinking": True + } + assert validate_saved_parameter_values(rules, {}) == {} + stored = split_outlet_parameters(rules, {}) + assert stored.explicit_null_parameters == [] + assert restore(rules, stored) == {} + + +@pytest.mark.parametrize("key", ["temperature", "top_p"]) +def test_float_columns_reject_integer_rounding(key): + rules = compile_parameter_rules([declaration(key)]) + with pytest.raises(ParameterValidationError, match="PARAMETER_COLUMN_INVALID"): + split_outlet_parameters(rules, {key: 2**53 + 1}) + assert restore(rules, split_outlet_parameters(rules, {key: 2**53})) == {key: 2**53} + + +@pytest.mark.parametrize( + "key,value", + [ + ("temperature", 0), + ("temperature", None), + ("top_p", 0), + ("top_p", None), + ("enable_thinking", False), + ("enable_thinking", None), + ("reasoning_effort", "none"), + ("reasoning_effort", None), + ("reasoning_budget_tokens", 0), + ("reasoning_budget_tokens", -1), + ("reasoning_budget_tokens", None), + ("custom", None), + ("custom", False), + ("max_output_tokens", 4096), + ("max_output_tokens", None), + ], +) +def test_round_trip_preserves_value_and_key_presence(key, value): + rules = compile_parameter_rules([declaration(key)]) + stored = split_outlet_parameters(rules, {key: value}) + assert restore(rules, stored) == {key: value} + if key in DEDICATED_PARAMETER_KEYS: + assert key not in stored.parameter_values + assert stored.explicit_null_parameters == ([key] if value is None else []) + else: + assert stored.parameter_values == {key: value} + assert stored.explicit_null_parameters == [] + + +def test_split_and_restore_return_detached_values_and_never_change_rules(): + declarations = [declaration("custom", default_value={"a": [1]})] + rules = compile_parameter_rules(declarations) + values = {"custom": {"a": [2]}} + stored = split_outlet_parameters(rules, values) + values["custom"]["a"].append(3) + result = restore(rules, stored) + result["custom"]["a"].append(4) + assert stored.parameter_values == {"custom": {"a": [2]}} + assert rules.declarations == declarations + + +@pytest.mark.parametrize( + "changes", + [ + {"explicit_null_parameters": ["temperature", "temperature"]}, + {"explicit_null_parameters": ["custom"]}, + {"explicit_null_parameters": [None]}, + {"explicit_null_parameters": "temperature"}, + {"explicit_null_parameters": {"temperature"}}, + {"columns": {}}, + {"columns": None}, + {"parameter_values": {"temperature": None}}, + {"parameter_values": None}, + ], +) +def test_corrupt_storage_is_rejected(changes): + rules = compile_parameter_rules([declaration("temperature")]) + stored = split_outlet_parameters(rules, {}) + arguments = { + "columns": stored.columns, + "parameter_values": stored.parameter_values, + "explicit_null_parameters": stored.explicit_null_parameters, + } + arguments.update(changes) + with pytest.raises(ParameterValidationError): + restore_outlet_parameters(rules, **arguments) + + +def test_non_null_column_cannot_have_a_null_marker(): + rules = compile_parameter_rules([declaration("temperature")]) + stored = split_outlet_parameters(rules, {"temperature": 0}) + stored.explicit_null_parameters = ["temperature"] + with pytest.raises( + ParameterValidationError, match="PARAMETER_NULL_MARKERS_INVALID" + ): + restore(rules, stored) + + +@pytest.mark.parametrize( + "key,value", + [ + ("temperature", True), + ("temperature", 10**1000), + ("top_p", "0.5"), + ("enable_thinking", 0), + ("reasoning_effort", "x" * 31), + ("reasoning_effort", " "), + ("reasoning_budget_tokens", True), + ("reasoning_budget_tokens", 1.5), + ("reasoning_budget_tokens", 2**31), + ("reasoning_budget_tokens", -(2**31) - 1), + ("max_output_tokens", True), + ("max_output_tokens", 0), + ("max_output_tokens", 2**31), + ], +) +def test_dedicated_column_types_and_limits_are_enforced(key, value): + rules = compile_parameter_rules([declaration(key)]) + with pytest.raises(ParameterValidationError, match="PARAMETER_COLUMN_INVALID"): + split_outlet_parameters(rules, {key: value}) + stored = split_outlet_parameters(rules, {}) + stored.columns[PARAMETER_COLUMNS[key]] = value + with pytest.raises(ParameterValidationError, match="PARAMETER_COLUMN_INVALID"): + restore(rules, stored) + + +@pytest.mark.parametrize("value", [-(2**31), 2**31 - 1]) +def test_int4_boundary_values_are_preserved_when_declared(value): + rules = compile_parameter_rules([declaration("reasoning_budget_tokens")]) + assert restore( + rules, split_outlet_parameters(rules, {"reasoning_budget_tokens": value}) + ) == { + "reasoning_budget_tokens": value, + } + + +@pytest.mark.parametrize( + "fields,values,category", + [ + ([declaration("custom", required=True)], {}, "PARAMETER_VALUE_REQUIRED"), + ([declaration("custom", fixed_value=0)], {}, "FIXED_PARAMETER"), + ([declaration("custom", fixed_value=False)], {"custom": 0}, "FIXED_PARAMETER"), + ([declaration("custom")], {"unknown": "secret"}, "PARAMETER_UNKNOWN"), + ( + [declaration("custom", schema={"type": "boolean"})], + {"custom": 0}, + "PARAMETER_VALUE_INVALID", + ), + ], +) +def test_saved_validation_rejects_invalid_snapshots_without_repair( + fields, values, category +): + original = deepcopy(values) + with pytest.raises(ParameterValidationError) as captured: + validate_saved_parameter_values(compile_parameter_rules(fields), values) + assert captured.value.category == category + assert "secret" not in str(captured.value) + assert values == original + + +def test_saved_inactive_values_fail_instead_of_disappearing(): + rules = compile_parameter_rules( + [ + declaration("enable_thinking", default_value=True), + declaration( + "reasoning_budget_tokens", + condition={"key": "enable_thinking", "op": "eq", "value": True}, + ), + ] + ) + assert validate_saved_parameter_values(rules, {"enable_thinking": False}) == { + "enable_thinking": False + } + for values in ( + {"reasoning_budget_tokens": 0}, + {"enable_thinking": False, "reasoning_budget_tokens": 0}, + ): + with pytest.raises(ParameterValidationError, match="PARAMETER_DEPENDENCY"): + validate_saved_parameter_values(rules, values) + + +def test_inactive_fixed_rule_does_not_create_a_value_on_load(): + rules = compile_parameter_rules( + [ + declaration("enabled"), + declaration( + "custom", + fixed_value=0, + condition={"key": "enabled", "op": "eq", "value": True}, + ), + ] + ) + assert validate_saved_parameter_values(rules, {}) == {} + assert validate_saved_parameter_values(rules, {"enabled": True, "custom": 0}) == { + "enabled": True, + "custom": 0, + } diff --git a/test/sdk/core/model_service/test_outlet_resolution.py b/test/sdk/core/model_service/test_outlet_resolution.py new file mode 100644 index 0000000000..a94beda0db --- /dev/null +++ b/test/sdk/core/model_service/test_outlet_resolution.py @@ -0,0 +1,160 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Save-time construction preserves composed facts and rejects invalid snapshots.""" + +from dataclasses import replace + +import pytest +from nexent.core.model_service.profiles.composition import compose_provider_model_policy +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.outlet_resolution import ( + OutletResolutionError, + construct_resolved_model_profile, +) +from nexent.core.model_service.profiles.outlet_storage import ( + decode_resolved_model_profile, + encode_resolved_model_profile, +) +from nexent.core.model_service.profiles.resolved_profile import ( + ConnectionRules, + ContextConfiguration, + OutletConnection, +) +from nexent.core.model_service.profiles.resolver import load_profile_catalog + +REF = VersionedReference(ref="construction_fixture_v1", version=1) +REGISTRY = {key: [REF] for key in ("protocol", "auth", "adapter", "code_converter", "native_converter")} +REGISTRY["parameter_transform"] = [VersionedReference(ref="identity_v1", version=1)] + + +@pytest.fixture(scope="module") +def catalog(): + return load_profile_catalog() + + +def compose(catalog, **changes): + options = { + "provider_key": "alibaba-cn", "request_model_id": "qwen3.5-plus", "model_profile_key": None, + "base_bindings": {key: REF.model_dump() for key in REGISTRY if key != "parameter_transform"}, + "base_instruction": VersionedReference(ref="system_only_v1", version=1), + "registered_references": REGISTRY, "submitted_values": {}, + } + options.update(changes) + return compose_provider_model_policy(catalog, **options) + + +def construct(catalog, policy=None, **changes): + options = { + "tenant_id": "tenant", "display_name": "outlet", "model_type": "llm", "provider_id": "operator-provider", + "connection": OutletConnection(base_url="https://fixture.invalid/v1", ssl_verify=True, + timeout_seconds=120, concurrency_limit=None, + credential_ref={"kind": "model_record", "model_id": None}), + "connection_rules": ConnectionRules(credential_kind="model_record", relative_path="/chat/completions", + method="POST", headers={}), + "context_configuration": ContextConfiguration(output_reserve_tokens=None, tokenizer_family=None, + capacity_source="profile", capability_profile_version=None), + "registered_references": REGISTRY, + } + options.update(changes) + return construct_resolved_model_profile(policy or compose(catalog), catalog, **options) + + +def test_known_combination_constructs_complete_detached_snapshot(catalog): + policy = compose(catalog, submitted_values={"enable_thinking": False}) + snapshot = construct(catalog, policy) + assert snapshot.outlet_id is None and snapshot.config_revision == 1 + assert snapshot.identity.request_model_id == "qwen3.5-plus" + assert snapshot.parameter_values == {"enable_thinking": False} + assert snapshot.provider_key == "alibaba-cn" and snapshot.model_profile_key == "alibaba/qwen3.5-plus" + assert snapshot.provenance.atomic_refs.provider_model.key == "alibaba-cn/qwen3.5-plus" + assert snapshot.provenance.overrides and snapshot.provenance.field_sources + assert snapshot.provenance.interpretation_versions.composer.version == 4 + snapshot.parameter_values["enable_thinking"] = True + assert policy.parameter_values["enable_thinking"] is False + + +def test_custom_provider_retains_exact_request_identity_and_selected_model(catalog): + policy = compose(catalog, provider_key=None, request_model_id="tenant/Exact-ID", + model_profile_key="alibaba/qwen3.5-plus") + snapshot = construct(catalog, policy) + assert snapshot.identity.request_model_id == "tenant/Exact-ID" + assert snapshot.provenance.atomic_refs.provider is None + assert snapshot.provenance.atomic_refs.provider_model is None + assert snapshot.model_profile_key == "alibaba/qwen3.5-plus" + assert snapshot.parameter_values == {} + + +def test_unmatched_outlet_keeps_unknown_capabilities(catalog): + snapshot = construct(catalog, compose(catalog, provider_key=None, request_model_id="tenant/model")) + assert snapshot.capabilities.tools.status == "unknown" + assert snapshot.capabilities.context.combined_window is None + assert snapshot.provenance.atomic_refs.model is None + + +def test_saved_row_restores_without_constructing_or_filling_defaults(catalog, monkeypatch): + snapshot = construct(catalog, compose(catalog, submitted_values={"enable_thinking": False})) + row = encode_resolved_model_profile(snapshot, registered_references=REGISTRY) + row["model_id"] = 10 + def forbidden(*args, **kwargs): + raise AssertionError("saved row attempted source access") + monkeypatch.setattr("nexent.core.model_service.profiles.resolver.load_profile_catalog", forbidden) + restored = decode_resolved_model_profile(row, registered_references=REGISTRY) + assert restored.parameter_values == {"enable_thinking": False} + assert restored.provenance.model_dump() == snapshot.provenance.model_dump() + + +@pytest.mark.parametrize("mutation,category", [ + ({"catalog_revision": "stale"}, "PROFILE_CATALOG_REVISION_MISMATCH"), + ({"provider_key": "missing"}, "PROFILE_SELECTION_INVALID"), + ({"model_profile_key": "missing"}, "PROFILE_SELECTION_INVALID"), + ({"model_profile_key": None}, "PROFILE_SELECTION_INVALID"), +]) +def test_stale_or_inconsistent_selection_fails(catalog, mutation, category): + with pytest.raises(OutletResolutionError) as failure: + construct(catalog, replace(compose(catalog), **mutation)) + assert failure.value.category == category + + +@pytest.mark.parametrize("changes", [ + {"registered_references": {}}, + {"extra_api_parameters": [{"path": "/vendor/option", "value": float("nan")}]}, + {"tenant_id": ""}, + {"connection": None}, + {"extra_api_parameters": [{"path": "/model", "value": "private-value"}], "protected_paths": ["/model"]}, + {"extra_api_parameters": [{"path": "/enable_thinking", "value": "private-value"}]}, +]) +def test_invalid_or_unavailable_snapshot_is_safely_rejected(catalog, changes): + with pytest.raises(OutletResolutionError) as failure: + construct(catalog, **changes) + assert failure.value.category == "PROFILE_RESOLUTION_INVALID" + assert "private-value" not in str(failure.value) + + +def test_missing_primary_source_reference_fails(catalog): + policy = compose(catalog) + atom = catalog.models[policy.model_profile_key] + broken = replace(atom, source=replace(atom.source, source_refs=())) + altered = replace(catalog, models={**catalog.models, atom.key: broken}) + with pytest.raises(OutletResolutionError) as failure: + construct(altered, policy) + assert failure.value.category == "PROFILE_SOURCE_REFERENCE_INVALID" + + +@pytest.mark.parametrize("policy,catalog_value", [(None, None), ("private-input", None)]) +def test_wrong_internal_input_types_fail_safely(policy, catalog_value): + with pytest.raises(OutletResolutionError) as failure: + construct_resolved_model_profile( + policy, catalog_value, tenant_id="tenant", display_name="outlet", model_type="llm", + provider_id="provider", connection=None, connection_rules=None, context_configuration=None, + registered_references=REGISTRY, + ) + assert str(failure.value) == "PROFILE_SELECTION_INVALID" + + +def test_composed_defaults_are_applied_only_once_and_budget_zero_survives(catalog): + policy = compose(catalog, submitted_values={"reasoning_budget_tokens": 0}) + snapshot = construct(catalog, policy) + assert snapshot.parameter_values == {"enable_thinking": True, "reasoning_budget_tokens": 0} + assert snapshot.provenance.interpretation_versions.source_mapping.ref == "models_dev_source_v1" + assert all("#" in source.source_path for source in snapshot.provenance.field_sources) + assert snapshot.provenance.atomic_refs.model.file_path.startswith("models/") diff --git a/test/sdk/core/model_service/test_outlet_rules.py b/test/sdk/core/model_service/test_outlet_rules.py new file mode 100644 index 0000000000..f7bd677ad6 --- /dev/null +++ b/test/sdk/core/model_service/test_outlet_rules.py @@ -0,0 +1,261 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Same-row domain and fixed-value references preserve rule semantics.""" + +from copy import deepcopy + +import pytest +from nexent.core.model_service.profiles.outlet_rules import ( + RuleStorageError, + restore_parameter_rules, + store_parameter_rules, +) +from nexent.core.model_service.profiles.parameter_rules import ( + compile_parameter_rules, + validate_saved_parameter_values, +) + + +def declaration(key="enable_thinking", **changes): + result = { + "key": key, + "schema": {}, + "visible": True, + "editable": True, + "required": False, + "allowed_scopes": ["outlet", "conversation"], + "ui_group": "common", + "owner": "provider", + "source": {"rule_id": "fixture"}, + } + result.update(changes) + return result + + +@pytest.mark.parametrize( + "key,control,schema,value,keywords", + [ + ( + "enable_thinking", + {"mode": "toggle", "values": [False, True]}, + {"type": "boolean", "enum": [False, True]}, + False, + ["enum"], + ), + ( + "reasoning_effort", + {"mode": "effort", "values": [None, "low", "high"]}, + {"type": ["string", "null"], "enum": [None, "low", "high"]}, + None, + ["enum"], + ), + ( + "reasoning_budget_tokens", + {"mode": "budget_tokens", "min": 0, "max": 81920}, + {"type": "integer", "minimum": 0, "maximum": 81920}, + 0, + ["maximum", "minimum"], + ), + ], +) +def test_declared_domains_are_stored_once_and_restore_exact_schema( + key, control, schema, value, keywords +): + declarations = [declaration(key, schema=schema)] + compiled = compile_parameter_rules(declarations) + stored = store_parameter_rules( + compiled, reasoning_controls=[control], saved_values={key: value} + ) + assert stored[0]["value_domain_ref"] == { + "mode": control["mode"], + "keywords": keywords, + } + assert not set(keywords) & stored[0]["schema"].keys() + restored = restore_parameter_rules( + stored, reasoning_controls=[control], saved_values={key: value} + ) + assert restored.declarations == declarations + assert validate_saved_parameter_values(restored, {key: value}) == {key: value} + assert compiled.declarations == declarations + + +def test_more_restrictive_limits_and_unknown_bounds_are_not_expanded(): + controls = [{"mode": "budget_tokens", "max": 81920}] + schema = {"type": "integer", "minimum": 0, "maximum": 40000} + compiled = compile_parameter_rules( + [declaration("reasoning_budget_tokens", schema=schema)] + ) + stored = store_parameter_rules( + compiled, reasoning_controls=controls, saved_values={} + ) + assert "value_domain_ref" not in stored[0] + assert stored[0]["schema"] == schema + assert ( + restore_parameter_rules( + stored, reasoning_controls=controls, saved_values={} + ).declarations + == compiled.declarations + ) + + +@pytest.mark.parametrize("value", [False, 0, None, "", {"a": [1]}]) +def test_fixed_values_use_saved_presence_and_round_trip_without_value_copy(value): + compiled = compile_parameter_rules([declaration("custom", fixed_value=value)]) + stored = store_parameter_rules( + compiled, reasoning_controls=None, saved_values={"custom": value} + ) + assert "fixed_value" not in stored[0] + assert stored[0]["fixed_to_saved_value"] is True + restored = restore_parameter_rules( + stored, reasoning_controls=None, saved_values={"custom": value} + ) + assert restored.declarations == compiled.declarations + with pytest.raises(RuleStorageError): + restore_parameter_rules(stored, reasoning_controls=None, saved_values={}) + + +def test_inactive_fixed_constraint_is_preserved_and_default_is_not_applied(): + compiled = compile_parameter_rules( + [ + declaration("enabled", default_value=True), + declaration( + "custom", + fixed_value=0, + condition={"key": "enabled", "op": "eq", "value": True}, + ), + ] + ) + stored = store_parameter_rules(compiled, reasoning_controls=[], saved_values={}) + assert stored[1]["fixed_value"] == 0 + assert "fixed_to_saved_value" not in stored[1] + restored = restore_parameter_rules(stored, reasoning_controls=[], saved_values={}) + assert restored.declarations == compiled.declarations + assert validate_saved_parameter_values(restored, {}) == {} + + +@pytest.mark.parametrize( + "reference", + [ + None, + {}, + {"mode": "effort", "keywords": ["enum"]}, + {"mode": "toggle", "keywords": []}, + {"mode": "toggle", "keywords": ["enum", "enum"]}, + {"mode": "toggle", "keywords": [None]}, + {"mode": "toggle", "keywords": ["maximum"]}, + {"mode": "toggle", "keywords": ["enum"], "extra": True}, + ], +) +def test_invalid_domain_references_are_rejected(reference): + rule = declaration(value_domain_ref=reference) + with pytest.raises(RuleStorageError, match="^PROFILE_STORAGE_INVALID$"): + restore_parameter_rules( + [rule], + reasoning_controls=[{"mode": "toggle", "values": [False, True]}], + saved_values={}, + ) + + +def test_duplicate_domain_constraint_cannot_override_the_reference(): + rule = declaration( + schema={"enum": [False]}, + value_domain_ref={"mode": "toggle", "keywords": ["enum"]}, + ) + with pytest.raises(RuleStorageError): + restore_parameter_rules( + [rule], + reasoning_controls=[{"mode": "toggle", "values": [False, True]}], + saved_values={}, + ) + + +@pytest.mark.parametrize( + "key,controls,reference", + [ + ( + "custom", + [{"mode": "toggle", "values": [False, True]}], + {"mode": "toggle", "keywords": ["enum"]}, + ), + ( + "reasoning_budget_tokens", + [{"mode": "budget_tokens", "max": 10}], + {"mode": "budget_tokens", "keywords": ["minimum"]}, + ), + ("enable_thinking", None, {"mode": "toggle", "keywords": ["enum"]}), + ], +) +def test_missing_or_wrong_control_domain_is_not_inferred(key, controls, reference): + with pytest.raises(RuleStorageError): + restore_parameter_rules( + [declaration(key, value_domain_ref=reference)], + reasoning_controls=controls, + saved_values={}, + ) + + +@pytest.mark.parametrize( + "rule", + [ + declaration(fixed_to_saved_value=False), + declaration(fixed_to_saved_value=1), + declaration(fixed_to_saved_value=True, fixed_value=False), + ], +) +def test_fixed_reference_marker_is_strict_and_cannot_duplicate_values(rule): + with pytest.raises(RuleStorageError): + restore_parameter_rules( + [rule], reasoning_controls=None, saved_values={"enable_thinking": False} + ) + + +@pytest.mark.parametrize( + "controls", + [ + [{"mode": "toggle", "values": [True]}] * 2, + [{"mode": "toggle", "values": [1]}], + {}, + ], +) +def test_invalid_control_domains_fail_on_both_boundaries(controls): + compiled = compile_parameter_rules([declaration()]) + with pytest.raises(RuleStorageError): + store_parameter_rules(compiled, reasoning_controls=controls, saved_values={}) + with pytest.raises(RuleStorageError): + restore_parameter_rules( + compiled.declarations, reasoning_controls=controls, saved_values={} + ) + + +def test_failed_restore_does_not_mutate_declarations_or_include_private_values(): + rules = [declaration("private", schema={"type": "integer"})] + original = deepcopy(rules) + with pytest.raises(RuleStorageError) as captured: + restore_parameter_rules( + rules, + reasoning_controls=None, + saved_values={"private": "credential-secret"}, + ) + assert rules == original + assert "credential-secret" not in str(captured.value) + + +@pytest.mark.parametrize( + "rules", + [None, {}, [None], [{"key": "x", "schema": None}], [declaration(unknown=True)]], +) +def test_invalid_stored_rule_structure_fails(rules): + with pytest.raises(RuleStorageError): + restore_parameter_rules(rules, reasoning_controls=None, saved_values={}) + + +def test_oversized_rules_fail_with_sanitized_storage_error(): + compiled = compile_parameter_rules( + [declaration(source={"note": "x" * (1024 * 1024)})] + ) + with pytest.raises(RuleStorageError): + store_parameter_rules(compiled, reasoning_controls=None, saved_values={}) + with pytest.raises(RuleStorageError): + restore_parameter_rules( + compiled.declarations, reasoning_controls=None, saved_values={} + ) diff --git a/test/sdk/core/model_service/test_outlet_storage.py b/test/sdk/core/model_service/test_outlet_storage.py new file mode 100644 index 0000000000..6288d9e60b --- /dev/null +++ b/test/sdk/core/model_service/test_outlet_storage.py @@ -0,0 +1,448 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Whole-row outlet codec validates ownership, versions and saved configuration.""" + +from copy import deepcopy + +import pytest +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.instruction_mapping import ( + resolve_instruction_mapping, +) +from nexent.core.model_service.profiles.outlet_storage import ( + ProfileStorageError, + decode_resolved_model_profile, + encode_resolved_model_profile, +) +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile +from pydantic import ValidationError + +REFERENCE = VersionedReference(ref="fixture_v1", version=1) +ROLE = VersionedReference(ref="system_only_v1", version=1) +REGISTRY = { + key: [REFERENCE] + for key in ( + "protocol", + "auth", + "adapter", + "code_converter", + "native_converter", + "parameter_transform", + ) +} + + +def rule(key, **changes): + value = { + "key": key, + "schema": {}, + "visible": True, + "editable": True, + "required": False, + "allowed_scopes": ["outlet", "conversation"], + "ui_group": "common", + "owner": "provider", + "source": {"rule_id": "fixture"}, + "api_binding": { + "path": "/" + key, + "transform_ref": "fixture_v1", + "transform_version": 1, + }, + } + value.update(changes) + return value + + +@pytest.fixture +def document(): + return { + "schema_version": 1, + "outlet_id": 12, + "config_revision": 1, + "identity": { + "tenant_id": "tenant", + "display_name": "model display", + "model_type": "llm", + "provider_id": "custom-provider", + "request_model_id": "Exact/MODEL", + "provider_key": None, + "model_profile_key": None, + }, + "connection": { + "base_url": "http://localhost:8000/v1", + "ssl_verify": False, + "timeout_seconds": 120, + "concurrency_limit": None, + "credential_ref": {"kind": "model_record", "model_id": 12}, + }, + "connection_rules": { + "credential_kind": "model_record", + "relative_path": "/chat/completions", + "method": "POST", + "headers": {"Accept": "application/json"}, + }, + "context_configuration": { + "output_reserve_tokens": None, + "tokenizer_family": None, + "capacity_source": "operator", + "capability_profile_version": None, + }, + "capabilities": {}, + "instruction_mapping": resolve_instruction_mapping(ROLE).model_dump( + mode="json" + ), + "parameter_rules": [ + rule("temperature", schema={"type": "number"}, fixed_value=0), + rule("custom", default_value="default"), + ], + "parameter_values": {"temperature": 0, "custom": None}, + "extra_api_parameters": [{"path": "/vendor/option", "value": False}], + "bindings": { + **{ + key: REFERENCE.model_dump() + for key in REGISTRY + if key != "parameter_transform" + }, + "role_mapping": ROLE.model_dump(), + }, + "provenance": { + "atomic_refs": {"model": None, "provider": None, "provider_model": None}, + "interpretation_versions": { + "source_parser": None, + "source_mapping": None, + "supplement_schema": None, + "composer": None, + }, + "overrides": [], + "field_sources": [], + "provider_metadata": {}, + }, + } + + +def encode(document): + return encode_resolved_model_profile( + ResolvedModelProfile.model_validate(document), registered_references=REGISTRY + ) + + +def decode(row): + return decode_resolved_model_profile(row, registered_references=REGISTRY) + + +def test_output_cap_and_request_default_have_independent_single_locations(document): + document["capabilities"] = {"context": {"output_limit": 32000}} + document["parameter_rules"].append(rule("max_output_tokens")) + document["parameter_values"]["max_output_tokens"] = 4096 + row = encode(document) + assert row["max_output_tokens"] == 32000 + assert row["default_max_output_tokens"] == 4096 + assert "max_output_tokens" not in row["extra_params"]["parameter_values"] + restored = decode(row) + assert restored.capabilities.context.output_limit == 32000 + assert restored.parameter_values["max_output_tokens"] == 4096 + + +def test_complete_snapshot_round_trip_without_duplicate_values_or_credentials(document): + profile = ResolvedModelProfile.model_validate(document) + row = encode(document) + assert "api_key" not in row + assert "max_tokens" not in row + assert row["temperature"] == 0 + assert row["extra_params"]["parameter_values"] == {"custom": None} + assert row["resolved_profile"]["parameter_rules"][0]["fixed_to_saved_value"] is True + assert "fixed_value" not in row["resolved_profile"]["parameter_rules"][0] + assert "protocol" not in row["resolved_profile"]["bindings"] + assert row["protocol_id"] == "fixture_v1" + assert row["resolved_profile"]["bindings"]["protocol_version"] == 1 + assert decode(row).model_dump() == profile.model_dump() + row["api_key"] = "credential-private" + assert decode(row).model_dump() == profile.model_dump() + assert "credential-private" not in repr(decode(row)) + + +def test_creation_before_database_id_assignment_and_no_default_application(document): + document["outlet_id"] = None + document["connection"]["credential_ref"]["model_id"] = None + document["parameter_values"].pop("custom") + row = encode(document) + assert "model_id" not in row + row["model_id"] = 27 + profile = decode(row) + assert profile.outlet_id == profile.connection.credential_ref.model_id == 27 + assert "custom" not in profile.parameter_values + + +def test_snapshot_load_is_detached_and_ignores_unrelated_row_fields(document): + row = encode(document) + row["create_time"] = object() + original = deepcopy(row["resolved_profile"]) + restored = decode(row) + restored.parameter_rules[0]["schema"]["type"] = "string" + restored.connection_rules.headers["Accept"] = "changed" + assert row["resolved_profile"] == original + + +@pytest.mark.parametrize( + "key", + [ + "model_id", + "resolved_profile", + "protocol_id", + "extra_params", + "timeout_seconds", + "config_revision", + ], +) +def test_incomplete_rows_fail_without_catalog_fallback(document, key): + row = encode(document) + row.pop(key) + with pytest.raises(ProfileStorageError, match="^PROFILE_STORAGE_INVALID$"): + decode(row) + + +@pytest.mark.parametrize( + "key,value", + [ + ("profile_schema_version", None), + ("profile_schema_version", True), + ("profile_schema_version", 2), + ("model_id", True), + ("model_id", 0), + ("config_revision", 0), + ("temperature", True), + ("protocol_id", "x" * 31), + ("timeout_seconds", -1), + ], +) +def test_invalid_row_types_and_versions_are_rejected(document, key, value): + row = encode(document) + row[key] = value + with pytest.raises(ProfileStorageError): + decode(row) + + +@pytest.mark.parametrize( + "group", + [ + "protocol", + "auth", + "adapter", + "code_converter", + "native_converter", + "parameter_transform", + ], +) +def test_unavailable_implementation_versions_fail_both_save_and_load(document, group): + row = encode(document) + registry = {key: refs for key, refs in REGISTRY.items() if key != group} + with pytest.raises(ProfileStorageError): + encode_resolved_model_profile( + ResolvedModelProfile.model_validate(document), + registered_references=registry, + ) + with pytest.raises(ProfileStorageError): + decode_resolved_model_profile(row, registered_references=registry) + + +@pytest.mark.parametrize( + "group,value", + [ + ("extra_params", {"temperature": 0}), + ("extra_params", None), + ("resolved_profile", None), + ("resolved_profile", {"api_key": "private"}), + ], +) +def test_legacy_or_bare_configuration_is_not_treated_as_resolved( + document, group, value +): + row = encode(document) + row[group] = value + with pytest.raises(ProfileStorageError) as captured: + decode(row) + assert "private" not in str(captured.value) + + +def test_duplicate_storage_and_conflicting_null_marker_fail(document): + row = encode(document) + row["extra_params"]["parameter_values"]["temperature"] = 0 + with pytest.raises(ProfileStorageError): + decode(row) + row = encode(document) + row["resolved_profile"]["explicit_null_parameters"] = ["temperature"] + with pytest.raises(ProfileStorageError): + decode(row) + + +def test_protected_adapter_paths_are_enforced_at_both_storage_boundaries(document): + row = encode(document) + with pytest.raises(ProfileStorageError): + decode_resolved_model_profile( + row, registered_references=REGISTRY, protected_paths=["/vendor"] + ) + with pytest.raises(ProfileStorageError): + encode_resolved_model_profile( + ResolvedModelProfile.model_validate(document), + registered_references=REGISTRY, + protected_paths=["/temperature"], + ) + + +@pytest.mark.parametrize( + "change", + [ + lambda d: d["connection"]["credential_ref"].update(model_id=99), + lambda d: d["connection"].update(api_key="private"), + lambda d: d["provenance"].update(unknown=True), + lambda d: d["bindings"].update(role_mapping={"ref": "other", "version": 1}), + lambda d: d.update(schema_version=True), + lambda d: d["identity"].update(request_model_id="x" * 101), + lambda d: d["extra_api_parameters"].append( + {"path": "/model", "value": "other"} + ), + ], +) +def test_complete_contract_rejects_unowned_or_conflicting_fields(document, change): + change(document) + with pytest.raises((ValueError, ValidationError)): + ResolvedModelProfile.model_validate(document) + + +def test_serialization_revalidates_mutated_nested_values(document): + profile = ResolvedModelProfile.model_validate(document) + profile.parameter_values["temperature"] = 1 + with pytest.raises(ProfileStorageError): + encode_resolved_model_profile(profile, registered_references=REGISTRY) + + +def test_atomic_identity_restores_from_provenance_without_source_reads( + document, monkeypatch +): + from pathlib import Path + + reference = { + "key": "provider/family", + "catalog_revision": "catalog-one", + "file_path": "providers/family/provider.toml", + "content_sha256": "a" * 64, + } + document["provenance"]["atomic_refs"]["provider"] = reference + document["identity"]["provider_key"] = "provider/family" + row = encode(document) + + def unexpected_read(*args, **kwargs): + pytest.fail("Saved outlet loading must not read source files") + + monkeypatch.setattr(Path, "read_text", unexpected_read) + restored = decode(row) + assert restored.identity.provider_key == restored.provider_key == "provider/family" + assert "identity" not in row["resolved_profile"] + assert restored.provenance.atomic_refs.provider.content_sha256 == "a" * 64 + + +@pytest.mark.parametrize( + "key,control,schema,value", + [ + ( + "enable_thinking", + {"mode": "toggle", "values": [False, True]}, + {"type": "boolean", "enum": [False, True]}, + False, + ), + ( + "reasoning_effort", + {"mode": "effort", "values": [None, "high"]}, + {"type": ["string", "null"], "enum": [None, "high"]}, + None, + ), + ( + "reasoning_budget_tokens", + {"mode": "budget_tokens", "min": 0, "max": 10, "special_values": [-1]}, + {"type": "integer"}, + -1, + ), + ], +) +def test_full_row_round_trip_includes_reasoning_domains_and_null_presence( + document, key, control, schema, value +): + document["capabilities"] = { + "reasoning": {"status": "supported", "controls": [control]} + } + document["parameter_rules"].append(rule(key, schema=schema)) + document["parameter_values"][key] = value + row = encode(document) + assert key not in row["extra_params"]["parameter_values"] + assert decode(row).parameter_values[key] == value + assert row["resolved_profile"]["explicit_null_parameters"] == ( + [key] if value is None else [] + ) + + +@pytest.mark.parametrize( + "control,value", + [ + ({"mode": "toggle", "values": [False]}, True), + ({"mode": "budget_tokens", "min": 0, "max": 10}, -1), + ({"mode": "budget_tokens", "min": 0, "max": 10}, 11), + ], +) +def test_saved_values_cannot_exceed_declared_reasoning_domain(document, control, value): + key = ( + "enable_thinking" if control["mode"] == "toggle" else "reasoning_budget_tokens" + ) + document["capabilities"] = { + "reasoning": {"status": "supported", "controls": [control]} + } + document["parameter_rules"].append(rule(key)) + document["parameter_values"][key] = value + with pytest.raises(ValueError): + ResolvedModelProfile.model_validate(document) + + +def test_control_declaration_requires_a_rule_even_when_value_is_not_sent(document): + document["capabilities"] = { + "reasoning": {"controls": [{"mode": "toggle", "values": [False, True]}]} + } + with pytest.raises(ValueError): + ResolvedModelProfile.model_validate(document) + document["parameter_rules"].append(rule("enable_thinking")) + assert "enable_thinking" not in decode(encode(document)).parameter_values + + +def test_schema_with_missing_parameter_binding_is_not_persistable(document): + document["parameter_rules"][0]["api_binding"].pop("path") + with pytest.raises(ValueError): + ResolvedModelProfile.model_validate(document) + + +def test_duplicate_protocol_reference_in_remaining_json_fails(document): + row = encode(document) + row["resolved_profile"]["bindings"]["protocol"] = REFERENCE.model_dump() + with pytest.raises(ProfileStorageError): + decode(row) + + +def test_oversized_complete_configuration_is_rejected(document): + profile = ResolvedModelProfile.model_validate(document) + profile.provenance.provider_metadata["filler"] = "x" * (1024 * 1024) + with pytest.raises(ProfileStorageError): + encode_resolved_model_profile(profile, registered_references=REGISTRY) + + +def test_atomic_key_cannot_contradict_provenance(document): + document["identity"]["model_profile_key"] = "unrelated-model" + with pytest.raises(ValueError): + ResolvedModelProfile.model_validate(document) + + +def test_integer_sampling_schema_survives_postgresql_float_column(document): + document["parameter_rules"][0]["schema"] = {"type": "integer"} + row = encode(document) + row["temperature"] = ( + 0.0 # PostgreSQL FLOAT returns a Python float for an integer insert. + ) + profile = decode(row) + assert type(profile.parameter_values["temperature"]) is int + assert profile.parameter_values["temperature"] == 0 diff --git a/test/sdk/core/model_service/test_parameter_transforms.py b/test/sdk/core/model_service/test_parameter_transforms.py new file mode 100644 index 0000000000..6bb8bc3fdf --- /dev/null +++ b/test/sdk/core/model_service/test_parameter_transforms.py @@ -0,0 +1,63 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Versioned transformations change the wire value without changing saved outlet data.""" + +import pytest +from nexent.core.model_service.adapters.openai_chat import ModelAdapterError, OpenAIChatAdapter +from nexent.core.model_service.contracts import ModelMessage, ModelRequest +from nexent.core.model_service.parameter_transforms import parameter_transform_references, transform_parameter +from nexent.core.model_service.profiles.outlet_storage import decode_resolved_model_profile, encode_resolved_model_profile +from nexent.core.model_service.registry import model_service_references + + +def binding(ref="thinking_toggle_v1", version=1): + return {"path": "/thinking/type", "transform_ref": ref, "transform_version": version} + + + + + + + + + + +@pytest.mark.parametrize("value,expected", [(True, "enabled"), (False, "disabled")]) +def test_saved_rule_executes_same_registered_transform_in_request(openai_profile, value, expected): + data = openai_profile.model_dump() + refs = model_service_references() + for key in ("auth", "code_converter", "native_converter"): + data["bindings"][key] = refs[key][0].model_dump() + data["outlet_id"] = data["connection"]["credential_ref"]["model_id"] = 12 + data["parameter_rules"] = [rule for rule in data["parameter_rules"] if rule["key"] != "enable_thinking"] + data["parameter_rules"].append({ + "key": "enable_thinking", "schema": {"type": "boolean"}, + "visible": True, "editable": True, "required": False, + "allowed_scopes": ["outlet", "conversation"], "ui_group": "reasoning", + "owner": "provider", "source": {"ref": "controlled_mapping", "version": 1}, + "api_binding": binding(), + }) + data["parameter_values"]["enable_thinking"] = value + profile = openai_profile.__class__.model_validate(data) + row = encode_resolved_model_profile(profile, registered_references=refs) + restored = decode_resolved_model_profile(row, registered_references=refs) + request = ModelRequest(request_id="test", outlet_id=12, config_revision=restored.config_revision, + messages=[ModelMessage(semantic_role="user_input", content_parts=[{"type": "text", "text": "Hello"}])]) + prepared = OpenAIChatAdapter().prepare_http_request(request, restored) + assert prepared.json_body["thinking"]["type"] == expected + assert restored.parameter_values["enable_thinking"] is value + assert restored == profile + + +def test_adapter_rejects_unavailable_transform_before_http(openai_profile): + data = openai_profile.model_dump() + data["parameter_rules"][0]["api_binding"]["transform_version"] = 2 + profile = openai_profile.__class__.model_validate(data) + request = ModelRequest(request_id="test", outlet_id=profile.outlet_id, config_revision=profile.config_revision, + messages=[ModelMessage(semantic_role="user_input", content_parts=[{"type": "text", "text": "Hello"}])]) + with pytest.raises(ModelAdapterError): + OpenAIChatAdapter().prepare_http_request(request, profile) + + +def test_registry_exports_the_same_versioned_transforms(): + assert model_service_references()["parameter_transform"] == parameter_transform_references() diff --git a/test/sdk/core/model_service/test_request_contracts.py b/test/sdk/core/model_service/test_request_contracts.py new file mode 100644 index 0000000000..1697ab9217 --- /dev/null +++ b/test/sdk/core/model_service/test_request_contracts.py @@ -0,0 +1,98 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Stable request, event and resource contracts reject ambiguous runtime state.""" + +import pytest +from pydantic import ValidationError + +from nexent.core.agent_runtime_support.contracts import RuntimeResourceView +from nexent.core.model_service.contracts import ( + AssistantMessage, ModelError, ModelEvent, ModelMessage, ModelRequest, ToolCall, ToolCallDelta, ToolDefinition, +) +from nexent.core.model_service.profiles.parameter_rules import compile_parameter_rules +from nexent.core.model_service.profiles.protocol_parameters import openai_chat_parameter_rules +from test.sdk.core.model_service.resource_fixtures import resources + + +def test_request_rejects_reused_message_ids_and_tool_names(): + message = ModelMessage(semantic_role="user") + request = dict(request_id="r", outlet_id=None, config_revision=1, messages=[message]) + assert ModelRequest(**request).messages == [message] + with pytest.raises(ValidationError, match="identities"): + ModelRequest(**{**request, "messages": [message, message]}) + tool = ToolDefinition(name="lookup", description="Lookup", input_schema={}) + with pytest.raises(ValidationError, match="tool names"): + ModelRequest(**request, tools=[tool, tool]) + + +@pytest.mark.parametrize("field", ["provider_call_id", "index"]) +def test_complete_calls_require_distinct_provider_ids_and_indexes(field): + call = ToolCall(provider_call_id="one", index=0, name="lookup", raw_arguments="{}") + other = call.model_copy(update={"provider_call_id": "two", "index": 1, field: getattr(call, field)}) + with pytest.raises(ValidationError, match="identities"): + AssistantMessage(finish_reason="tool_calls", tool_calls=[call, other]) + + +@pytest.mark.parametrize("kind", ["text_delta", "reasoning_delta", "tool_call_delta", "message_completed", "usage", "error"]) +def test_events_carry_only_their_declared_payload(kind): + message = AssistantMessage(finish_reason="stop") + values = dict(request_id="r", message_id=message.message_id, sequence=1, type=kind) + error = ModelError(category="safe", scope="protocol", retryable=False, safe_message="Failed", request_id="r") + payload = {"text_delta": {"delta": "hi"}, "reasoning_delta": {"delta": "summary"}, + "tool_call_delta": {"tool_call_delta": ToolCallDelta(local_call_id=message.message_id, index=0)}, + "message_completed": {"message": message, "finish_reason": "stop"}, + "usage": {"usage": {}}, "error": {"error": error}}[kind] + assert ModelEvent(**values, **payload).type == kind + with pytest.raises(ValidationError, match="declared payload"): + ModelEvent(**values) + with pytest.raises(ValidationError, match="declared payload"): + ModelEvent(**values, **{**payload, "delta" if kind not in {"text_delta", "reasoning_delta"} else "usage": "extra" if kind not in {"text_delta", "reasoning_delta"} else {}}) + + +@pytest.mark.parametrize("change", ["content_kind", "finish_reason", "completion_id", "completion_finish", "error_request"]) +def test_event_metadata_cannot_contradict_its_payload(change): + message = AssistantMessage(finish_reason="stop") + data = dict(request_id="r", message_id=message.message_id, sequence=1, type="usage", usage={}) + if change in {"content_kind", "finish_reason"}: + data[change] = "text" if change == "content_kind" else "stop" + elif change.startswith("completion"): + data.update(type="message_completed", message=message, finish_reason="stop") + data.pop("usage") + data["message_id" if change == "completion_id" else "finish_reason"] = ModelMessage(semantic_role="user").message_id if change == "completion_id" else "length" + else: + data.update(type="error", error=ModelError(category="safe", scope="protocol", retryable=False, safe_message="Failed", request_id="other")) + data.pop("usage") + with pytest.raises(ValidationError): + ModelEvent(**data) + + +@pytest.mark.parametrize("change", ["message_id", "tool_name", "tool_call_role", "tool_result_role"]) +def test_resource_identity_and_authorization_invariants(change): + data = resources().model_dump() + if change == "message_id":data["current_user_input"]["message"]["message_id"] = data["history"][0]["message"]["message_id"] + elif change == "tool_name":data["tool_definitions"] *= 2 + elif change == "tool_call_role":data["history"][0]["message"]["semantic_role"] = "user" + else:data["tool_observations"][0]["message"]["semantic_role"] = "assistant" + with pytest.raises(ValidationError):RuntimeResourceView.model_validate(data) + + +def test_protocol_rules_have_no_invented_defaults_and_are_detached(): + rules = openai_chat_parameter_rules() + compile_parameter_rules(rules) + assert {rule["key"] for rule in rules} == {"temperature", "top_p", "max_output_tokens", "stop", "response_format"} + assert all("default_value" not in rule for rule in rules) + rules[0]["schema"]["type"].clear() + assert openai_chat_parameter_rules()[0]["schema"]["type"] == ["number", "null"] +def test_resource_boundary_rejects_privilege_promotion_and_execution_objects(): + data = resources().model_dump() + data["references"][0]["message"]["semantic_role"] = "platform_instruction" + with pytest.raises(ValidationError, match="authorized group"): + RuntimeResourceView.model_validate(data) + data = resources().model_dump() + data["executor"] = object() + with pytest.raises(ValidationError): + RuntimeResourceView.model_validate(data) + data = resources().model_dump() + data["current_user_input"]["order"] = data["history"][0]["order"] + with pytest.raises(ValidationError, match="ordering"): + RuntimeResourceView.model_validate(data) diff --git a/test/sdk/core/model_service/test_resource_conversion.py b/test/sdk/core/model_service/test_resource_conversion.py new file mode 100644 index 0000000000..10506bddd5 --- /dev/null +++ b/test/sdk/core/model_service/test_resource_conversion.py @@ -0,0 +1,89 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Authorized resources cross a stable conversion boundary without execution.""" + +import json + +import pytest +from nexent.core.agent_runtime_support.contracts import RuntimeResourceView +from nexent.core.model_service.adapters.openai_chat import OpenAIChatAdapter +from nexent.core.model_service.resource_conversion import ( + ResourceConverterRegistry, + ResourceRequestConverter, +) +from test.sdk.core.model_service.resource_fixtures import resources + + +def test_native_conversion_preserves_every_resource_and_tool_association(openai_profile): + view = resources() + before = view.model_dump() + converted = ResourceRequestConverter(native=True).convert(view, openai_profile, request_id="request", stream=True) + assert converted.tool_protocol == "native" + request = converted.request + assert request.request_id == "request" and request.config_revision == openai_profile.config_revision + assert [m.content_parts[0]["text"] for m in request.messages] == [ + "platform", "skill", "knowledge", "progress", "result", "approved metadata", "continue", + ] + assert request.messages[3].tool_calls[0].provider_call_id == request.messages[4].tool_call_id == "call-1" + assert request.tools == view.tool_definitions + wire = OpenAIChatAdapter().prepare_http_request(request, openai_profile) + assert [message["role"] for message in wire.json_body["messages"]] == [ + "system", "system", "user", "assistant", "tool", "user", "user", + ] + assert view.model_dump() == before + view.history[0].message.content_parts[0]["text"] = "changed after conversion" + assert request.messages[3].content_parts[0]["text"] == "progress" + + +@pytest.mark.parametrize("status", ["supported", "unsupported", "unknown"]) +@pytest.mark.parametrize("native", [False, True]) +def test_text_protocol_is_selected_before_request_without_changing_resources(openai_profile, status, native): + data = openai_profile.model_dump() + data["capabilities"]["tools"]["status"] = status + profile = openai_profile.__class__.model_validate(data) + view = resources() + before = view.model_dump() + converted = ResourceRequestConverter(native=native).convert(view, profile) + actual_native = native and status == "supported" + assert converted.tool_protocol == ("native" if actual_native else "text_block") + request = converted.request + assert bool(request.tools) == actual_native + wire = OpenAIChatAdapter().prepare_http_request(request, profile) + if not actual_native: + assert all(not message.tool_calls and message.tool_call_id is None for message in request.messages) + assert all(message["role"] != "tool" for message in wire.json_body["messages"]) + blocks = [json.loads(part["text"]) for message in request.messages for part in message.content_parts + if part.get("type") == "text" and part["text"].startswith("{")] + assert {"historical_tool_calls": [{"id": "call-1", "name": "lookup", "arguments": '{"q":"x"}'}]} in blocks + assert {"historical_tool_result": "call-1"} in blocks + assert {"available_tools": [tool.model_dump() for tool in view.tool_definitions]} in blocks + assert view.model_dump() == before + + +def test_converter_registry_rejects_unknown_versions(): + registry = ResourceConverterRegistry() + for mode in ("code_converter", "native_converter"): + reference, = registry.references(mode) + assert registry.resolve(reference).reference == reference + with pytest.raises(ValueError, match="UNREGISTERED"): + registry.resolve(reference.model_copy(update={"version": 2})) + + +def test_code_observations_remain_usable_in_native_history(openai_profile): + view = resources() + values = view.model_dump() + values["history"][0]["message"]["tool_calls"] = [] + values["tool_observations"][0]["message"]["tool_call_id"] = None + converted = ResourceRequestConverter(native=True).convert(RuntimeResourceView.model_validate(values), openai_profile) + wire = OpenAIChatAdapter().prepare_http_request(converted.request, openai_profile) + assert wire.json_body["messages"][4]["role"] == "user" + assert converted.request.messages[4].content_parts[-1]["text"] == "result" + assert values["tool_observations"][0]["message"]["semantic_role"] == "tool_result" + + +def test_code_conversion_does_not_replay_provider_reasoning_or_mutate_history(openai_profile): + view = resources() + view.history[0].message.reasoning_parts.append({"type": "text", "text": "provider reasoning"}) + result = ResourceRequestConverter().convert(view, openai_profile) + assert not any(message.reasoning_parts for message in result.request.messages) + assert view.history[0].message.reasoning_parts == [{"type": "text", "text": "provider reasoning"}] diff --git a/test/sdk/core/model_service/test_thinking_configuration.py b/test/sdk/core/model_service/test_thinking_configuration.py new file mode 100644 index 0000000000..a08c22c68b --- /dev/null +++ b/test/sdk/core/model_service/test_thinking_configuration.py @@ -0,0 +1,108 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Operator thinking presets retain frozen bindings, domains and reset behavior.""" + +import pytest + +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile +from nexent.core.model_service.profiles.thinking_configuration import ( + configure_thinking, + configure_thinking_degree, +) + + +def without_reasoning(profile): + data = profile.model_dump(mode="json") + keys = {"enable_thinking", "reasoning_effort", "reasoning_budget_tokens"} + data["parameter_rules"] = [rule for rule in data["parameter_rules"] if rule["key"] not in keys] + data["parameter_values"] = {key: value for key, value in data["parameter_values"].items() if key not in keys} + data["capabilities"]["reasoning"] = {"status": "unknown", "controls": None, "response_paths": []} + return ResolvedModelProfile.model_validate(data) + + +@pytest.mark.parametrize("preset,path,transform", [ + ("enable_thinking", "/enable_thinking", "identity_v1"), + ("thinking_type", "/thinking/type", "thinking_toggle_v1"), + ("chat_template", "/chat_template_kwargs/enable_thinking", "identity_v1"), +]) +def test_explicit_switch_format_is_registered_and_reset_restores_unknown(openai_profile, preset, path, transform): + original = without_reasoning(openai_profile) + changed = configure_thinking(original, preset) + rule = next(rule for rule in changed.parameter_rules if rule["key"] == "enable_thinking") + assert rule["api_binding"] == {"path": path, "transform_ref": transform, "transform_version": 1} + assert changed.capabilities.reasoning.status == "supported" + assert changed.provenance.provider_metadata["operator_thinking"]["preset"] == preset + restored = configure_thinking(changed, "profile_default") + assert restored.model_dump() == original.model_dump() + assert original.capabilities.reasoning.status == "unknown" + + +def test_existing_switch_preserves_domain_and_order_across_format_changes(openai_profile): + original = openai_profile.model_dump() + first = configure_thinking(openai_profile, "thinking_type") + second = configure_thinking(first, "chat_template") + assert len([rule for rule in second.parameter_rules if rule["key"] == "enable_thinking"]) == 1 + assert len([item for item in second.provenance.overrides if item.source == "operator_thinking_v1"]) == 1 + restored = configure_thinking(second, "profile_default") + assert restored.model_dump() == original + assert configure_thinking(openai_profile, None) is openai_profile + assert configure_thinking(openai_profile, "profile_default") is openai_profile + + +@pytest.mark.parametrize("mode,key,path", [ + ("effort", "reasoning_effort", "/reasoning_effort"), + ("budget_tokens", "reasoning_budget_tokens", "/thinking_budget"), +]) +def test_degree_preset_on_unknown_profile_has_control_and_can_be_reset(openai_profile, mode, key, path): + original = without_reasoning(openai_profile) + changed = configure_thinking_degree(configure_thinking(original, "enable_thinking"), mode) + rule = next(rule for rule in changed.parameter_rules if rule["key"] == key) + assert rule["api_binding"]["path"] == path + assert rule["condition"] == {"key": "enable_thinking", "op": "eq", "value": True} + assert changed.capabilities.reasoning.status == "supported" + assert any(control.mode == mode for control in changed.capabilities.reasoning.controls) + restored = configure_thinking_degree(changed, "default") + assert not any(rule["key"] == key for rule in restored.parameter_rules) + assert restored.capabilities.reasoning.status == "supported" + assert configure_thinking_degree(original, None) is original + + +def test_degree_reset_without_operator_switch_restores_original_status(openai_profile): + data = openai_profile.model_dump() + data["capabilities"]["reasoning"].update(status="unknown", controls=None) + original = ResolvedModelProfile.model_validate(data) + changed = configure_thinking_degree(original, "effort") + reset = configure_thinking_degree(changed, "default") + assert reset.capabilities.reasoning.status == "unknown" + assert reset.parameter_values == original.parameter_values + assert reset.provenance.overrides == original.provenance.overrides + + +@pytest.mark.parametrize("function,value,category", [ + (configure_thinking, "arbitrary_field", "THINKING_FORMAT_UNKNOWN"), + (configure_thinking_degree, "arbitrary_mode", "THINKING_MODE_UNKNOWN"), +]) +def test_unknown_operator_presets_are_rejected_without_mutating_profile(openai_profile, function, value, category): + before = openai_profile.model_dump() + with pytest.raises(ParameterValidationError, match=category): + function(openai_profile, value) + assert openai_profile.model_dump() == before + + +def test_declared_budget_binding_and_domain_survive_operator_selection(openai_profile): + original = next(rule for rule in openai_profile.parameter_rules if rule["key"] == "reasoning_budget_tokens") + selected = configure_thinking_degree(openai_profile, "budget_tokens") + actual = next(rule for rule in selected.parameter_rules if rule["key"] == "reasoning_budget_tokens") + assert actual == original + assert configure_thinking_degree(selected, "default").parameter_rules == openai_profile.parameter_rules + + +def test_switching_degree_does_not_silently_discard_fixed_budget(openai_profile): + data = openai_profile.model_dump() + data["parameter_values"].update(enable_thinking=True, reasoning_budget_tokens=4096) + next(rule for rule in data["parameter_rules"] if rule["key"] == "reasoning_budget_tokens")["editable"] = False + fixed = ResolvedModelProfile.model_validate(data) + with pytest.raises(ParameterValidationError, match="FIXED_PARAMETER"): + configure_thinking_degree(fixed, "effort") + assert fixed.parameter_values["reasoning_budget_tokens"] == 4096