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}
}
>