diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index c23e75d5d8..f86e84770f 100644 --- a/backend/agents/create_agent_info.py +++ b/backend/agents/create_agent_info.py @@ -11,6 +11,13 @@ from typing import Any, Dict, List, Optional from urllib.parse import urljoin +from nexent.core.model_service.compatibility.legacy_config import ( + COMMON_REASONING_LEVELS, + legacy_extra_body, + legacy_reasoning_budget, + legacy_reasoning_effort, + legacy_thinking_enabled, +) from nexent.core.utils.observer import MessageObserver from nexent.core.agents.prompt.user_context import render_user_context from nexent.core.concurrency import run_blocking @@ -52,7 +59,7 @@ from database.a2a_agent_db import PROTOCOL_JSONRPC from management.services.configuration.user.memory import build_memory_context from management.services.knowledge_base.providers.aidp import create_ind_aidp_image_url_builder -from management.services.model.adapter import get_llm_adapter, get_vlm_adapter +from management.services.model.adapter import get_llm_adapter, get_vlm_adapter, restore_model_snapshot from database.agent_db import ( search_agent_info_by_agent_id, query_sub_agent_relations, @@ -192,6 +199,7 @@ def _ensure_agent_reasoning_snapshot( requested_overrides=current_overrides, existing_overrides=current_overrides, tenant_id=agent_info.get("tenant_id") or tenant_id, + normalize_profiles=False, ) if updated_overrides == current_overrides: return @@ -286,40 +294,6 @@ def _build_effective_knowledge_base_summary( "tokenizer_family", ) -COMMON_REASONING_LEVELS = ("low", "medium", "high") -COMMON_REASONING_DEFAULT = "auto" - -def _is_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 _build_extra_body(extra_params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: - """Build extra_body for ModelConfig from model_record_t.extra_params. - - Merges __custom__ user-defined params into the top level so they flow - into OpenAI chat.completions.create request body. Returns None when - there is nothing to pass through so production behaviour is unchanged. - """ - 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 _resolve_reasoning_capability( model_name: str, base_url: Optional[str], @@ -337,56 +311,6 @@ def _resolve_reasoning_capability( ) -def _resolve_model_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 _is_thinking_enabled(extra_params): - return None - if extra_params.get("reasoning_effort") == "auto": - return None - if isinstance(capability, dict) and capability.get("status") == "supported": - levels = capability.get("levels") or list(COMMON_REASONING_LEVELS) - default = capability.get("default") - if default not in levels: - default = COMMON_REASONING_DEFAULT - else: - # Unknown/custom model IDs still use the common generic profile. The - # provider remains the source of truth when a concrete value is sent. - levels = list(COMMON_REASONING_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 _resolve_model_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 _is_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 - # Per-process dedup for the "model has no capacity configured" warning. # Without this, every agent run logs the same line, drowning real signal. # Keyed by model_id; cleared only on process restart. @@ -443,6 +367,9 @@ def _operator_overrides_from_model_info(model_info: Optional[dict]) -> dict: value = model_info.get(field) if value is not None: overrides[field] = value + if (model_info.get("profile_schema_version") is not None + and model_info.get("default_max_output_tokens") is not None): + overrides["default_output_reserve_tokens"] = model_info["default_max_output_tokens"] return overrides @@ -495,6 +422,14 @@ def _dominant_capacity_source(field_sources: dict) -> Optional[str]: return values[0] +def _runtime_capacity_overrides(agent_info, model_id, runtime_output_tokens): + """Use the validated root preference as this run's baseline, retaining hard limits.""" + overrides = _agent_capacity_overrides(agent_info, model_id) + if runtime_output_tokens is not None: + overrides["default_output_reserve_tokens"] = runtime_output_tokens + return overrides + + def _capacity_snapshot_for_monitoring(snapshot: Any) -> dict: data = snapshot.model_dump() if hasattr(snapshot, "model_dump") else dict(snapshot) return { @@ -601,11 +536,18 @@ def _resolve_input_budget( operator_overrides = _operator_overrides_from_model_info(model_info) if capacity_overrides: operator_overrides.update(capacity_overrides) + # A smaller Agent window also bounds inherited service limits in this + # run's budget view; the saved outlet and its hard limits stay intact. + window = capacity_overrides.get("context_window_tokens") + if model_info.get("profile_schema_version") is not None and type(window) is int: + for key in ("max_input_tokens", "max_output_tokens"): + if key not in capacity_overrides and type(operator_overrides.get(key)) is int: + operator_overrides[key] = min(operator_overrides[key], window) snapshot = resolve_capacity( model_id=model_id, provider=provider, operator_overrides=operator_overrides, - capability_profiles=CAPABILITY_CATALOG, + capability_profiles={} if model_info.get("profile_schema_version") is not None else CAPABILITY_CATALOG, ) logger.debug( "Capacity resolved for (%s, %s): input_limit=%s source=%s profile=%s fingerprint=%s", @@ -1204,8 +1146,39 @@ def _get_skill_script_tools( return [] +def _resolved_model_config(record, snapshot, cite_name=None): + """Map one frozen outlet into the existing Agent configuration contract.""" + from services.model_outlet_projection import project_saved_outlet + + output_reserve = snapshot.parameter_values.get("max_output_tokens") + if output_reserve is None: + output_reserve = snapshot.context_configuration.output_reserve_tokens + return ModelConfig( + cite_name=cite_name if cite_name is not None else record["display_name"], api_key=record.get("api_key") or "", + model_name=snapshot.identity.request_model_id, url=snapshot.connection.base_url, + resolved_profile=snapshot, model_factory=snapshot.identity.provider_id, + reasoning_capability=project_saved_outlet(record)["reasoning_capability"], + temperature=snapshot.parameter_values.get("temperature"), top_p=snapshot.parameter_values.get("top_p"), + enable_thinking=snapshot.parameter_values.get("enable_thinking") is True, + reasoning_effort=(snapshot.parameter_values.get("reasoning_effort") + if snapshot.parameter_values.get("reasoning_effort") in + {"auto", "none", "minimal", "low", "medium", "high", "xhigh", "max"} else None), + reasoning_budget_tokens=snapshot.parameter_values.get("reasoning_budget_tokens"), + context_window_tokens=snapshot.capabilities.context.combined_window, + max_input_tokens=snapshot.capabilities.context.input_limit, + max_output_tokens=snapshot.capabilities.context.output_limit, + default_output_reserve_tokens=output_reserve, + tokenizer_family=snapshot.context_configuration.tokenizer_family, + capacity_source=snapshot.context_configuration.capacity_source, + capability_profile_version=snapshot.context_configuration.capability_profile_version, + ) + + def create_model_config_from_record(record): """Build a complete provider binding from one exact tenant model record.""" + snapshot = restore_model_snapshot(record, record.get("tenant_id")) + if snapshot is not None: + return _resolved_model_config(record, snapshot) model_name = add_repo_to_name( model_repo=record["model_repo"], model_name=record["model_name"], @@ -1240,50 +1213,48 @@ def create_model_config_from_record(record): # temperature/top_p/extra_params flow into SDK. temperature=record.get("temperature"), top_p=record.get("top_p"), - enable_thinking=_is_thinking_enabled(record.get("extra_params")), + enable_thinking=legacy_thinking_enabled(record.get("extra_params")), reasoning_capability=reasoning_capability, - reasoning_effort=_resolve_model_reasoning_effort( + reasoning_effort=legacy_reasoning_effort( record.get("extra_params"), reasoning_capability ), - reasoning_budget_tokens=_resolve_model_reasoning_budget( + reasoning_budget_tokens=legacy_reasoning_budget( record.get("extra_params"), reasoning_capability ), - extra_body=_build_extra_body(record.get("extra_params"))) + extra_body=legacy_extra_body(record.get("extra_params"))) async def create_model_config_list(tenant_id): records = get_model_records({"model_type": "llm"}, tenant_id) - model_list = [create_model_config_from_record(record) for record in records] + model_list = [create_model_config_from_record(record) for record in records if record.get("model_id") is None or record.get("profile_schema_version") is not None] extra_body = {"logprobs": True} if LLM_INCLUDE_LOGPROBS else None # fit for old version, main_model and sub_model use default model main_model_config = tenant_config_manager.get_model_config( key=MODEL_CONFIG_MAPPING["llm"], tenant_id=tenant_id) - main_prompt_cache = resolve_prompt_cache_profile( - main_model_config.get("model_factory")) - model_list.append( - ModelConfig(cite_name="main_model", - api_key=main_model_config.get("api_key", ""), - model_name=get_model_name_from_config(main_model_config) if main_model_config.get( - "model_name") else "", - url=main_model_config.get("base_url", ""), - ssl_verify=main_model_config.get("ssl_verify", True), - model_factory=main_model_config.get("model_factory"), - timeout_seconds=main_model_config.get("timeout_seconds"), - concurrency_limit=main_model_config.get("concurrency_limit"), - prompt_cache=main_prompt_cache, - extra_body=extra_body)) - model_list.append( - ModelConfig(cite_name="sub_model", - api_key=main_model_config.get("api_key", ""), - model_name=get_model_name_from_config(main_model_config) if main_model_config.get( - "model_name") else "", - url=main_model_config.get("base_url", ""), - ssl_verify=main_model_config.get("ssl_verify", True), - model_factory=main_model_config.get("model_factory"), - timeout_seconds=main_model_config.get("timeout_seconds"), - concurrency_limit=main_model_config.get("concurrency_limit"), - prompt_cache=main_prompt_cache, - extra_body=extra_body)) + # Reuse the row already captured for this run, including its credential and revision. + if main_model_config.get("profile_schema_version") is not None: + main_model_config = next((record for record in records + if record.get("model_id") == main_model_config.get("model_id")), main_model_config) + if (main_model_config.get("model_id") is not None + and main_model_config.get("profile_schema_version") is None): + # A failed default outlet is not an implicit fallback for a selected outlet. + return model_list + main_snapshot = restore_model_snapshot(main_model_config, tenant_id) + if main_snapshot is not None: + model_list.extend(_resolved_model_config(main_model_config, main_snapshot, cite_name=alias) + for alias in ("main_model", "sub_model")) + else: + main_prompt_cache = resolve_prompt_cache_profile(main_model_config.get("model_factory")) + for alias in ("main_model", "sub_model"): + model_list.append(ModelConfig( + cite_name=alias, api_key=main_model_config.get("api_key", ""), + model_name=get_model_name_from_config(main_model_config) if main_model_config.get("model_name") else "", + url=main_model_config.get("base_url", ""), ssl_verify=main_model_config.get("ssl_verify", True), + model_factory=main_model_config.get("model_factory"), + timeout_seconds=main_model_config.get("timeout_seconds"), + concurrency_limit=main_model_config.get("concurrency_limit"), + prompt_cache=main_prompt_cache, extra_body=extra_body, + )) return model_list @@ -1347,6 +1318,8 @@ async def create_agent_config( runtime_skill_snapshot: Optional[List[Dict[str, Any]]] = None, runtime_knowledge_tools: Optional[List[Dict[str, Any]]] = None, runtime_sub_agent_mounts: Optional[List[Dict[str, Any]]] = None, + runtime_model_records: Optional[Dict[int, Dict[str, Any]]] = None, + runtime_output_tokens: int | None = None, ): normalized_tool_params = _normalize_tool_params_request(tool_params) agent_info = search_agent_info_by_agent_id( @@ -1394,6 +1367,7 @@ async def create_agent_config( runtime_file_context=runtime_file_context, # Workbench overlays replace Skills on the effective root only. runtime_skill_snapshot=None, + runtime_model_records=runtime_model_records, ) # Persisted Agent relations are rendered into the manager prompt by # their configured business name. Keep the callable registered in the @@ -1428,6 +1402,7 @@ async def create_agent_config( runtime_knowledge_context=child_knowledge_context, runtime_file_context=runtime_file_context, runtime_skill_snapshot=None, + runtime_model_records=runtime_model_records, runtime_knowledge_tools=None, runtime_sub_agent_mounts=None, ) @@ -1807,7 +1782,10 @@ async def create_agent_config( model_id_to_use = _select_agent_model_id(agent_model_ids, override_model_id, tenant_id) model_info = None if model_id_to_use is not None: - model_info = get_model_by_model_id(model_id_to_use, tenant_id=tenant_id) + model_info = (runtime_model_records.get(model_id_to_use) if runtime_model_records is not None + else get_model_by_model_id(model_id_to_use, tenant_id=tenant_id)) + if model_info is None and override_model_id is not None: + raise ValidationError("MODEL_OUTLET_NOT_AVAILABLE") model_name = model_info["display_name"] if model_info is not None else "main_model" # W1 step 6: derive input budget via ModelCapacityResolver instead of # treating model_info["max_tokens"] (a deprecated output cap) as a @@ -1817,8 +1795,8 @@ async def create_agent_config( input_budget, capacity_snapshot, resolved_capacity_snapshot = ( _resolve_input_budget( model_info, - capacity_overrides=_agent_capacity_overrides( - agent_info, model_id_to_use), + capacity_overrides=_runtime_capacity_overrides( + agent_info, model_id_to_use, runtime_output_tokens), ) ) else: @@ -1827,7 +1805,8 @@ async def create_agent_config( capacity_snapshot = None resolved_capacity_snapshot = None - requested_output_tokens = agent_info.get("requested_output_tokens") + requested_output_tokens = (runtime_output_tokens if runtime_output_tokens is not None + else agent_info.get("requested_output_tokens")) context_budget_snapshot = _resolve_context_budget( capacity_snapshot=resolved_capacity_snapshot, tenant_id=tenant_id, @@ -2585,23 +2564,32 @@ def check_agent_tools(agent_config: AgentConfig): return list(used_mcp_urls) -def apply_root_generation_overlay( - model_list: List[ModelConfig], - agent_config: AgentConfig, - generation_config: Optional[Dict[str, Any]], -) -> None: - """Add a root-only model alias without changing child Agent aliases.""" - if not generation_config: +def apply_root_model_preferences(model_list, agent_config, preferences, *, outlet_profiles=None, agent_overrides=None): + """Give the root a private alias; never change a child Agent's model defaults.""" + if not preferences: return - from management.services.model.run_controls import overlay_root_model - for model_config in model_list: - if model_config.cite_name != agent_config.model_name: + from services.conversation_model_preferences_service import merge_model_preferences + + for configured in model_list: + profile = getattr(configured, "resolved_profile", None) + if configured.cite_name != agent_config.model_name or profile is None: continue - root_model = overlay_root_model( - model_config, generation_config, "workbench_root_model", - ) - model_list.append(root_model) - agent_config.model_name = root_model.cite_name + values = preferences.get(str(profile.outlet_id)) + if values is None: + return + original = (outlet_profiles or {}).get(profile.outlet_id) + if original is not None: + from services.agent_model_parameters import merge_runtime_model_parameters + + resolved = merge_runtime_model_parameters(original, (agent_overrides or {}).get(str(profile.outlet_id), {}), values) + else: + resolved = merge_model_preferences(profile, values) + root = configured.model_copy(deep=True, update={"cite_name": "conversation_root_model_" + uuid.uuid4().hex, + "resolved_profile": resolved, + "default_output_reserve_tokens": resolved.parameter_values.get("max_output_tokens", + configured.default_output_reserve_tokens)}) + model_list.append(root) + agent_config.model_name = root.cite_name return @@ -2617,8 +2605,6 @@ async def create_agent_run_info( is_debug: bool = False, override_version_no: int | None = None, override_model_id: int | None = None, - reasoning_effort: str | None = None, - reasoning_budget_tokens: int | None = None, requested_output_tokens: int | None = None, tool_params: Optional[ToolParamsRequest | Dict[str, Any]] = None, conversation_id: Optional[int] = None, @@ -2628,8 +2614,8 @@ async def create_agent_run_info( runtime_knowledge_context: Optional[Dict[str, str]] = None, runtime_skill_snapshot: Optional[List[Dict[str, Any]]] = None, runtime_knowledge_tools: Optional[List[Dict[str, Any]]] = None, - runtime_generation_config: Optional[Dict[str, Any]] = None, runtime_sub_agent_mounts: Optional[List[Dict[str, Any]]] = None, + model_parameter_preferences: Optional[Dict[str, Dict[str, Any]]] = None, ): if override_model_id is not None: from management.services.model.run_controls import validate_selected_record @@ -2713,6 +2699,33 @@ async def create_agent_run_info( create_config_kwargs["runtime_knowledge_tools"] = runtime_knowledge_tools if runtime_sub_agent_mounts is not None: create_config_kwargs["runtime_sub_agent_mounts"] = runtime_sub_agent_mounts + from nexent.core.model_service.profiles.outlet_storage import encode_resolved_model_profile + from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile + captured_records = {} + outlet_profiles = {} + for configured in model_list: + if isinstance(getattr(configured, "resolved_profile", None), ResolvedModelProfile): + from nexent.core.model_service.registry import model_service_references + + outlet_profiles[configured.resolved_profile.outlet_id] = configured.resolved_profile + captured = encode_resolved_model_profile(configured.resolved_profile, registered_references=model_service_references()) + if captured.get("model_id") is not None: + captured_records[captured["model_id"]] = captured + if captured_records: + create_config_kwargs["runtime_model_records"] = captured_records + if model_parameter_preferences: + selected_info = search_agent_info_by_agent_id(agent_id=agent_id, tenant_id=tenant_id, version_no=version_no) + selected_id = _select_agent_model_id((selected_info or {}).get("model_ids") or [], override_model_id, tenant_id) + selected_preferences = model_parameter_preferences.get(str(selected_id), {}) + if selected_preferences.get("max_output_tokens") is not None: + from services.conversation_model_preferences_service import merge_model_preferences + + for configured in model_list: + profile = getattr(configured, "resolved_profile", None) + if isinstance(profile, ResolvedModelProfile) and profile.outlet_id == selected_id: + validated = merge_model_preferences(profile, selected_preferences) + create_config_kwargs["runtime_output_tokens"] = validated.parameter_values["max_output_tokens"] + break agent_config = await create_agent_config(**create_config_kwargs, tool_params=tool_params) # v2.6.0: Apply per-agent model params override to model_config_list. @@ -2723,6 +2736,11 @@ async def create_agent_run_info( _agent_info_for_override = search_agent_info_by_agent_id( agent_id=agent_id, tenant_id=tenant_id, version_no=version_no) model_params_override = (_agent_info_for_override or {}).get("model_params_override") or {} + from services.agent_model_parameters import bind_agent_model_profiles + + bind_agent_model_profiles(model_list, agent_config, _agent_info_for_override or {}, + lambda selected_agent_id, selected_version: search_agent_info_by_agent_id( + agent_id=selected_agent_id, tenant_id=tenant_id, version_no=selected_version)) if model_params_override: for model_id_str, override_entry in model_params_override.items(): if not isinstance(override_entry, dict): @@ -2738,6 +2756,8 @@ async def create_agent_run_info( if not display_name: continue for mc in model_list: + if isinstance(getattr(mc, "resolved_profile", None), ResolvedModelProfile): + continue # Saved outlets were bound independently for each Agent above. if mc.cite_name == display_name: if override_entry.get("temperature") is not None: mc.temperature = override_entry["temperature"] @@ -2789,31 +2809,6 @@ async def create_agent_run_info( if isinstance(budget, int) and not isinstance(budget, bool) and budget > 0: mc.reasoning_budget_tokens = budget break - # Request-scoped Workbench settings apply only to the root model. Static - # children retain the model aliases from their published versions. - # Static children keep their published aliases and never inherit this overlay. - apply_root_generation_overlay( - model_list, - agent_config, - runtime_generation_config, - ) - - # Legacy top-level fields use the same capability policy as generation overlays. - if reasoning_effort is not None or reasoning_budget_tokens is not None: - from management.services.model.run_controls import overlay_root_model - selected_config = next((m for m in model_list if m.cite_name == agent_config.model_name), None) - if selected_config is None: - from consts.exceptions import RunModelControlsError - raise RunModelControlsError("model_not_available") - legacy = {} - if reasoning_effort is not None: - legacy["thinking_effort"] = reasoning_effort - if reasoning_budget_tokens is not None: - legacy["reasoning_budget_tokens"] = reasoning_budget_tokens - overlay = overlay_root_model(selected_config, legacy, selected_config.cite_name) - index = model_list.index(selected_config) - model_list[index] = overlay - remote_mcp_list = await get_remote_mcp_server_list(tenant_id=tenant_id, is_need_auth=True) default_mcp_url = get_tenant_local_mcp_server(tenant_id) remote_mcp_list.append({ @@ -2900,6 +2895,9 @@ async def create_agent_run_info( "shared_workspace": True, }) + apply_root_model_preferences(model_list, agent_config, model_parameter_preferences, + outlet_profiles=outlet_profiles, agent_overrides=model_params_override) + agent_run_info = AgentRunInfo( query=final_query, model_config_list=model_list, diff --git a/backend/apps/agent_app.py b/backend/apps/agent_app.py index d2475eaed9..d0fc360976 100644 --- a/backend/apps/agent_app.py +++ b/backend/apps/agent_app.py @@ -9,6 +9,7 @@ from fastapi import APIRouter, Body, Depends, File, Header, HTTPException, Query, Request, UploadFile from fastapi.encoders import jsonable_encoder from nexent.core.concurrency import run_blocking +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError from starlette.responses import JSONResponse, Response, StreamingResponse from consts.const import ASSET_OWNER_TENANT_ID, ENABLE_AGENT_WORKBENCH @@ -356,6 +357,13 @@ async def nl2agent_run_api( _, tenant_id, language = get_current_user_info( authorization, http_request ) + if nl2agent_request.conversation_id is not None: + from services.conversation_model_preferences_service import read_model_preferences + preference_user, _ = get_current_user_id(authorization) + saved = await run_blocking("creator-model-preferences", read_model_preferences, nl2agent_request.conversation_id, preference_user) + nl2agent_request.__dict__["_runtime_model_parameter_preferences"] = saved["model_parameter_preferences"] + if nl2agent_request.model_id is None: + nl2agent_request.model_id = saved.get("selected_model_id") stream = await create_nl2agent_stream( request=nl2agent_request, tenant_id=tenant_id, @@ -370,6 +378,8 @@ async def nl2agent_run_api( query=nl2agent_request.query, minio_files=nl2agent_request.minio_files, workbench_config=nl2agent_request.workbench_config, + model_parameter_preferences=nl2agent_request.model_parameter_preferences, + selected_model_id=nl2agent_request.model_id, agent_id=nl2agent_request.agent_id, user_id=user_id, tenant_id=tenant_id, @@ -547,6 +557,9 @@ async def update_agent_info_api( user_id=user_id, tenant_id=tenant_id, details=audit_details) return result or {} + except ParameterValidationError as exc: + raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail={"code": exc.category, "field": exc.path}) from None except AgentRelationValidationError as exc: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) diff --git a/backend/apps/conversation_management_app.py b/backend/apps/conversation_management_app.py index e14b40ca91..7876d7ecf1 100644 --- a/backend/apps/conversation_management_app.py +++ b/backend/apps/conversation_management_app.py @@ -1,4 +1,5 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + import logging from http import HTTPStatus from typing import Annotated, Any, Dict, Optional @@ -8,6 +9,8 @@ from consts.model import ( BatchDeleteConversationRequest, + ConversationModelPreferencePatch, + ConversationModelSelectionPatch, ConversationKnowledgeScopeUpdateRequest, WorkbenchConfigUpdateRequest, ConversationRequest, @@ -19,6 +22,8 @@ ) from consts.exceptions import ( AppException, + ModelOutletConflictError, + ModelOutletValidationError, ConversationNotFoundError, ValidationError, TokenExpiredError, @@ -297,6 +302,77 @@ async def update_conversation_workbench_config_endpoint( raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail={"code": "WORKBENCH_CONFIG_INVALID"}) from exc +@router.get("/{conversation_id}/model_preferences", response_model=ConversationResponse) +async def get_conversation_model_preferences(conversation_id: int, authorization: Optional[str] = Header(None)): + from nexent.core.concurrency import run_blocking + from services.conversation_model_preferences_service import read_model_preferences + + user_id, _ = get_current_user_id(authorization) + try: + result = await run_blocking("conversation-model-preferences-read", read_model_preferences, conversation_id, user_id) + return ConversationResponse(code=0, message="success", data=result) + except ConversationNotFoundError: + raise HTTPException(status_code=404, detail="CONVERSATION_NOT_FOUND") from None + + +@router.patch("/{conversation_id}/model_preferences", response_model=ConversationResponse) +async def patch_conversation_model_selection(conversation_id: int, request: ConversationModelSelectionPatch, + authorization: Optional[str] = Header(None)): + from nexent.core.concurrency import run_blocking + from services.conversation_model_preferences_service import patch_model_selection + from consts.exceptions import ForbiddenError, RunModelControlsError + + user_id, tenant_id = get_current_user_id(authorization) + try: + result = await run_blocking("conversation-model-selection-update", patch_model_selection, + conversation_id, request, user_id, tenant_id) + return ConversationResponse(code=0, message="success", data=result) + except ConversationNotFoundError: + raise HTTPException(status_code=404, detail="CONVERSATION_NOT_FOUND") from None + except ModelOutletConflictError: + raise HTTPException(status_code=409, detail={"code": "MODEL_PREFERENCE_VERSION_CONFLICT"}) from None + except ForbiddenError: + raise HTTPException(status_code=403, detail="MODEL_SELECTION_FORBIDDEN") from None + except RunModelControlsError as error: + status = {"model_not_available": 404, "model_unavailable": 409, "model_type_invalid": 400}.get(error.code, 422) + raise HTTPException(status_code=status, detail={"code": error.code}) from None + + +@router.get("/{conversation_id}/model_preferences/{model_id}", response_model=ConversationResponse) +async def get_conversation_model_preference_view(conversation_id: int, model_id: int, authorization: Optional[str] = Header(None)): + from nexent.core.concurrency import run_blocking + from services.conversation_model_preferences_service import read_model_preference_view + + user_id, tenant_id = get_current_user_id(authorization) + try: + result = await run_blocking("conversation-model-preference-view", read_model_preference_view, + conversation_id, model_id, user_id, tenant_id) + return ConversationResponse(code=0, message="success", data=result) + except ConversationNotFoundError: + raise HTTPException(status_code=404, detail="CONVERSATION_NOT_FOUND") from None + except ModelOutletValidationError as error: + raise HTTPException(status_code=422, detail={"code": error.category, "field": error.path}) from None + + +@router.patch("/{conversation_id}/model_preferences/{model_id}", response_model=ConversationResponse) +async def patch_conversation_model_preferences(conversation_id: int, model_id: int, + request: ConversationModelPreferencePatch, authorization: Optional[str] = Header(None)): + from nexent.core.concurrency import run_blocking + from services.conversation_model_preferences_service import patch_model_preferences + + user_id, tenant_id = get_current_user_id(authorization) + try: + result = await run_blocking("conversation-model-preferences-update", patch_model_preferences, + conversation_id, model_id, request, user_id, tenant_id) + return ConversationResponse(code=0, message="success", data=result) + except ConversationNotFoundError: + raise HTTPException(status_code=404, detail="CONVERSATION_NOT_FOUND") from None + except ModelOutletConflictError: + raise HTTPException(status_code=409, detail={"code": "MODEL_PREFERENCE_VERSION_CONFLICT"}) from None + except ModelOutletValidationError as error: + raise HTTPException(status_code=422, detail={"code": error.category, "field": error.path}) from None + + @router.post("/sources", response_model=Dict[str, Any]) async def get_sources_endpoint(request: Dict[str, Any], authorization: Optional[str] = Header(None)): """ diff --git a/backend/apps/skill_app.py b/backend/apps/skill_app.py index 28e36fab50..5364fa4267 100644 --- a/backend/apps/skill_app.py +++ b/backend/apps/skill_app.py @@ -7,6 +7,7 @@ from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, Request, UploadFile +from nexent.core.concurrency import run_blocking from pydantic import BaseModel, Field from starlette.responses import JSONResponse, StreamingResponse @@ -814,6 +815,13 @@ async def nl2skill_run_api( raise HTTPException(status_code=401, detail="Unauthorized") try: + if request.conversation_id is not None: + from services.conversation_model_preferences_service import read_model_preferences + preference_user, _ = get_current_user_id(authorization) + saved = await run_blocking("creator-model-preferences", read_model_preferences, request.conversation_id, preference_user) + request.__dict__["_runtime_model_parameter_preferences"] = saved["model_parameter_preferences"] + if request.model_id is None: + request.model_id = saved.get("selected_model_id") stream = await create_nl2skill_stream( request=request, tenant_id=tenant_id, @@ -827,6 +835,8 @@ async def nl2skill_run_api( query=request.query, minio_files=request.minio_files, workbench_config=request.workbench_config, + model_parameter_preferences=request.model_parameter_preferences, + selected_model_id=request.model_id, agent_id=None, user_id=user_id, tenant_id=tenant_id, diff --git a/backend/configs/model_reasoning_wire_profiles.example.json b/backend/configs/model_reasoning_wire_profiles.example.json deleted file mode 100644 index db6523e9a4..0000000000 --- a/backend/configs/model_reasoning_wire_profiles.example.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "profiles": [ - { - "base_url": "https://verified-gateway.example/v1", - "model_id": "verified-model-id", - "toggle_wire_format": "thinking_object" - } - ] -} diff --git a/backend/consts/const.py b/backend/consts/const.py index 5320a73a09..21fe6c2cf4 100644 --- a/backend/consts/const.py +++ b/backend/consts/const.py @@ -947,11 +947,6 @@ def _resolve_app_version(default: str = "v2.2.1") -> str: NEXENT_LITELLM_JSON = os.environ.get("NEXENT_LITELLM_JSON", "/opt/nexent/litellm_models.json") -# Exact, operator-verified endpoint/model reasoning transport overrides. -MODEL_REASONING_WIRE_PROFILES_PATH = os.getenv( - "MODEL_REASONING_WIRE_PROFILES_PATH", - os.path.join(os.path.dirname(__file__), "..", "configs", "model_reasoning_wire_profiles.json"), -) MODEL_RESPONSE_DIAGNOSTIC_CHUNKS = os.getenv( "NEXENT_MODEL_DIAGNOSTIC_CHUNKS", os.getenv("NEXENT_PROTOCOL_DIAGNOSTIC_CONTENT", "0") ) == "1" diff --git a/backend/consts/model.py b/backend/consts/model.py index 228d1cdcab..ed0905c169 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -1055,30 +1055,14 @@ class RuntimeSkillMount(BaseModel): config_values: Dict[str, Any] = Field(default_factory=dict) -class WorkbenchGenerationConfig(BaseModel): - """Provider-neutral generation settings persisted with the conversation.""" - - model_config = ConfigDict(extra="forbid") - - deep_thinking: bool = False - thinking_effort: Literal["auto", "none", "minimal", "low", "medium", "high", "xhigh", "max"] = "low" - reasoning_budget_tokens: Optional[StrictInt] = Field(default=None, ge=0) - temperature: Optional[float] = Field(default=None, ge=0, le=2, strict=True, allow_inf_nan=False) - top_p: Optional[float] = Field(default=None, ge=0, le=1, strict=True, allow_inf_nan=False) - requested_output_tokens: Optional[int] = Field(default=None, gt=0) - - class WorkbenchSessionConfig(BaseModel): """Canonical, persisted Workbench declaration; resolved artifacts are excluded.""" model_config = ConfigDict(extra="forbid") - schema_version: Literal[3] = 3 + schema_version: Literal[4] = 4 mode: WorkbenchMode - model_id: Optional[int] = Field(default=None, gt=0) - generation_config: WorkbenchGenerationConfig = Field( - default_factory=WorkbenchGenerationConfig - ) + requested_output_tokens: Optional[int] = Field(default=None, gt=0) agent_mounts: List[RuntimeAgentMount] = Field(default_factory=list, max_length=8) skill_mounts: List[RuntimeSkillMount] = Field(default_factory=list, max_length=20) knowledge_scope: Optional[ConversationKnowledgeScopeRequest] = None @@ -1129,6 +1113,16 @@ def reject_legacy_agent_fields(value): return value +def reject_temporary_model_controls(value): + """Reject the removed parallel contract instead of silently dropping intent.""" + if isinstance(value, dict) and {"generation_config", "reasoning_effort", "reasoning_budget_tokens"}.intersection(value): + raise ValueError("Use model_parameter_preferences for conversation model parameters") + if (isinstance(value, dict) and value.get("conversation_id") is not None + and value.get("model_parameter_preferences") and value.get("is_debug") is not True): + raise ValueError("Save existing conversation parameters through the preference endpoint") + return value + + class AgentRequest(BaseModel): query: str conversation_id: Optional[int] = None @@ -1136,23 +1130,8 @@ class AgentRequest(BaseModel): # Complete list of attachment information minio_files: Optional[List[Dict[str, Any]]] = None agent_id: Optional[int] = None - model_id: Optional[int] = None - generation_config: Optional[WorkbenchGenerationConfig] = None - reasoning_effort: Optional[Literal["auto", "none", "minimal", "low", "medium", "high", "xhigh", "max"]] = Field( - default=None, - description=( - "Optional per-run reasoning effort. None inherits the selected model " - "or Agent default." - ), - ) - reasoning_budget_tokens: Optional[int] = Field( - default=None, - gt=0, - description=( - "Optional per-run reasoning token budget. None inherits the selected " - "model or Agent default." - ), - ) + model_id: Optional[StrictInt] = Field(default=None, gt=0) + model_parameter_preferences: Dict[str, Dict[str, Any]] = Field(default_factory=dict) requested_output_tokens: Optional[int] = Field(default=None, gt=0) version_no: Optional[int] = None is_debug: Optional[bool] = False @@ -1178,7 +1157,7 @@ class AgentRequest(BaseModel): @model_validator(mode="before") @classmethod def reject_legacy_execution_controls(cls, value): - return reject_legacy_agent_fields(value) + return reject_temporary_model_controls(reject_legacy_agent_fields(value)) @field_validator("context_policy") @classmethod @@ -1203,72 +1182,16 @@ def validate_workbench_entrypoint(self): ) -class RuntimeGenerationOverrides(BaseModel): - """Partial generation intent; absent fields never materialize defaults.""" - - model_config = ConfigDict(extra="forbid") - deep_thinking: Optional[StrictBool] = None - thinking_effort: Optional[Literal["auto", "none", "minimal", "low", "medium", "high", "xhigh", "max"]] = None - reasoning_budget_tokens: Optional[StrictInt] = Field(default=None, ge=0) - temperature: Optional[float] = Field(default=None, ge=0, le=2, strict=True, allow_inf_nan=False) - top_p: Optional[float] = Field(default=None, ge=0, le=1, strict=True, allow_inf_nan=False) - - @field_validator("deep_thinking", "thinking_effort", "reasoning_budget_tokens", mode="before") - @classmethod - def reject_explicit_null(cls, value): - if value is None: - raise ValueError("A reasoning override cannot be null; omit it to inherit") - return value - - class NL2ModelControlsRequest(BaseModel): - """Shared optional controls for direct and Workbench creator turns.""" + """The same per-model preferences used by every Agent conversation.""" model_id: Optional[StrictInt] = Field(default=None, gt=0) - generation_config: Optional[RuntimeGenerationOverrides] = None - reasoning_effort: Optional[Literal["auto", "none", "minimal", "low", "medium", "high", "xhigh", "max"]] = None - reasoning_budget_tokens: Optional[StrictInt] = Field(default=None, ge=0) + model_parameter_preferences: Dict[str, Dict[str, Any]] = Field(default_factory=dict) @model_validator(mode="before") @classmethod - def reconcile_model_controls(cls, value): - if not isinstance(value, dict): - return value - value = dict(value) - overlay = value.get("generation_config") - if isinstance(overlay, BaseModel): - overlay = overlay.model_dump(exclude_unset=True) - if overlay is not None and not isinstance(overlay, dict): - return value - overlay = dict(overlay or {}) - snapshot = value.get("workbench_config") - if isinstance(snapshot, dict): - snapshot_model = snapshot.get("model_id") - if snapshot_model is not None: - if value.get("model_id") is not None and value["model_id"] != snapshot_model: - raise ValueError("model_controls_invalid: conflicting model snapshot") - value["model_id"] = snapshot_model - saved = snapshot.get("generation_config") - if isinstance(saved, dict): - for key in ("deep_thinking", "thinking_effort", "reasoning_budget_tokens", "temperature", "top_p"): - if key in ("temperature", "top_p") and key in saved and saved[key] is None: - if overlay.get(key) is not None: - raise ValueError("model_controls_invalid: conflicting sampling snapshot") - continue - if key not in saved or saved[key] is None: - continue - if key in overlay and overlay[key] != saved[key]: - raise ValueError("model_controls_invalid: conflicting generation snapshot") - overlay[key] = saved[key] - for legacy, canonical in (("reasoning_effort", "thinking_effort"), - ("reasoning_budget_tokens", "reasoning_budget_tokens")): - if value.get(legacy) is not None: - if canonical in overlay and overlay[canonical] != value[legacy]: - raise ValueError("model_controls_invalid: conflicting reasoning override") - overlay[canonical] = value[legacy] - if overlay or value.get("generation_config") is not None: - value["generation_config"] = overlay - return value + def reject_removed_controls(cls, value): + return reject_temporary_model_controls(value) class NL2AgentRunRequest(NL2ModelControlsRequest): diff --git a/backend/management/services/agent/reasoning.py b/backend/management/services/agent/reasoning.py index 97cda35e40..354b549888 100644 --- a/backend/management/services/agent/reasoning.py +++ b/backend/management/services/agent/reasoning.py @@ -1,4 +1,5 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Helpers for keeping agent-owned reasoning settings independent from models.""" from copy import deepcopy @@ -26,6 +27,10 @@ def _resolve_model_reasoning_capability(model_info: Optional[dict]) -> Optional[ """Resolve capability metadata for an agent snapshot when needed.""" if not isinstance(model_info, dict): return None + if model_info.get("profile_schema_version") is not None and model_info.get("model_type") in {"llm", "chat"}: + from services.model_outlet_projection import project_saved_outlet + + return (project_saved_outlet(model_info) or {}).get("reasoning_capability") capability = model_info.get("reasoning_capability") if isinstance(capability, dict): return capability @@ -95,6 +100,8 @@ def snapshot_agent_reasoning_config( requested_overrides: Optional[dict], existing_overrides: Optional[dict], tenant_id: str, + *, + normalize_profiles: bool = True, ) -> Optional[dict]: """Snapshot reasoning settings while preserving explicit agent overrides. @@ -124,6 +131,25 @@ def snapshot_agent_reasoning_config( ) model_info = get_model_by_model_id(model_id, tenant_id=tenant_id) + if ((model_info or {}).get("profile_schema_version") is not None + and (model_info or {}).get("model_type") in {"llm", "chat"}): + # Runtime reads convert in memory; only explicit saves write the current format. + if not normalize_profiles: + continue + # Validate only explicit overrides; omitted values keep inheriting the outlet. + from nexent.core.model_service.profiles.outlet_storage import decode_resolved_model_profile + from nexent.core.model_service.registry import model_service_references + from services.agent_model_parameters import merge_agent_model_parameters + + if not entry: + continue + from nexent.core.model_service.compatibility.agent_parameters import read_agent_parameter_entry + entry = read_agent_parameter_entry(entry) + if entry["parameter_values"] or entry.get("context_window_tokens") is not None or entry.get("custom_overrides") is not None: + profile = decode_resolved_model_profile(model_info, registered_references=model_service_references()) + merge_agent_model_parameters(profile, entry) + override_map[model_key] = entry + continue capability = _resolve_model_reasoning_capability(model_info) has_reasoning_override = ( "enable_thinking" in extra_params diff --git a/backend/management/services/agent/run.py b/backend/management/services/agent/run.py index 4b804c2193..33a3881ceb 100644 --- a/backend/management/services/agent/run.py +++ b/backend/management/services/agent/run.py @@ -48,10 +48,12 @@ ) from nexent.core.concurrency import ( ManagedExecution, + run_blocking, ) from nexent.monitor import ( agent_monitoring_context, ) +from services.conversation_model_preferences_service import read_model_preferences as _read_model_preferences from management.services.conversation.service import ( get_current_run_user_message_id, get_latest_assistant_message, @@ -119,9 +121,6 @@ def apply_workbench_runtime_plan( "display_name": root_identity.display_name, "origin": root_identity.origin, } - agent_request.__dict__["_runtime_generation_config"] = ( - canonical_workbench.generation_config.model_dump(mode="json") - ) async def prepare_agent_run( @@ -152,8 +151,6 @@ async def prepare_agent_run( "is_debug": agent_request.is_debug, "override_version_no": agent_request.version_no, "override_model_id": agent_request.model_id, - "reasoning_effort": agent_request.reasoning_effort, - "reasoning_budget_tokens": agent_request.reasoning_budget_tokens, "requested_output_tokens": agent_request.requested_output_tokens, "tool_params": agent_request.tool_params, "conversation_id": agent_request.conversation_id, @@ -171,18 +168,6 @@ async def prepare_agent_run( create_run_kwargs["runtime_knowledge_tools"] = runtime_knowledge_tools if runtime_skill_snapshot is not None: create_run_kwargs["runtime_skill_snapshot"] = runtime_skill_snapshot - runtime_generation_config = getattr( - agent_request, "_runtime_generation_config", None - ) - if ( - runtime_generation_config is None - and agent_request.generation_config is not None - ): - runtime_generation_config = agent_request.generation_config.model_dump( - mode="json", exclude_unset=True - ) - if runtime_generation_config is not None: - create_run_kwargs["runtime_generation_config"] = runtime_generation_config runtime_mount_plan = getattr(agent_request, "_runtime_mount_plan", None) if runtime_mount_plan is not None: create_run_kwargs["runtime_sub_agent_mounts"] = [ @@ -197,6 +182,14 @@ async def prepare_agent_run( ] if not agent_request.enable_automation_tool: create_run_kwargs["enable_automation_tool"] = False + preferences = getattr(agent_request, "_runtime_model_parameter_preferences", None) + if preferences is None: + preferences = agent_request.model_parameter_preferences + if agent_request.conversation_id is not None and not agent_request.is_debug: + saved = await run_blocking("run-model-preferences", _read_model_preferences, agent_request.conversation_id, user_id) + preferences = saved["model_parameter_preferences"] + if preferences: + create_run_kwargs["model_parameter_preferences"] = preferences agent_run_info = await create_agent_run_info( **create_run_kwargs, ) diff --git a/backend/management/services/agent/subscription.py b/backend/management/services/agent/subscription.py index abc077c062..719ca4411e 100644 --- a/backend/management/services/agent/subscription.py +++ b/backend/management/services/agent/subscription.py @@ -243,12 +243,6 @@ async def run_agent_stream( user_id=user_id, tenant_id=tenant_id, ) - if not resume: - from management.services.model import run_controls - - run_controls.validate_agent_model_selection( - agent_request, resolved_tenant_id, search_agent_info_by_agent_id - ) if agent_request.is_debug and not resume: # Debug executions deliberately do not create conversations, so they # need a transient identifier for lifecycle operations such as stop. @@ -286,6 +280,16 @@ async def run_agent_stream( "Conversation belongs to a different chat entrypoint" ) + if not resume: + from management.services.model import run_controls + if conversation is not None: + from services.conversation_model_preferences_service import read_model_preferences + saved = await run_blocking("conversation-model-settings", read_model_preferences, agent_request.conversation_id, resolved_user_id) + if agent_request.model_id is None: + agent_request.model_id = saved.get("selected_model_id") + agent_request.__dict__["_runtime_model_parameter_preferences"] = saved["model_parameter_preferences"] + run_controls.validate_agent_model_selection(agent_request, resolved_tenant_id, search_agent_info_by_agent_id) + canonical_workbench = None if agent_request.entrypoint == "workbench" and not resume: from management.services.agent.workbench import ( @@ -335,6 +339,9 @@ async def run_agent_stream( agent_request.expected_workbench_config_version, ) + if not resume and conversation is not None and agent_request.model_parameter_preferences: + raise ValidationError("Save existing conversation parameters through the preference endpoint") + if not resume: run_controls.preflight_agent_turn_controls( agent_request, resolved_tenant_id, search_agent_info_by_agent_id @@ -463,6 +470,10 @@ async def run_agent_stream( "agent_id": agent_request.agent_id, "chat_mode": "planning" if agent_request.enable_plan else "execution", } + if agent_request.model_id is not None: + conversation_kwargs["selected_model_id"] = agent_request.model_id + if agent_request.model_parameter_preferences: + conversation_kwargs["model_parameter_preferences"] = agent_request.model_parameter_preferences if canonical_workbench is not None: conversation_kwargs["workbench_config"] = canonical_workbench.model_dump( mode="json" @@ -474,6 +485,7 @@ async def run_agent_stream( conversation_data = create_new_conversation(**conversation_kwargs) agent_request.conversation_id = conversation_data["conversation_id"] is_new_conversation = True + agent_request.__dict__["_runtime_model_parameter_preferences"] = agent_request.model_parameter_preferences logger.info( "Auto-created conversation_id=%s for user=%s (new conversation)", agent_request.conversation_id, @@ -997,7 +1009,7 @@ async def stream_with_agent_context(): "config_version": getattr( agent_request, "_workbench_config_version", 0 ), - "schema_version": 3, + "schema_version": canonical_workbench.schema_version, "mode": canonical_workbench.mode, "agent_mounts": [ mount.model_dump(mode="json") diff --git a/backend/management/services/agent/workbench.py b/backend/management/services/agent/workbench.py index d157dc1a23..56e36f77f7 100644 --- a/backend/management/services/agent/workbench.py +++ b/backend/management/services/agent/workbench.py @@ -449,26 +449,18 @@ def resolve_workbench_config( origin="SYSTEM", ) child_mounts = tuple(child_identities) - if canonical.model_id is not None: - model_info = get_model_by_model_id( - canonical.model_id, tenant_id=tenant_id - ) - if not is_model_available(model_info): - raise WorkbenchError("WORKBENCH_MODEL_NOT_ALLOWED") - snapshot["skill_instances"] = skill_db.search_skills_for_agent( agent_id=int(root_identity.agent_id), tenant_id=tenant_id, version_no=int(root_identity.version_no), ) - generation = canonical.generation_config root = PublishedRootDescriptor( identity=root_identity, published_snapshot=deepcopy(snapshot), ) overlay = RootRuntimeOverlay( - model_id=canonical.model_id, - requested_output_tokens=generation.requested_output_tokens, + model_id=None, + requested_output_tokens=canonical.requested_output_tokens, skill_mounts=tuple( value.model_dump(mode="json") for value in canonical.skill_mounts ), diff --git a/backend/management/services/conversation/creation_history.py b/backend/management/services/conversation/creation_history.py index 39f2a61f4f..2b08fe3f89 100644 --- a/backend/management/services/conversation/creation_history.py +++ b/backend/management/services/conversation/creation_history.py @@ -38,6 +38,8 @@ def prepare_creation_history( agent_id: int | None, user_id: str, tenant_id: str, + model_parameter_preferences: dict[str, dict[str, Any]] | None = None, + selected_model_id: int | None = None, retry_user_message_id: int | None = None, retry_message_index: int | None = None, ) -> tuple[int, int]: @@ -59,6 +61,8 @@ def prepare_creation_history( user_id=user_id, agent_id=agent_id if mode == "agent_create" else None, workbench_config=normalized_config, + **({"selected_model_id": selected_model_id} if selected_model_id is not None else {}), + **({"model_parameter_preferences": model_parameter_preferences} if model_parameter_preferences else {}), ) resolved_id = int(created["conversation_id"]) next_index = 0 diff --git a/backend/management/services/conversation/service.py b/backend/management/services/conversation/service.py index d4d3d737c0..7c8da6826d 100644 --- a/backend/management/services/conversation/service.py +++ b/backend/management/services/conversation/service.py @@ -408,6 +408,8 @@ def create_new_conversation( knowledge_scope: Optional[Dict[str, Any]] = None, runtime_metadata: Optional[Dict[str, Any]] = None, workbench_config: Optional[Dict[str, Any]] = None, + model_parameter_preferences: Optional[Dict[str, Any]] = None, + selected_model_id: Optional[int] = None, ) -> Dict[str, Any]: """ Create a new conversation @@ -432,6 +434,10 @@ def create_new_conversation( create_kwargs["runtime_metadata"] = runtime_metadata if workbench_config is not None: create_kwargs["workbench_config"] = workbench_config + if selected_model_id is not None: + create_kwargs["selected_model_id"] = selected_model_id + if model_parameter_preferences: + create_kwargs["model_parameter_preferences"] = model_parameter_preferences conversation_data = create_conversation(title, user_id, **create_kwargs) return conversation_data except AppException: diff --git a/backend/management/services/model/run_controls.py b/backend/management/services/model/run_controls.py index 5fe465786b..7e8d73d288 100644 --- a/backend/management/services/model/run_controls.py +++ b/backend/management/services/model/run_controls.py @@ -3,14 +3,12 @@ """Resolve request-scoped root controls without mutating independent models.""" from collections.abc import Callable -import math from typing import Any from nexent.core.agents.agent_model import AgentConfig, ModelConfig from consts.exceptions import RunModelControlsError from database.model_management_db import get_model_by_model_id -from management.services.model.reasoning import reasoning_controls def _agent_record_for_run(request, tenant_id: str, lookup: Callable[..., dict]) -> dict: @@ -27,7 +25,7 @@ def _agent_record_for_run(request, tenant_id: str, lookup: Callable[..., dict]) def validate_agent_model_selection(request, tenant_id: str, lookup: Callable[..., dict]) -> None: """Validate selected catalog models and native Agent associations before writes.""" - selected_id = request.workbench.model_id if request.workbench else request.model_id + selected_id = request.model_id if selected_id is None: return validate_selected_model(selected_id, tenant_id) @@ -41,9 +39,7 @@ def validate_agent_model_selection(request, tenant_id: str, lookup: Callable[... def preflight_agent_turn_controls(request, tenant_id: str, lookup: Callable[..., dict]) -> None: """Preflight only explicit model controls using the native Agent's defaults.""" - if all(getattr(request, field) is None for field in ( - "model_id", "generation_config", "reasoning_effort", "reasoning_budget_tokens", - )): + if request.model_id is None and not request.model_parameter_preferences and not getattr(request, "_runtime_model_parameter_preferences", {}): return root_record = None if request.entrypoint != "workbench" and request.agent_id: @@ -68,125 +64,49 @@ def validate_selected_record(record: dict | None) -> dict[str, Any]: return record -def overlay_root_model(base: ModelConfig, values: dict[str, Any], alias: str) -> ModelConfig: - """Apply only present fields and validate active effort/budget intent.""" - root = base.model_copy(deep=True) - root.cite_name = alias - explicit_sampling = [] - for key, maximum in (("temperature", 2), ("top_p", 1)): - value = values.get(key) - if value is None: - continue - if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or not 0 <= value <= maximum: - raise RunModelControlsError("model_controls_invalid") - explicit_sampling.append(key) - setattr(root, key, value) - if explicit_sampling: - root.explicit_sampling = explicit_sampling - # OpenAI's extra_body can otherwise shadow the canonical keyword values. - root.extra_body = {k: v for k, v in (root.extra_body or {}).items() if k not in explicit_sampling} or None - if not any(k in values for k in ("deep_thinking", "thinking_effort", "reasoning_budget_tokens")): - return root - enabled = values.get("deep_thinking", base.enable_thinking) - effort = values.get("thinking_effort") - budget = values.get("reasoning_budget_tokens") - controls = reasoning_controls(base.reasoning_capability) - effort_control = next((c for c in controls if c.get("type") == "effort"), None) - budget_control = next((c for c in controls if c.get("type") == "budget_tokens"), None) - if enabled: - if not controls: - raise RunModelControlsError("model_controls_invalid") - if effort not in (None, "auto") and budget not in (None, 0): - raise RunModelControlsError("model_controls_invalid") - if effort not in (None, "auto"): - if not effort_control or effort not in effort_control.get("values", []): - raise RunModelControlsError("model_controls_invalid") - root.reasoning_effort = effort - root.reasoning_budget_tokens = None - elif effort == "auto": - # Resolve against the immutable model/Agent defaults, never last turn. - root.reasoning_effort = base.reasoning_effort - if budget is not None: - if not budget_control or (budget > 0 and not budget_control["min"] <= budget <= budget_control["max"]): - raise RunModelControlsError("model_controls_invalid") - root.reasoning_budget_tokens = budget or base.reasoning_budget_tokens - root.reasoning_effort = None - elif budget_control and effort == "auto": - root.reasoning_effort = None - else: - root.reasoning_effort = None - root.reasoning_budget_tokens = None - if "deep_thinking" in values: - root.enable_thinking = enabled - extra = dict(root.extra_body or {}) - if "deep_thinking" in values: - extra["enable_thinking"] = enabled - if any(k in values for k in ("deep_thinking", "thinking_effort", "reasoning_budget_tokens")): - extra.pop("reasoning_effort", None) - extra.pop("reasoning_budget_tokens", None) - root.extra_body = extra or None - return root +def preflight_turn_controls(request, tenant_id: str, agent_record: dict | None = None) -> None: + """Validate the single preference format before any conversation write.""" + from consts.exceptions import ModelOutletValidationError + from services.conversation_model_preferences_service import validate_model_preferences + + model_id = request.model_id + if model_id is None and getattr(request, "_runtime_model_parameter_preferences", {}): + configured = (agent_record or {}).get("model_ids") or [] + if configured: + model_id = configured[0] + else: + from consts.const import MODEL_CONFIG_MAPPING + from management.services.configuration.tenant.settings import tenant_config_manager + default = tenant_config_manager.get_model_config(MODEL_CONFIG_MAPPING["llm"], tenant_id=tenant_id) + model_id = (default or {}).get("model_id") + if model_id is not None: + validate_selected_model(model_id, tenant_id) + allowed = (agent_record or {}).get("model_ids") or None + preferences = request.model_parameter_preferences + if not preferences and model_id is not None: + saved = getattr(request, "_runtime_model_parameter_preferences", {}) + values = saved.get(str(model_id)) + if values is not None: + preferences = {str(model_id): values} + try: + validate_model_preferences(preferences, tenant_id, allowed_model_ids=allowed) + except ModelOutletValidationError as error: + raise RunModelControlsError("model_controls_invalid") from error def resolve_creator_model( model_list: list[ModelConfig], agent: AgentConfig, tenant_id: str, - model_id: int | None, values: dict[str, Any] | None, default_record: dict | None, + model_id: int | None, preferences: dict | None, default_record: dict | None, ) -> dict: - """Bind a creator root to its actual record, preserving all other aliases.""" + """Select a creator root and use the shared profile preference merger.""" + from agents.create_agent_info import apply_root_model_preferences, create_model_config_from_record + record = validate_selected_model(model_id, tenant_id) if model_id is not None else default_record or {} if record: - from agents.create_agent_info import create_model_config_from_record - base = create_model_config_from_record(record) - else: - base = next((m for m in model_list if m.cite_name == record.get("display_name")), None) - if base is None: - if model_id is not None: - raise RunModelControlsError("model_not_available") - base = next((m for m in model_list if m.cite_name == agent.model_name), None) - if base is None: - raise RunModelControlsError("model_not_available") - if model_id is not None or values: - root = overlay_root_model(base, values or {}, "creator_root_model") + root = create_model_config_from_record(record).model_copy(deep=True, update={"cite_name": "creator_root_model"}) model_list.append(root) agent.model_name = root.cite_name + elif not any(model.cite_name == agent.model_name for model in model_list): + raise RunModelControlsError("model_not_available") + apply_root_model_preferences(model_list, agent, preferences) return record - - -def preflight_turn_controls(request, tenant_id: str, agent_record: dict | None = None) -> None: - """Reject invalid intent before conversation creation or draft persistence.""" - model_id = request.model_id - values = getattr(request, "_runtime_generation_config", None) - if values is None: - values = request.generation_config.model_dump(exclude_unset=True) if request.generation_config else {} - if model_id is None: - if not values and getattr(request, "reasoning_effort", None) is None and getattr(request, "reasoning_budget_tokens", None) is None: - return - configured = (agent_record or {}).get("model_ids") or [] - if configured: - model_id = configured[0] - else: - from consts.const import MODEL_CONFIG_MAPPING - from management.services.configuration.tenant.settings import tenant_config_manager - default_record = tenant_config_manager.get_model_config(MODEL_CONFIG_MAPPING["llm"], tenant_id) - model_id = (default_record or {}).get("model_id") - if model_id is None: - raise RunModelControlsError("model_not_available") - record = validate_selected_model(model_id, tenant_id) - from agents.create_agent_info import create_model_config_from_record - base = create_model_config_from_record(record) - if agent_record: - override = (agent_record.get("model_params_override") or {}).get(str(model_id), {}) - extra = override.get("extra_params") or {} - for source, target in (("enable_thinking", "enable_thinking"), - ("reasoning_effort", "reasoning_effort"), - ("reasoning_budget_tokens", "reasoning_budget_tokens")): - if source in extra: - setattr(base, target, extra[source]) - values = dict(values) - for attr, key in (("reasoning_effort", "thinking_effort"), ("reasoning_budget_tokens", "reasoning_budget_tokens")): - value = getattr(request, attr, None) - if value is not None: - if key in values and values[key] != value: - raise RunModelControlsError("model_controls_invalid") - values[key] = value - overlay_root_model(base, values, "preflight_root") diff --git a/backend/services/nl2agent_service.py b/backend/services/nl2agent_service.py index bb4b3cc176..366791d9a5 100644 --- a/backend/services/nl2agent_service.py +++ b/backend/services/nl2agent_service.py @@ -1356,10 +1356,10 @@ async def build_nl2agent_run_info( key=MODEL_CONFIG_MAPPING["llm"], tenant_id=tenant_id, ) - if request.model_id is not None or request.generation_config is not None: + if request.model_id is not None or request.model_parameter_preferences or getattr(request, "_runtime_model_parameter_preferences", {}): default_model = resolve_creator_model( model_config_list, agent_config, tenant_id, request.model_id, - request.generation_config.model_dump(exclude_unset=True) if request.generation_config else None, + getattr(request, "_runtime_model_parameter_preferences", request.model_parameter_preferences), default_model, ) input_budget, capacity_snapshot, resolved_capacity_snapshot = ( @@ -1416,6 +1416,7 @@ async def build_nl2agent_run_info( stop_event = threading.Event() run_info = AgentRunInfo( + tenant_id=tenant_id, query=final_query, model_config_list=model_config_list, observer=_Nl2AgentBoundaryObserver( diff --git a/backend/services/nl2skill_service.py b/backend/services/nl2skill_service.py index f8e84f3cbc..0f4d8b9011 100644 --- a/backend/services/nl2skill_service.py +++ b/backend/services/nl2skill_service.py @@ -235,10 +235,10 @@ async def build_nl2skill_run_info( system_prompt=template.get("system_prompt", ""), model_name=cite_name, ) - if request.model_id is not None or request.generation_config is not None: + if request.model_id is not None or request.model_parameter_preferences or getattr(request, "_runtime_model_parameter_preferences", {}): model_info = resolve_creator_model( model_config_list, agent_config, tenant_id, request.model_id, - request.generation_config.model_dump(exclude_unset=True) if request.generation_config else None, + getattr(request, "_runtime_model_parameter_preferences", request.model_parameter_preferences), model_info, ) input_budget, capacity_snapshot, resolved_capacity = _resolve_input_budget(model_info) @@ -257,6 +257,7 @@ async def build_nl2skill_run_info( compaction_target_tokens=context_budget.compaction_target_tokens if context_budget else 0, ) run_info = AgentRunInfo( + tenant_id=tenant_id, query=template.get("user_prompt") or final_query, model_config_list=model_config_list, observer=MessageObserver(lang=template_language), diff --git a/deploy/sql/migrations/v2.7.1_007_unify_conversation_model_parameters.sql b/deploy/sql/migrations/v2.7.1_007_unify_conversation_model_parameters.sql new file mode 100644 index 0000000000..cefcbf3da3 --- /dev/null +++ b/deploy/sql/migrations/v2.7.1_007_unify_conversation_model_parameters.sql @@ -0,0 +1,70 @@ +-- Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +ALTER TABLE nexent.conversation_record_t ADD COLUMN IF NOT EXISTS selected_model_id INTEGER; +COMMENT ON COLUMN nexent.conversation_record_t.selected_model_id IS + 'Root model selected for this Agent conversation; NULL inherits the Agent default.'; + +-- Upgrade only historical Workbench declarations. New preferences win per field. +-- Never assign historical choices to an arbitrary model when its identity is missing. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM nexent.conversation_record_t + WHERE jsonb_typeof(workbench_config->'generation_config') = 'object' + AND COALESCE(workbench_config->>'model_parameters_source', 'legacy_generation') = 'legacy_generation' + AND COALESCE(workbench_config->>'model_id', selected_model_id::text) IS NULL + AND (workbench_config->'generation_config') - 'requested_output_tokens' <> '{}'::jsonb + ) THEN + RAISE EXCEPTION 'Workbench model parameter migration requires a model_id; no historical settings were discarded'; + END IF; +END $$; + +WITH historical AS ( + SELECT conversation_id, workbench_config AS config, + workbench_config->'generation_config' AS generation, + COALESCE(workbench_config->>'model_id', selected_model_id::text) AS model_key + FROM nexent.conversation_record_t + WHERE jsonb_typeof(workbench_config) = 'object' + AND (workbench_config ? 'generation_config' OR workbench_config ? 'model_parameters_source' + OR workbench_config->>'schema_version' = '3') +), converted AS ( + SELECT *, CASE + WHEN model_key IS NOT NULL + AND COALESCE(config->>'model_parameters_source', 'legacy_generation') = 'legacy_generation' + THEN jsonb_strip_nulls(jsonb_build_object( + 'temperature', generation->'temperature', + 'top_p', generation->'top_p', + 'enable_thinking', generation->'deep_thinking', + 'reasoning_budget_tokens', CASE WHEN generation->>'deep_thinking' = 'true' + AND COALESCE((generation->>'reasoning_budget_tokens')::bigint, 0) > 0 + THEN generation->'reasoning_budget_tokens' END, + 'reasoning_effort', CASE WHEN generation->>'deep_thinking' = 'true' + AND COALESCE((generation->>'reasoning_budget_tokens')::bigint, 0) = 0 + AND generation->>'thinking_effort' <> 'auto' + THEN generation->'thinking_effort' END + )) ELSE '{}'::jsonb END AS preferences + FROM historical +) +UPDATE nexent.conversation_record_t AS target +SET selected_model_id = COALESCE(target.selected_model_id, converted.model_key::integer), + model_parameter_preferences = CASE WHEN converted.preferences <> '{}'::jsonb + THEN target.model_parameter_preferences || jsonb_build_object(converted.model_key, + (CASE WHEN target.model_parameter_preferences->converted.model_key ?| ARRAY['reasoning_effort', 'reasoning_budget_tokens'] + OR target.model_parameter_preferences->converted.model_key->>'enable_thinking' = 'false' + THEN converted.preferences - 'reasoning_effort' - 'reasoning_budget_tokens' + ELSE converted.preferences END) + || COALESCE(target.model_parameter_preferences->converted.model_key, '{}'::jsonb)) + ELSE target.model_parameter_preferences END, + model_parameter_preferences_version = target.model_parameter_preferences_version + + CASE WHEN converted.preferences <> '{}'::jsonb + OR (converted.model_key IS NOT NULL AND target.selected_model_id IS NULL) THEN 1 ELSE 0 END, + workbench_config = (converted.config - 'generation_config' - 'model_parameters_source' - 'model_id') + || jsonb_build_object('schema_version', 4) + || CASE WHEN converted.generation->>'requested_output_tokens' IS NOT NULL + THEN jsonb_build_object('requested_output_tokens', converted.generation->'requested_output_tokens') + ELSE '{}'::jsonb END, + workbench_config_version = target.workbench_config_version + 1, + updated_by = 'migration:session-model-parameters', + update_time = CURRENT_TIMESTAMP +FROM converted +WHERE target.conversation_id = converted.conversation_id; diff --git a/frontend/components/chat/ModelParameterSettings.tsx b/frontend/components/chat/ModelParameterSettings.tsx index ba019b5fe5..efabb64b9d 100644 --- a/frontend/components/chat/ModelParameterSettings.tsx +++ b/frontend/components/chat/ModelParameterSettings.tsx @@ -2,7 +2,7 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState, type ReactNode } from "react"; import { Button, Popover, Slider } from "antd"; import { ChevronRight } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -14,11 +14,12 @@ function configuredValue(value: number | undefined, max: number) { : undefined; } -function ParameterLevel({ +export function ParameterLevel({ label, value, inheritedValue, max, + min = 0, disabled, onChange, }: { @@ -26,6 +27,7 @@ function ParameterLevel({ value?: number; inheritedValue?: number; max: number; + min?: number; disabled?: boolean; onChange: (value: number) => void; }) { @@ -46,7 +48,7 @@ function ParameterLevel({ const commit = () => { if (!dirty) return; const next = Number(text); - if (!text.trim() || !Number.isFinite(next) || next < 0 || next > max) { + if (!text.trim() || !Number.isFinite(next) || next < min || next > max) { setError(true); return; } @@ -64,7 +66,7 @@ function ParameterLevel({
{sliderValue != null ? ( {t("workbench.controls.numberRange", { defaultValue: "请输入 {{min}}–{{max}} 的数字", - min: 0, + min, max, })}

@@ -125,34 +127,51 @@ function ParameterLevel({ ); } -export function ModelParameterSettings(control: ModelParameterSettingsControl) { +/** Shared selector entry and nested panel for legacy and profile parameters. */ +export function ModelParameterPopover({ + children, + disabled, + onOpenChange, +}: { + children: ReactNode | ((close: () => void) => ReactNode); + disabled?: boolean; + onOpenChange?: (open: boolean) => void; +}) { const { t } = useTranslation(); const [open, setOpen] = useState(false); - const [resetVersion, setResetVersion] = useState(0); + const changeOpen = useCallback( + (next: boolean) => { + setOpen(next); + onOpenChange?.(next); + }, + [onOpenChange] + ); useEffect(() => { if (!open) return; const closeOutside = (event: PointerEvent) => { if ( event.target instanceof Element && !event.target.closest( - "[data-model-parameter-panel], [data-model-parameter-trigger]" + "[data-model-parameter-panel], [data-model-parameter-trigger], [data-model-effort-popup]" ) ) - setOpen(false); + changeOpen(false); }; document.addEventListener("pointerdown", closeOutside); return () => document.removeEventListener("pointerdown", closeOutside); - }, [open]); + }, [open, changeOpen]); return ( { if ( !next && - document.activeElement?.closest("[data-model-parameter-panel]") + document.activeElement?.closest( + "[data-model-parameter-panel], [data-model-parameter-trigger]" + ) ) return; - setOpen(next); + changeOpen(next); }} trigger={["hover", "click"]} placement="rightTop" @@ -167,73 +186,28 @@ export function ModelParameterSettings(control: ModelParameterSettingsControl) { className="w-64 space-y-3" onKeyDown={(event) => { event.stopPropagation(); - if (event.key === "Escape") setOpen(false); + if (event.key === "Escape") changeOpen(false); }} > - - control.onChange({ ...control.value, temperature }) - } - /> - control.onChange({ ...control.value, top_p })} - /> - {(configuredValue( - control.value.temperature ?? control.inheritedValue?.temperature, - 2 - ) == null || - configuredValue( - control.value.top_p ?? control.inheritedValue?.top_p, - 1 - ) == null) && ( -

- {t( - "workbench.controls.configureDefaults", - "请先在模型配置中设置默认值,或输入本次使用的数值。" - )} -

- )} - + {typeof children === "function" + ? children(() => changeOpen(false)) + : children}
} > + + ); +} + +export function ModelParameterSettings(control: ModelParameterSettingsControl) { + return ( + + + + ); +} diff --git a/frontend/components/model/AgentModelParameters.tsx b/frontend/components/model/AgentModelParameters.tsx new file mode 100644 index 0000000000..d755a45aef --- /dev/null +++ b/frontend/components/model/AgentModelParameters.tsx @@ -0,0 +1,180 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Alert, Form, Modal, Spin } from "antd"; +import { useTranslation } from "react-i18next"; +import { getModelOutlet } from "@/services/modelProfileService"; +import { + agentParameterValues, + AGENT_PARAMETER_KEYS, + parameterIsActive, + withAgentParameterValues, +} from "@/lib/modelParameterLayers"; +import { + ModelThinkingSettings, + thinkingDegreeKeys, +} from "./ModelThinkingSettings"; +import type { + ModelOutletResponse, + ThinkingDegreeMode, +} from "@/types/modelProfile"; +import { StandardButton } from "@/components/common/StandardButton"; +import { ModelParameterField } from "./ModelParameterField"; + +interface Props { + modelId: number; + name: string; + value: Record; + disabled?: boolean; + onApply: (entry: Record) => void; + onClose: () => void; +} + +/** The Agent page owns saving/publishing; this editor only applies a local draft. */ +export function AgentModelParameters(props: Props) { + const { t } = useTranslation(); + const query = useQuery({ + queryKey: ["model-outlet", props.modelId], + queryFn: ({ signal }) => getModelOutlet(props.modelId, signal), + staleTime: 0, + retry: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + return ( + + {query.isFetching ? ( + + ) : query.isError ? ( + + ) : ( + query.data && ( + + ) + )} + + ); +} + +function Editor({ + value, + disabled, + onApply, + onClose, + outlet, +}: Props & { outlet: ModelOutletResponse }) { + const { t } = useTranslation(); + const [parameters, setParameters] = useState(() => + agentParameterValues(value) + ); + const [edited, setEdited] = useState(false); + const [thinkingMode, setThinkingMode] = useState(); + const defaults = outlet.profile.parameter_values; + const thinkingKeys = ["enable_thinking", ...thinkingDegreeKeys]; + const rules = outlet.profile.parameter_rules.filter( + (rule) => + rule.visible && + rule.allowed_scopes.includes("conversation") && + AGENT_PARAMETER_KEYS.includes(rule.key) + ); + const change = (patch: Record, removed: string[] = []) => { + setEdited(true); + setParameters((previous) => { + const next = { ...previous, ...patch }; + removed.forEach((key) => delete next[key]); + return next; + }); + }; + const effective = { ...defaults, ...parameters }; + return ( +
{ + const next = edited + ? withAgentParameterValues(value, parameters) + : { ...value }; + onApply(next); + onClose(); + }} + > + + {rules + .filter( + (rule) => + !thinkingKeys.includes(rule.key) && + (parameterIsActive(rule.condition, effective) || + Object.hasOwn(parameters, rule.key)) + ) + .map((rule) => ( + change({ [key]: next })} + onReset={(key) => change({}, [key])} + /> + ))} + {rules.some((rule) => thinkingKeys.includes(rule.key)) && ( + + change( + { enable_thinking: enabled }, + enabled ? [] : thinkingDegreeKeys + ) + } + onModeChange={(mode) => { + setThinkingMode(mode); + change({}, thinkingDegreeKeys); + }} + onValueChange={(key, next) => + next === undefined ? change({}, [key]) : change({ [key]: next }) + } + /> + )} + {!disabled && + thinkingKeys.some((key) => Object.hasOwn(parameters, key)) && ( + { + setThinkingMode(undefined); + change({}, thinkingKeys); + }} + > + {t("modelProfile.useOutletDefault")} + + )} + + {t("common.confirm")} + + + ); +} diff --git a/frontend/components/model/ModelParameterPanel.tsx b/frontend/components/model/ModelParameterPanel.tsx new file mode 100644 index 0000000000..28f479211d --- /dev/null +++ b/frontend/components/model/ModelParameterPanel.tsx @@ -0,0 +1,98 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import type { ReactNode } from "react"; +import { + BrainCircuit, + Layers, + RotateCcw, + SlidersHorizontal, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { StandardCard } from "@/components/common/StandardCard"; +import { StandardButton } from "@/components/common/StandardButton"; + +/** Shared by onboarding, batch configuration, and saved outlet editing. */ +export function ModelParameterPanel({ + source, + hasModel, + pending, + onReset, + sampling, + thinking, + capacity, +}: { + source?: string; + hasModel: boolean; + pending: boolean; + onReset?: () => void; + sampling: ReactNode; + thinking: ReactNode; + capacity: ReactNode; +}) { + const { t } = useTranslation(); + return ( +
+

+ {t("modelProfile.modelParameters")} +

+ {!hasModel ? ( +
+ +

+ {t("modelProfile.layout.emptyTitle")} +

+

{t("modelProfile.layout.emptyDescription")}

+
+ ) : ( + <> +
+ + {t("modelProfile.layout.source")}{" "} + + {source ?? t("modelProfile.otherProfile")} + + + {onReset && ( + } + disabled={pending} + onClick={onReset} + > + {t("modelProfile.layout.reset")} + + )} +
+ {( + [ + ["samplingGroup", SlidersHorizontal, sampling], + ["thinkingGroup", BrainCircuit, thinking], + ["capacityGroup", Layers, capacity], + ] as const + ).map(([key, Icon, content]) => ( + +

+ + + + {t(`modelProfile.${key}`)} +

+ {content} +
+ ))} + + )} +
+ ); +} diff --git a/frontend/components/model/ModelThinkingSettings.tsx b/frontend/components/model/ModelThinkingSettings.tsx new file mode 100644 index 0000000000..d650fd09a1 --- /dev/null +++ b/frontend/components/model/ModelThinkingSettings.tsx @@ -0,0 +1,207 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useEffect } from "react"; +import { Form, Select, Switch } from "antd"; +import { ModelParameterField } from "./ModelParameterField"; +import { useTranslation } from "react-i18next"; +import type { + ModelParameterRule, + ThinkingDegreeMode, +} from "@/types/modelProfile"; + +export const thinkingDegreeKeys = [ + "reasoning_effort", + "reasoning_budget_tokens", +]; +export function detectThinkingMode( + rules: ModelParameterRule[], + values: Record +): ThinkingDegreeMode { + if (values.reasoning_effort !== undefined) return "effort"; + if (values.reasoning_budget_tokens !== undefined) return "budget_tokens"; + if (rules.some((rule) => rule.key === "reasoning_effort")) return "effort"; + if (rules.some((rule) => rule.key === "reasoning_budget_tokens")) + return "budget_tokens"; + return "default"; +} + +/** Shared prefill for all UI entry points; never invent a request binding. */ +export function thinkingDegreePrefill( + rule: ModelParameterRule | undefined, + mode: ThinkingDegreeMode, + outputLimit?: number | null +) { + if (!rule) return undefined; + if (rule.fixed_value !== undefined) return rule.fixed_value; + if (rule.default_value !== undefined) return rule.default_value; + if (mode === "effort") { + const choices = Array.isArray(rule.schema.enum) + ? rule.schema.enum + : ["low", "medium", "high"]; + return ["low", "minimal", "medium", "high", "xhigh", "max"].find((value) => + choices.includes(value) + ); + } + const maximum = rule.schema.maximum ?? outputLimit; + const minimum = + typeof rule.schema.minimum === "number" ? rule.schema.minimum : 1; + return mode === "budget_tokens" && typeof maximum === "number" + ? Math.max(minimum, Math.floor(maximum / 2)) + : undefined; +} + +/** Product controls; provider request-field bindings stay behind the profile API. */ +export function ModelThinkingSettings({ + rules, + values, + defaults = {}, + mode, + pending = false, + outputLimit, + prefillReady = true, + declaredControlsOnly = false, + layout = "inline", + onToggle, + onModeChange, + onValueChange, +}: { + rules: ModelParameterRule[]; + values: Record; + defaults?: Record; + mode?: ThinkingDegreeMode; + pending?: boolean; + outputLimit?: number | null; + prefillReady?: boolean; + declaredControlsOnly?: boolean; + layout?: "inline" | "panel"; + onToggle: (enabled: boolean) => void; + onModeChange: (mode: ThinkingDegreeMode) => void; + onValueChange: (key: string, value: unknown) => void; +}) { + const { t } = useTranslation(); + const effective = { ...defaults, ...values }; + const toggle = rules.find((rule) => rule.key === "enable_thinking"); + const enabled = effective.enable_thinking === true; + const selectedMode = + mode && mode !== "default" ? mode : detectThinkingMode(rules, effective); + const key = + selectedMode === "effort" ? "reasoning_effort" : "reasoning_budget_tokens"; + const rule = rules.find((item) => item.key === key) ?? { + key, + visible: true, + editable: !declaredControlsOnly, + required: false, + allowed_scopes: [ + "outlet", + "conversation", + ] as ModelParameterRule["allowed_scopes"], + ui_group: "reasoning", + schema: + selectedMode === "effort" + ? { type: "string", enum: ["low", "medium", "high"] } + : { type: "integer", minimum: 1 }, + }; + const fixed = rule.editable === false; + const modes = (["effort", "budget_tokens"] as const).filter( + (value, index) => + rules.some((item) => item.key === thinkingDegreeKeys[index]) || + (!declaredControlsOnly && + (mode === value || + !rules.some((item) => thinkingDegreeKeys.includes(item.key)))) + ); + const currentValue = effective[key]; + const prefill = + currentValue !== undefined + ? currentValue + : prefillReady + ? thinkingDegreePrefill(rule, selectedMode, outputLimit) + : (rule?.fixed_value ?? rule?.default_value); + + useEffect(() => { + if (!prefillReady) return; + if ( + enabled && + !pending && + selectedMode === "default" && + !declaredControlsOnly + ) { + onModeChange("effort"); + return; + } + if ( + enabled && + !pending && + selectedMode !== "default" && + currentValue === undefined && + prefill !== undefined + ) + onValueChange(key, prefill); + }, [ + enabled, + pending, + selectedMode, + key, + currentValue, + prefill, + onValueChange, + onModeChange, + prefillReady, + declaredControlsOnly, + ]); + return ( +
+ +
+ onToggle(enabled)} + /> +
+
+ {enabled && modes.length > 0 && ( + <> + +