From ee1c7b8bdaa87df6b8d6325bf7c77bdf6ce3b80c Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Wed, 21 Oct 2026 09:32:39 +0800 Subject: [PATCH] Model onboarding and editor integration Local review preparation only. Reuse Draft PR #4097. --- .../[agentId]/components/agent-prompt.tsx | 20 +- .../components/model/ModelAddDialog.tsx | 344 +++++-- .../components/model/ModelLibraryList.tsx | 11 + .../components/model/ModelOutletAddForm.tsx | 696 +++++++++++++ .../model/ModelOutletEditDialog.tsx | 399 ++++++++ .../components/model/profileSuggestion.ts | 102 ++ .../models/components/modelConfig.tsx | 25 +- .../components/model/ModelRequestSettings.tsx | 96 ++ .../model/RequestParametersEditor.tsx | 299 ++++++ .../modelAddDialogBatchDuplicate.test.tsx | 4 +- .../component/modelOutletAddForm.test.tsx | 924 ++++++++++++++++++ .../tests/component/modelOutletBatch.test.tsx | 181 ++++ .../component/modelOutletEditing.test.tsx | 448 +++++++++ .../component/modelProfileSuggestion.test.tsx | 69 ++ .../requestParametersEditor.test.tsx | 109 +++ .../CHAT-MODEL-CONTROLS-D2-001/case.yaml | 20 +- .../CHAT-MODEL-CONTROLS-D2-001/test_api.py | 95 +- .../CHAT-MODEL-CONTROLS-D2-002/test_api.py | 2 +- .../CHAT-MODEL-CONTROLS-D3-001/case.yaml | 8 + .../CHAT-MODEL-CONTROLS-D3-001/execution.yaml | 1 + .../test_runtime.py | 59 +- .../CHAT-MODEL-CONTROLS-D3-002/case.yaml | 8 + .../CHAT-MODEL-CONTROLS-D3-002/execution.yaml | 1 + .../test_runtime.py | 26 +- .../CHAT-MODEL-CONTROLS-D4-001/case.yaml | 13 +- .../CHAT-MODEL-CONTROLS-D4-001/test.spec.ts | 25 +- .../CHAT-MODEL-CONTROLS-D4-002/case.yaml | 9 +- .../CHAT-MODEL-CONTROLS-D4-002/test.spec.ts | 8 +- .../CHAT-MODEL-CONTROLS-D4-003/case.yaml | 11 +- .../CHAT-MODEL-CONTROLS-D4-003/test.spec.ts | 24 +- .../CHAT-MODEL-CONTROLS-D5-001/case.yaml | 51 + .../CHAT-MODEL-CONTROLS-D5-001/execution.yaml | 10 + .../test_migration.py | 95 ++ test-e2e/cases/FAF-D3-001/test.py | 4 +- test-e2e/cases/MOP-D3-001/case.yaml | 66 ++ test-e2e/cases/MOP-D3-001/execution.yaml | 17 + test-e2e/cases/MOP-D3-001/test.py | 210 ++++ test-e2e/cases/MOS-D5-001/case.yaml | 57 ++ test-e2e/cases/MOS-D5-001/execution.yaml | 16 + test-e2e/cases/MOS-D5-001/test.py | 113 +++ test-e2e/cases/MPC-D1-001/case.yaml | 105 ++ test-e2e/cases/MPC-D1-001/execution.yaml | 16 + test-e2e/cases/MPC-D1-001/test.py | 305 ++++++ test-e2e/cases/MPN-D1-001/case.yaml | 69 ++ test-e2e/cases/MPN-D1-001/execution.yaml | 16 + test-e2e/cases/MPN-D1-001/test.py | 232 +++++ test-e2e/cases/MPR-D1-001/case.yaml | 74 ++ test-e2e/cases/MPR-D1-001/execution.yaml | 16 + test-e2e/cases/MPR-D1-001/test.py | 165 ++++ test-e2e/cases/MST-D3-001/case.yaml | 95 ++ .../cases/MST-D3-001/empty_response_cases.py | 215 ++++ test-e2e/cases/MST-D3-001/execution.yaml | 18 + test-e2e/cases/MST-D3-001/http_peer.py | 192 ++++ .../MST-D3-001/prompt_generation_cases.py | 144 +++ test-e2e/cases/MST-D3-001/test.py | 496 ++++++++++ test-e2e/cases/PW-MODEL-01/case.yaml | 35 +- test-e2e/cases/PW-MODEL-01/execution.yaml | 5 + test-e2e/cases/PW-MODEL-01/test.spec.ts | 26 +- .../UT-SDK-AUTO-9A76928E3A7B1ADF/case.yaml | 2 +- .../UT-SDK-AUTO-9A76928E3A7B1ADF/test.py | 4 +- .../MODEL-ONBOARDING-FEEDBACK-20261016.yaml | 40 + .../MODEL-ONBOARDING-LAYOUT-20261015.yaml | 38 + .../model-access-simplification.yaml | 29 + .../MODEL-CATALOG-CHINA-20261016.yaml | 33 + .../MODEL-EXPERIENCE-OVERLAY-20261016.yaml | 30 + .../MODEL-ONBOARDING-LAYOUT-20261016.yaml | 40 + ...EL-OUTLET-CAPABILITY-STORAGE-20261013.yaml | 27 + ...DEL-OUTLET-PARAMETER-STORAGE-20261013.yaml | 27 + .../MODEL-OUTLET-PERSISTENCE-20261013.yaml | 30 + .../MODEL-OUTLET-RESOLUTION-20261013.yaml | 32 + .../MODEL-OUTLET-ROW-STORAGE-20261013.yaml | 27 + .../MODEL-OUTLET-RULE-STORAGE-20261013.yaml | 27 + .../MODEL-OUTLET-SCHEMA-20261013.yaml | 28 + .../MODEL-PARAMETER-LAYERS-20261015.yaml | 51 + .../MODEL-PROFILE-CATALOG-20261013.yaml | 43 + .../MODEL-PROFILE-COMPOSITION-20261013.yaml | 45 + .../MODEL-PROFILE-CONTRACTS-20261013.yaml | 28 + .../MODEL-PROFILE-NORMALIZATION-20261013.yaml | 56 ++ .../MODEL-PROFILE-PARAMETERS-20261013.yaml | 31 + .../MODEL-TRANSPORT-20261013.yaml | 108 ++ .../SESSION-MODEL-UNIFY-20261020.yaml | 33 + .../test-fixes/MODEL-OUTLET-UI-20261015.yaml | 25 + .../BE-MODEL-OUTLET-PERSISTENCE/cases.md | 5 + .../BE-MODEL-OUTLET-PERSISTENCE/feature.yaml | 22 + .../features/BE-MODEL-OUTLET-SCHEMA/cases.md | 5 + .../BE-MODEL-OUTLET-SCHEMA/feature.yaml | 20 + .../features/CHAT-MODEL-CONTROLS/cases.md | 1 + .../features/CHAT-MODEL-CONTROLS/feature.yaml | 5 +- .../SDK-MODEL-OUTLET-RESOLUTION/cases.md | 5 + .../SDK-MODEL-OUTLET-RESOLUTION/feature.yaml | 25 + .../SDK-MODEL-OUTLET-STORAGE/cases.md | 5 + .../SDK-MODEL-OUTLET-STORAGE/feature.yaml | 26 + .../SDK-MODEL-PROFILE-CATALOG/cases.md | 5 + .../SDK-MODEL-PROFILE-CATALOG/feature.yaml | 22 + .../SDK-MODEL-PROFILE-COMPOSITION/cases.md | 5 + .../feature.yaml | 28 + .../SDK-MODEL-PROFILE-CONTRACTS/cases.md | 5 + .../SDK-MODEL-PROFILE-CONTRACTS/feature.yaml | 31 + .../SDK-MODEL-PROFILE-NORMALIZATION/cases.md | 5 + .../feature.yaml | 31 + .../SDK-MODEL-PROFILE-PARAMETERS/cases.md | 5 + .../SDK-MODEL-PROFILE-PARAMETERS/feature.yaml | 21 + .../features/SDK-MODEL-TRANSPORT/cases.md | 5 + .../features/SDK-MODEL-TRANSPORT/feature.yaml | 27 + .../automation/d4/fixtures/model-controls.ts | 94 +- .../d4/fixtures/workbench-controls.ts | 2 +- .../infra/automation/d4/pages/model.page.ts | 78 +- .../infra/automation/model_controls/api.py | 25 +- .../automation/model_controls/database.py | 23 +- .../automation/model_controls/profiles.py | 58 ++ .../automation/model_controls/runtime.py | 42 +- 111 files changed, 8063 insertions(+), 302 deletions(-) create mode 100644 frontend/app/[locale]/models/components/model/ModelOutletAddForm.tsx create mode 100644 frontend/app/[locale]/models/components/model/ModelOutletEditDialog.tsx create mode 100644 frontend/app/[locale]/models/components/model/profileSuggestion.ts create mode 100644 frontend/components/model/ModelRequestSettings.tsx create mode 100644 frontend/components/model/RequestParametersEditor.tsx create mode 100644 frontend/tests/component/modelOutletAddForm.test.tsx create mode 100644 frontend/tests/component/modelOutletBatch.test.tsx create mode 100644 frontend/tests/component/modelOutletEditing.test.tsx create mode 100644 frontend/tests/component/modelProfileSuggestion.test.tsx create mode 100644 frontend/tests/component/requestParametersEditor.test.tsx create mode 100644 test-e2e/cases/CHAT-MODEL-CONTROLS-D5-001/case.yaml create mode 100644 test-e2e/cases/CHAT-MODEL-CONTROLS-D5-001/execution.yaml create mode 100644 test-e2e/cases/CHAT-MODEL-CONTROLS-D5-001/test_migration.py create mode 100644 test-e2e/cases/MOP-D3-001/case.yaml create mode 100644 test-e2e/cases/MOP-D3-001/execution.yaml create mode 100644 test-e2e/cases/MOP-D3-001/test.py create mode 100644 test-e2e/cases/MOS-D5-001/case.yaml create mode 100644 test-e2e/cases/MOS-D5-001/execution.yaml create mode 100644 test-e2e/cases/MOS-D5-001/test.py create mode 100644 test-e2e/cases/MPC-D1-001/case.yaml create mode 100644 test-e2e/cases/MPC-D1-001/execution.yaml create mode 100644 test-e2e/cases/MPC-D1-001/test.py create mode 100644 test-e2e/cases/MPN-D1-001/case.yaml create mode 100644 test-e2e/cases/MPN-D1-001/execution.yaml create mode 100644 test-e2e/cases/MPN-D1-001/test.py create mode 100644 test-e2e/cases/MPR-D1-001/case.yaml create mode 100644 test-e2e/cases/MPR-D1-001/execution.yaml create mode 100644 test-e2e/cases/MPR-D1-001/test.py create mode 100644 test-e2e/cases/MST-D3-001/case.yaml create mode 100644 test-e2e/cases/MST-D3-001/empty_response_cases.py create mode 100644 test-e2e/cases/MST-D3-001/execution.yaml create mode 100644 test-e2e/cases/MST-D3-001/http_peer.py create mode 100644 test-e2e/cases/MST-D3-001/prompt_generation_cases.py create mode 100644 test-e2e/cases/MST-D3-001/test.py create mode 100644 test-e2e/changes/bugs/MODEL-ONBOARDING-FEEDBACK-20261016.yaml create mode 100644 test-e2e/changes/bugs/MODEL-ONBOARDING-LAYOUT-20261015.yaml create mode 100644 test-e2e/changes/refactors/model-access-simplification.yaml create mode 100644 test-e2e/changes/requirements/MODEL-CATALOG-CHINA-20261016.yaml create mode 100644 test-e2e/changes/requirements/MODEL-EXPERIENCE-OVERLAY-20261016.yaml create mode 100644 test-e2e/changes/requirements/MODEL-ONBOARDING-LAYOUT-20261016.yaml create mode 100644 test-e2e/changes/requirements/MODEL-OUTLET-CAPABILITY-STORAGE-20261013.yaml create mode 100644 test-e2e/changes/requirements/MODEL-OUTLET-PARAMETER-STORAGE-20261013.yaml create mode 100644 test-e2e/changes/requirements/MODEL-OUTLET-PERSISTENCE-20261013.yaml create mode 100644 test-e2e/changes/requirements/MODEL-OUTLET-RESOLUTION-20261013.yaml create mode 100644 test-e2e/changes/requirements/MODEL-OUTLET-ROW-STORAGE-20261013.yaml create mode 100644 test-e2e/changes/requirements/MODEL-OUTLET-RULE-STORAGE-20261013.yaml create mode 100644 test-e2e/changes/requirements/MODEL-OUTLET-SCHEMA-20261013.yaml create mode 100644 test-e2e/changes/requirements/MODEL-PARAMETER-LAYERS-20261015.yaml create mode 100644 test-e2e/changes/requirements/MODEL-PROFILE-CATALOG-20261013.yaml create mode 100644 test-e2e/changes/requirements/MODEL-PROFILE-COMPOSITION-20261013.yaml create mode 100644 test-e2e/changes/requirements/MODEL-PROFILE-CONTRACTS-20261013.yaml create mode 100644 test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml create mode 100644 test-e2e/changes/requirements/MODEL-PROFILE-PARAMETERS-20261013.yaml create mode 100644 test-e2e/changes/requirements/MODEL-TRANSPORT-20261013.yaml create mode 100644 test-e2e/changes/requirements/SESSION-MODEL-UNIFY-20261020.yaml create mode 100644 test-e2e/changes/test-fixes/MODEL-OUTLET-UI-20261015.yaml create mode 100644 test-e2e/features/BE-MODEL-OUTLET-PERSISTENCE/cases.md create mode 100644 test-e2e/features/BE-MODEL-OUTLET-PERSISTENCE/feature.yaml create mode 100644 test-e2e/features/BE-MODEL-OUTLET-SCHEMA/cases.md create mode 100644 test-e2e/features/BE-MODEL-OUTLET-SCHEMA/feature.yaml create mode 100644 test-e2e/features/SDK-MODEL-OUTLET-RESOLUTION/cases.md create mode 100644 test-e2e/features/SDK-MODEL-OUTLET-RESOLUTION/feature.yaml create mode 100644 test-e2e/features/SDK-MODEL-OUTLET-STORAGE/cases.md create mode 100644 test-e2e/features/SDK-MODEL-OUTLET-STORAGE/feature.yaml create mode 100644 test-e2e/features/SDK-MODEL-PROFILE-CATALOG/cases.md create mode 100644 test-e2e/features/SDK-MODEL-PROFILE-CATALOG/feature.yaml create mode 100644 test-e2e/features/SDK-MODEL-PROFILE-COMPOSITION/cases.md create mode 100644 test-e2e/features/SDK-MODEL-PROFILE-COMPOSITION/feature.yaml create mode 100644 test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/cases.md create mode 100644 test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/feature.yaml create mode 100644 test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/cases.md create mode 100644 test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/feature.yaml create mode 100644 test-e2e/features/SDK-MODEL-PROFILE-PARAMETERS/cases.md create mode 100644 test-e2e/features/SDK-MODEL-PROFILE-PARAMETERS/feature.yaml create mode 100644 test-e2e/features/SDK-MODEL-TRANSPORT/cases.md create mode 100644 test-e2e/features/SDK-MODEL-TRANSPORT/feature.yaml create mode 100644 test-e2e/infra/automation/model_controls/profiles.py diff --git a/frontend/app/[locale]/agents/[agentId]/components/agent-prompt.tsx b/frontend/app/[locale]/agents/[agentId]/components/agent-prompt.tsx index 41892e612f..be3bec1b42 100644 --- a/frontend/app/[locale]/agents/[agentId]/components/agent-prompt.tsx +++ b/frontend/app/[locale]/agents/[agentId]/components/agent-prompt.tsx @@ -34,6 +34,7 @@ import Image from "next/image"; import { useAgentStore } from "@/stores/agentStore"; import { ModelDrawer } from "@/components/resource-picker/ModelDrawer"; +import { AgentModelParameters } from "@/components/model/AgentModelParameters"; import { ModelParamsModal } from "@/components/resource-picker/ModelParamsModal"; import { useModelList } from "@/hooks/model/useModelList"; import { useInferenceFieldSpecs } from "@/hooks/model/useInferenceFieldSpecs"; @@ -969,7 +970,22 @@ export default function AgentPrompt({ ))} {/* Per-model parameter override popup */} - setConfiguringModelId(null)} + onApply={(entry) => { + const updated = { ...modelParamsOverride }; + if (Object.keys(entry).length) updated[String(configuringModel.id)] = entry; + else delete updated[String(configuringModel.id)]; + updateAgent({ model_params_override: Object.keys(updated).length ? updated : null }); + }} + /> + ) : + />} ); } diff --git a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx index 2034be1179..64179e6ce2 100644 --- a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx @@ -47,6 +47,13 @@ import { buildVoiceCredentialParams, } from "./ModelAdvancedSettings"; import { ModelAdvancedConfig } from "./ModelAdvancedConfig"; +import { ModelOutletAddForm } from "./ModelOutletAddForm"; +import { + createModelOutlet, + previewModelProfile, + validateModelOutlet, +} from "@/services/modelProfileService"; +import type { ModelProfileDraftRequest } from "@/types/modelProfile"; import { DEFAULT_CHUNKING_BATCH_SIZE, DEFAULT_EXPECTED_CHUNK_SIZE, @@ -59,26 +66,18 @@ import { useTypeOptions, } from "./modelTypeUi"; -/** - * v2.6.1 redesign (v0 design): the add-model dialog. - * - * Two tabs styled after the v0 ModelManagerDialog: 单个添加 (manual form: - * provider / type / name / display name / base url / key) and 批量添加 - * (fetch the provider's model list and pick what to import). Built on the - * project's shadcn/ui primitives. - * - * Deliberately simple: no per-row capacity or inference-param editing and - * no pre-submit connectivity gate (the v0 logic) — models land in - * not_detected state and are checked/edited from the library afterwards. - * Both tabs reuse the existing single-model create endpoint per row. - */ +/** LLM outlets use profile-driven forms; other model types retain their existing setup. */ const CUSTOM_PROVIDER_KEY = "__custom__"; export interface ModelAddDialogProps { isOpen: boolean; onClose: () => void; - onSuccess: (newModel?: { name: string; type: ModelType }) => void; + onSuccess: (newModel?: { + name: string; + type: ModelType; + connectionChecked?: boolean; + }) => void; tenantId?: string; initialTab?: "single" | "batch"; } @@ -91,6 +90,7 @@ export const ModelAddDialog = ({ initialTab = "single", }: ModelAddDialogProps) => { const { t } = useTranslation(); + const [wide, setWide] = useState(true); const [tab, setTab] = useState<"single" | "batch">(initialTab); // Reset the tab whenever the dialog reopens (the sync-ModelEngine button @@ -101,48 +101,47 @@ export const ModelAddDialog = ({ return ( !v && onClose()}> - - - - {t("modelConfig.addDialog.title", { defaultValue: "添加模型" })} - - - {t("modelConfig.addDialog.description", { - defaultValue: - "手动添加单个模型,或从服务商一键批量导入可用模型。", - })} - - - -
-
+ + +
+ + {t("modelConfig.addDialog.title", { defaultValue: "添加模型" })} + + + {t("modelConfig.addDialog.description", { + defaultValue: + "手动添加单个模型,或从服务商一键批量导入可用模型。", + })} + +
+
setTab("single")} icon={} - title={t("modelConfig.addDialog.singleTab", { - defaultValue: "单个添加", - })} - desc={t("modelConfig.addDialog.singleTabDesc", { - defaultValue: "手动填写模型信息", - })} + title={t("modelConfig.addDialog.singleTab")} /> setTab("batch")} icon={} - title={t("modelConfig.addDialog.batchTab", { - defaultValue: "批量添加", - })} - desc={t("modelConfig.addDialog.batchTabDesc", { - defaultValue: "从服务商导入", - })} + title={t("modelConfig.addDialog.batchTab")} />
-
+ {tab === "single" ? ( void; icon: React.ReactNode; title: string; - desc: string; }) { return ( ); } @@ -385,21 +371,32 @@ function capacitySuggestionToSettings( } function SingleAddForm({ + onLayoutChange, onDone, onSuccess, tenantId, }: { onDone: () => void; - onSuccess: (newModel?: { name: string; type: ModelType }) => void; + onSuccess: (newModel?: { + name: string; + type: ModelType; + connectionChecked?: boolean; + }) => void; tenantId?: string; + onLayoutChange: (wide: boolean) => void; }) { const { t } = useTranslation(); const { message } = App.useApp(); - const presets = useProviderPresets(true); const typeOptions = useTypeOptions(); const [provider, setProvider] = useState(CUSTOM_PROVIDER_KEY); const [type, setType] = useState(MODEL_TYPES.LLM as ModelType); + const useOutletForm = type === MODEL_TYPES.LLM && !tenantId; + useEffect( + () => onLayoutChange(useOutletForm), + [useOutletForm, onLayoutChange] + ); + const presets = useProviderPresets(!useOutletForm); const [name, setName] = useState(""); const [displayName, setDisplayName] = useState(""); const [baseUrl, setBaseUrl] = useState(""); @@ -424,7 +421,7 @@ function SingleAddForm({ // Auto-detect capacity + reasoning capability whenever name and URL are // filled (debounced); user-saved overrides (the gear) win over suggestions. useEffect(() => { - if (!name.trim() || !baseUrl.trim()) { + if (useOutletForm || !name.trim() || !baseUrl.trim()) { setSuggestion(undefined); setCapability(undefined); return; @@ -453,7 +450,7 @@ function SingleAddForm({ cancelled = true; clearTimeout(timer); }; - }, [name, baseUrl, provider, type]); + }, [name, baseUrl, provider, type, useOutletForm]); function changeProvider(next: string) { setProvider(next); @@ -561,6 +558,37 @@ function SingleAddForm({ } } + if (useOutletForm) + return ( + setType(value as ModelType)} + > + + + + + {typeOptions.map((option) => ( + + {option.label} + + ))} + + + } + onDone={onDone} + onSuccess={onSuccess} + onPendingChange={setSubmitting} + /> + ); + return (
@@ -825,6 +853,7 @@ interface FetchedRow { * includes both capacity and inference params (the component renders them * all in override mode), plus an optional custom display name. */ interface RowOverride { + outletDraft?: ModelProfileDraftRequest; displayName?: string; settings?: ModelAdvancedSettingsValue; } @@ -835,7 +864,11 @@ function BatchAddForm({ tenantId, }: { onDone: () => void; - onSuccess: (newModel?: { name: string; type: ModelType }) => void; + onSuccess: (newModel?: { + name: string; + type: ModelType; + connectionChecked?: boolean; + }) => void; tenantId?: string; }) { const { t } = useTranslation(); @@ -922,14 +955,6 @@ function BatchAddForm({ } async function fetchModels() { - if (!apiKey.trim()) { - message.warning( - t("model.dialog.v2.warn.apiKeyRequired", { - defaultValue: "请先输入 API Key", - }) - ); - return; - } if (provider === CUSTOM_PROVIDER_KEY && !baseUrl.trim()) { message.warning( t("modelConfig.addDialog.baseUrlRequired", { @@ -1009,9 +1034,41 @@ function BatchAddForm({ } } + function outletDraft(row: FetchedRow): ModelProfileDraftRequest { + const configured = rowOverrides[row.id]?.outletDraft; + return { + selection_mode: "auto", + ...configured, + authentication_scheme: apiKey.trim() ? "bearer_token_v1" : "no_auth_v1", + request_model_id: row.model_name, + provider_info: { + ...configured?.provider_info, + base_url: baseUrl.trim(), + provider_id: provider === CUSTOM_PROVIDER_KEY ? null : provider, + }, + }; + } + async function checkRow(row: FetchedRow) { setRowCheck((s) => ({ ...s, [row.id]: "checking" })); try { + if (!tenantId && row.model_type === MODEL_TYPES.LLM) { + const draft = outletDraft(row); + const result = await validateModelOutlet({ + draft: { + ...draft, + api_key: + draft.authentication_scheme === "no_auth_v1" + ? null + : apiKey.trim() || null, + }, + }); + setRowCheck((previous) => ({ + ...previous, + [row.id]: result.ok ? "available" : "unavailable", + })); + return; + } // voice 凭证随探测透传,按当前类型门控防止类型切换后过期值泄漏 // (只取手动 override——目录建议不携带语音凭证) const result = await modelService.verifyModelConfigConnectivity({ @@ -1072,6 +1129,40 @@ function BatchAddForm({ // reported as a per-row failure instead of silently suffixed. const displayName = override?.displayName?.trim() || row.model_name; try { + if (!tenantId && row.model_type === MODEL_TYPES.LLM) { + const draft = outletDraft(row); + const preview = await previewModelProfile(draft); + // A configured row must be reviewed again if its catalog changed. + if ( + draft.catalog_revision && + draft.catalog_revision !== preview.catalog_revision + ) { + throw new Error(t("modelProfile.previewRequired")); + } + const saved = await createModelOutlet({ + ...draft, + catalog_revision: preview.catalog_revision, + api_key: + draft.authentication_scheme === "no_auth_v1" + ? null + : apiKey.trim() || null, + }); + createdDisplayNames.push( + draft.display_name?.trim() || row.model_name + ); + created++; + try { + const checked = await validateModelOutlet({ + outlet_id: saved.model_id, + expected_revision: saved.config_revision, + }); + if (!checked.ok) + message.warning(t("modelProfile.savedUnavailable")); + } catch { + message.warning(t("modelProfile.savedCheckFailed")); + } + continue; + } const params: Record = { name: row.model_name, type: row.model_type, @@ -1162,7 +1253,27 @@ function BatchAddForm({ ); } onSuccess(); - onDone(); + if ( + failed.length > 0 && + !tenantId && + rows.every((row) => row.model_type === MODEL_TYPES.LLM) + ) { + const remaining = new Set(failed); + setFetched((previous) => + previous.filter( + (row) => remaining.has(row.model_name) || !selected[row.id] + ) + ); + setSelected( + Object.fromEntries( + rows + .filter((row) => remaining.has(row.model_name)) + .map((row) => [row.id, true]) + ) + ); + } else { + onDone(); + } return; } // All creates failed: keep the dialog open so the operator can fix the @@ -1200,7 +1311,7 @@ function BatchAddForm({ return (
-
+
-
+
@@ -1411,6 +1522,7 @@ function BatchAddForm({ size="icon" variant="ghost" className="size-7" + aria-label={`${t("modelProfile.checkModel")} ${row.model_name}`} onClick={(e) => { e.stopPropagation(); checkRow(row); @@ -1423,6 +1535,7 @@ function BatchAddForm({ size="icon" variant="ghost" className="size-7" + aria-label={`${t("modelProfile.configureModel")} ${row.model_name}`} onClick={(e) => { e.stopPropagation(); setSettingsRowId(row.id); @@ -1444,7 +1557,7 @@ function BatchAddForm({

{t("modelConfig.addDialog.fetchHint", { - defaultValue: "填写 API Key 后获取模型列表", + defaultValue: "填写服务连接信息后获取模型列表", })}

@@ -1499,24 +1612,65 @@ function BatchAddForm({ suggestions. key forces remount per row so useState picks up the correct initial values (otherwise the first open's empty state sticks for all subsequent rows). */} - {settingsRowId && ( - r.id === settingsRowId) ?? null} - override={ - rowOverrides[settingsRowId] ?? rowSuggestions[settingsRowId] - } - reasoningCapability={rowCapabilities[settingsRowId]} - onModelTypeChange={(next) => updateRowType(settingsRowId, next)} - onSave={(next) => { - setRowOverrides((prev) => ({ - ...prev, - [settingsRowId]: next, - })); - setSettingsRowId(null); - }} - onClose={() => setSettingsRowId(null)} - /> + {settingsRowId && + !tenantId && + fetched.find((row) => row.id === settingsRowId)?.model_type === + MODEL_TYPES.LLM ? ( + !open && setSettingsRowId(null)}> + + + + {fetched.find((row) => row.id === settingsRowId)?.model_name} + + + {t("modelProfile.batchConfigurationHint")} + + + row.id === settingsRowId)! + ), + onApply: (draft) => { + setRowOverrides((previous) => ({ + ...previous, + [settingsRowId]: { outletDraft: draft }, + })); + setRowCheck((previous) => { + const next = { ...previous }; + delete next[settingsRowId]; + return next; + }); + setSettingsRowId(null); + }, + }} + onPendingChange={() => {}} + onSuccess={() => {}} + onDone={() => setSettingsRowId(null)} + /> + + + ) : ( + settingsRowId && ( + r.id === settingsRowId) ?? null} + override={ + rowOverrides[settingsRowId] ?? rowSuggestions[settingsRowId] + } + reasoningCapability={rowCapabilities[settingsRowId]} + onModelTypeChange={(next) => updateRowType(settingsRowId, next)} + onSave={(next) => { + setRowOverrides((prev) => ({ + ...prev, + [settingsRowId]: next, + })); + setSettingsRowId(null); + }} + onClose={() => setSettingsRowId(null)} + /> + ) )}
); diff --git a/frontend/app/[locale]/models/components/model/ModelLibraryList.tsx b/frontend/app/[locale]/models/components/model/ModelLibraryList.tsx index 5c9424f653..11a6b9c9e7 100644 --- a/frontend/app/[locale]/models/components/model/ModelLibraryList.tsx +++ b/frontend/app/[locale]/models/components/model/ModelLibraryList.tsx @@ -1,3 +1,5 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + "use client"; import { useEffect, useMemo, useState } from "react"; @@ -33,6 +35,8 @@ import { cn } from "@/lib/utils"; import { MODEL_SOURCES } from "@/const/modelConfig"; import { ModelOption, ModelType, ModelSource } from "@/types/modelConfig"; import { Can } from "@/components/permission/Can"; +import { ModelMigrationBadge } from "./ModelMigrationBadge"; +import { ModelMigrationReviewButton } from "./ModelMigrationReviewButton"; import { STATUS_DOT_CLASS, TYPE_BADGE_CLASS, @@ -68,7 +72,9 @@ export function ModelLibraryList({ onCheck, onEdit, onDelete, + onReviewed, }: { + onReviewed?: () => void; models: ModelOption[]; /** displayName -> slot keys this model occupies (drives the 默认 badge). */ defaultSlotMap: Record; @@ -214,6 +220,7 @@ export function ModelLibraryList({ model={m} isDefault={!!(defaultSlotMap[m.displayName] || []).length} onCheck={() => onCheck(m.displayName, m.type)} + onReviewed={onReviewed} onEdit={() => onEdit(m)} onDelete={() => onDelete(m)} /> @@ -292,7 +299,9 @@ function ModelRow({ onCheck, onEdit, onDelete, + onReviewed, }: { + onReviewed?: () => void; model: ModelOption; isDefault: boolean; onCheck: () => void; @@ -316,6 +325,7 @@ function ModelRow({ {model.displayName || model.name} + {isDefault && ( + void; + }; + onDone: () => void; + onPendingChange: (pending: boolean) => void; + onSuccess: (model: { + name: string; + type: "llm"; + connectionChecked?: boolean; + }) => void; +} + +export function ModelOutletAddForm({ + onDone, + onSuccess, + onPendingChange, + configuration, + typeControl, +}: ModelOutletAddFormProps) { + const { t } = useTranslation(); + const { message } = App.useApp(); + const initial = configuration?.draft; + const [baseUrl, setBaseUrl] = useState(initial?.provider_info.base_url ?? ""); + const [name, setName] = useState(initial?.request_model_id ?? ""); + const [displayName, setDisplayName] = useState(initial?.display_name ?? ""); + const [apiKey, setApiKey] = useState(""); + const [providerKey, setProviderKey] = useState( + initial?.provider_profile?.key ?? "__custom__" + ); + const [modelKey, setModelKey] = useState( + initial?.model_profile?.key ?? + (initial?.selection_mode === "manual" ? "__other__" : undefined) + ); + const [advanced, setAdvanced] = useState( + initial?.selection_mode === "manual" && !initial.model_profile + ? ["advanced"] + : [] + ); + const [sslVerify, setSslVerify] = useState( + initial?.provider_info.ssl_verify ?? true + ); + const [timeout, setTimeoutValue] = useState( + initial?.provider_info.timeout_seconds ?? 120 + ); + const [values, setValues] = useState>( + initial?.parameter_values ?? {} + ); + const [thinkingControl, setThinkingControl] = useState( + initial?.thinking_control ?? "profile_default" + ); + const [thinkingMode, setThinkingMode] = useState< + ThinkingDegreeMode | undefined + >(initial?.thinking_mode); + const [extrasValid, setExtrasValid] = useState(true); + const [extras, setExtras] = useState( + JSON.stringify(initial?.extra_api_parameters ?? [], null, 2) + ); + const [reserve] = useState( + initial?.context_configuration?.output_reserve_tokens ?? null + ); + const [tokenizer] = useState( + initial?.context_configuration?.tokenizer_family ?? "" + ); + const [limits, setLimits] = useState< + NonNullable + >(initial?.context_limits ?? {}); + const providers = useQuery({ + queryKey: ["provider-profiles"], + queryFn: ({ signal }) => listProviderProfiles({ page_size: 100 }, signal), + }); + const models = useQuery({ + queryKey: ["model-profiles"], + queryFn: ({ signal }) => listModelProfiles({ page_size: 100 }, signal), + }); + const provider = providers.data?.items.find( + (item) => item.key === providerKey + ); + const model = useMemo( + () => + modelKey + ? models.data?.items.find((item) => item.key === modelKey) + : suggestModelProfile(name, models.data?.items ?? []), + [modelKey, models.data, name] + ); + const parsedExtras = useMemo( + () => (extrasValid ? parseRequestParameters(extras) : null), + [extras, extrasValid] + ); + + const draft = useMemo( + () => ({ + selection_mode: modelKey === "__other__" ? "manual" : "auto", + provider_profile: provider + ? { key: provider.key, revision: provider.revision } + : null, + model_profile: model + ? { key: model.key, revision: model.revision } + : null, + provider_info: { + ...initial?.provider_info, + base_url: baseUrl.trim(), + ssl_verify: sslVerify, + timeout_seconds: timeout, + }, + request_model_id: name.trim(), + display_name: displayName.trim() || name.trim(), + authentication_scheme: configuration + ? (initial?.authentication_scheme ?? "no_auth_v1") + : apiKey.trim() + ? "bearer_token_v1" + : "no_auth_v1", + context_limits: limits, + context_configuration: { + output_reserve_tokens: reserve, + tokenizer_family: tokenizer || null, + }, + parameter_values: values, + thinking_control: thinkingControl, + thinking_mode: thinkingMode, + extra_api_parameters: parsedExtras ?? [], + }), + [ + thinkingControl, + thinkingMode, + modelKey, + provider, + model, + baseUrl, + sslVerify, + timeout, + name, + displayName, + apiKey, + configuration, + initial?.authentication_scheme, + values, + limits, + reserve, + tokenizer, + parsedExtras, + initial?.provider_info, + ] + ); + const [debounced, setDebounced] = useState(draft); + useEffect(() => { + const timer = setTimeout(() => setDebounced(draft), 300); + return () => clearTimeout(timer); + }, [draft]); + const [catalogRefresh, setCatalogRefresh] = useState(0); + const [refreshingCatalog, setRefreshingCatalog] = useState(false); + const refreshCatalog = async () => { + if (!modelKey && model) setModelKey(model.key); + setRefreshingCatalog(true); + try { + const results = await Promise.all([ + providers.refetch(), + models.refetch(), + ]); + if (results.every((result) => !result.isError)) { + setCatalogRefresh((version) => version + 1); + } + } finally { + setRefreshingCatalog(false); + } + }; + const previewIdentity = (request: ModelProfileDraftRequest) => + JSON.stringify([ + request.model_profile, + request.provider_profile, + request.request_model_id, + request.provider_info.base_url, + ]); + const currentIdentity = previewIdentity(draft); + const [lastPreview, setLastPreview] = useState<{ + identity: string; + result: ModelProfileDraftResponse; + }>(); + const preview = useQuery({ + queryKey: ["model-profile-preview", debounced, catalogRefresh], + queryFn: async ({ signal }) => { + const result = await previewModelProfile(debounced, signal); + if (!signal.aborted) + setLastPreview({ identity: previewIdentity(debounced), result }); + return result; + }, + retry: false, + enabled: + !refreshingCatalog && + !!debounced.request_model_id && + !!debounced.provider_info.base_url && + parsedExtras !== null, + }); + const previewCurrent = previewIdentity(debounced) === currentIdentity; + const fullPreview = previewCurrent ? preview.data : undefined; + const view = + fullPreview ?? + (lastPreview?.identity === currentIdentity + ? lastPreview.result + : undefined) ?? + model?.configuration; + const previewConflict = + previewCurrent && + preview.error instanceof ApiError && + (String(preview.error.code) === "409" || + preview.error.code === "MODEL_OUTLET_VERSION_CONFLICT"); + const previewDiagnostic = + preview.error instanceof ApiError + ? [preview.error.details?.category, preview.error.details?.field] + .filter((value): value is string => typeof value === "string") + .join(" · ") + : undefined; + const sampling = outletSamplingValues(values, view?.parameter_values); + const samplingRules = outletSamplingRules(view?.field_specs ?? []); + const ready = + samplingComplete(sampling) && + (providerKey === "__custom__" || !!provider) && + (!modelKey || modelKey === "__other__" || !!model) && + !!draft.provider_info.base_url.trim() && + !!preview.data && + JSON.stringify(debounced) === JSON.stringify(draft) && + !refreshingCatalog && + !preview.isFetching && + !preview.isError && + parsedExtras !== null; + const save = useMutation({ + onMutate: () => onPendingChange(true), + onSettled: () => onPendingChange(false), + mutationFn: async () => { + if (!ready || !preview.data) + throw new Error(t("modelProfile.previewRequired")); + if (configuration) { + configuration.onApply({ + ...draft, + parameter_values: sampling, + catalog_revision: preview.data.catalog_revision, + }); + return null; + } + const saved = await createModelOutlet({ + ...draft, + parameter_values: sampling, + catalog_revision: preview.data.catalog_revision, + api_key: apiKey.trim() || null, + }); + try { + const result = await validateModelOutlet({ + outlet_id: saved.model_id, + expected_revision: saved.config_revision, + }); + if (!result.ok) message.warning(t("modelProfile.savedUnavailable")); + else message.success(t("modelProfile.savedAvailable")); + } catch { + message.warning(t("modelProfile.savedCheckFailed")); + } + return saved; + }, + onSuccess: () => { + if (configuration) return; + onSuccess({ name: name.trim(), type: "llm", connectionChecked: true }); + onDone(); + }, + onError: () => { + message.error(t("modelProfile.saveFailed")); + }, + }); + const changeValue = (key: string, value: unknown) => + setValues((previous) => ({ ...previous, [key]: value })); + const resetValue = (key: string) => + setValues((previous) => { + const next = { ...previous }; + delete next[key]; + return next; + }); + const inheritedOutputLimit = profileOutputLimit( + view?.capabilities.context, + limits.combined_window + ); + const field = (rule: ModelParameterRule) => ( + + ); + return ( +
+ +
+
+
+ + + )} + +
+ + {t("modelConfig.addDialog.modelName")}{" "} + + {t("modelConfig.addDialog.modelNameHint")} + + + } + > + setName(event.target.value)} + /> + + + {t("modelProfile.modelProfile")} + {!modelKey && model && isApproximateProfile(name, model) && ( + + {t("modelProfile.approximateMatch")} + + )} + + } + > + setName(event.target.value)} + /> + + + + setConnection({ ...connection, base_url: event.target.value }) + } + /> + + + { + if (maskedKey) event.target.select(); + }} + onChange={(event) => { + const next = event.target.value; + if ( + !next.trim() || + /^\*+$/.test(next) || + next.includes("********") + ) { + setKeyVisible(false); + } + setKeyEdited(true); + setKey(next); + }} + /> + + + setConnection({ ...connection, ssl_verify }) + } + timeout={connection.timeout_seconds} + onTimeoutChange={(timeout_seconds) => + setConnection({ ...connection, timeout_seconds }) + } + extras={extras} + onExtrasChange={setExtras} + onValidityChange={setExtrasValid} + pending={save.isPending} + /> +
+ + {[ + ...outletSamplingRules( + saved.field_specs ?? profile.parameter_rules + ), + ...(saved.field_specs ?? profile.parameter_rules).filter( + (rule) => + rule.visible && + !["temperature", "top_p", "max_output_tokens"].includes( + rule.key + ) && + rule.ui_group !== "reasoning" + ), + ].map((rule) => ( + updateParameter(field)} + /> + ))} +
+ } + thinking={ + { + if ( + !profile.parameter_rules.some( + (rule) => rule.key === "enable_thinking" && rule.visible + ) + ) + setThinkingControl( + thinkingControl === "profile_default" + ? "enable_thinking" + : thinkingControl + ); + const next: Record = { + ...values, + enable_thinking: enabled, + }; + const removed = enabled + ? [] + : thinkingDegreeKeys.filter((key) => + Object.hasOwn(values, key) + ); + removed.forEach((key) => delete next[key]); + setValues(next); + setDeleted( + Array.from( + new Set([ + ...deleted.filter((key) => key !== "enable_thinking"), + ...removed, + ]) + ) + ); + }} + onModeChange={(mode) => { + setThinkingMode(mode); + const next = { ...values }; + const removed = thinkingDegreeKeys.filter((key) => + Object.hasOwn(values, key) + ); + removed.forEach((key) => delete next[key]); + setValues(next); + setDeleted(Array.from(new Set([...deleted, ...removed]))); + }} + onValueChange={updateParameter} + /> + } + capacity={ + + setLimits({ ...limits, [key]: value }) + } + /> + } + /> +
+
+ {save.isError && ( + + )} +
+ + {t("modelProfile.parameterPriority")} + + + {t("common.cancel")} + + + {t("common.save")} + +
+ + ); +} diff --git a/frontend/app/[locale]/models/components/model/profileSuggestion.ts b/frontend/app/[locale]/models/components/model/profileSuggestion.ts new file mode 100644 index 0000000000..9e4610ad05 --- /dev/null +++ b/frontend/app/[locale]/models/components/model/profileSuggestion.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import type { AtomicProfileSummary } from "@/types/modelProfile"; + +export const normalizeProfileKeyword = (value: string) => + value.toLowerCase().replace(/[^a-z0-9]/g, ""); + +/** Exact identity wins; version-qualified prefixes produce an explicit approximate suggestion. */ +export function suggestModelProfile( + requestModelId: string, + profiles: AtomicProfileSummary[] +): AtomicProfileSummary | undefined { + const requested = normalizeProfileKeyword(requestModelId); + if (!requested) return undefined; + const ranked = profiles + .map((profile) => { + const id = normalizeProfileKeyword( + profile.key.split("/").at(-1) ?? profile.key + ); + const rawId = profile.key.split("/").at(-1) ?? profile.key; + const identity = rawId + .toLowerCase() + .match(/([a-z]+?)[\s/_-]*v?(\d+)(?:[._-](\d{1,2})(?!\d))?/); + const versions = identity + ? Array.from( + requestModelId + .toLowerCase() + .matchAll( + new RegExp( + `${identity[1]}[\\s/_-]*v?(\\d+)(?:[._-](\\d{1,2})(?!\\d))?`, + "g" + ) + ) + ) + : []; + const sameVersion = + identity && + versions.some( + (match) => match[1] === identity[2] && match[2] === identity[3] + ); + if (identity && versions.length && !sameVersion) + return { profile, id, score: 0 }; + const names = [id, normalizeProfileKeyword(profile.name)]; + const exact = names.includes(requested); + const contained = Math.max( + 0, + ...names + .filter( + (name) => + (name.length >= 6 || sameVersion) && requested.includes(name) + ) + .map((name) => name.length) + ); + const partial = + /[a-z]+\d/.test(requested) && + requested.length >= 6 && + names.some((name) => name.startsWith(requested)); + return { + profile, + id, + score: exact + ? 10000 + id.length + : contained + ? 1000 + contained + : partial + ? 100 - id.length + : 0, + }; + }) + .filter((entry) => entry.score > 0) + .sort( + (a, b) => b.score - a.score || a.profile.key.localeCompare(b.profile.key) + ); + if (!ranked[0]) return undefined; + // Duplicate exact identities from different sources need manual disambiguation. + if (ranked[0].score === ranked[1]?.score && ranked[0].id === ranked[1]?.id) + return undefined; + return ranked[0].profile; +} + +export function isApproximateProfile( + requested: string, + profile: AtomicProfileSummary +): boolean { + const normalized = normalizeProfileKeyword(requested); + return ![profile.key.split("/").at(-1) ?? profile.key, profile.name].some( + (name) => normalizeProfileKeyword(name) === normalized + ); +} + +export function matchesProfileKeywords( + query: string, + profile: AtomicProfileSummary +): boolean { + const haystack = normalizeProfileKeyword( + `${profile.key} ${profile.name} ${profile.family ?? ""}` + ); + return query + .trim() + .split(/\s+/) + .every((token) => haystack.includes(normalizeProfileKeyword(token))); +} diff --git a/frontend/app/[locale]/models/components/modelConfig.tsx b/frontend/app/[locale]/models/components/modelConfig.tsx index 1e323d8c82..ec5370fcc1 100644 --- a/frontend/app/[locale]/models/components/modelConfig.tsx +++ b/frontend/app/[locale]/models/components/modelConfig.tsx @@ -1,3 +1,5 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + import { forwardRef, useEffect, @@ -30,6 +32,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { MODEL_TYPES, MODEL_STATUS } from "@/const/modelConfig"; import { useConfig, CONFIG_QUERY_KEY } from "@/hooks/useConfig"; import { usePermission } from "@/hooks/permission/usePermission"; +import { updateModelOutlet } from "@/services/modelProfileService"; import { modelService, ModelError } from "@/services/modelService"; import { loadMemoryConfig } from "@/services/memoryService"; import { @@ -40,6 +43,7 @@ import { import log from "@/lib/logger"; import { ModelAddDialog } from "./model/ModelAddDialog"; +import { ModelOutletEditDialog } from "./model/ModelOutletEditDialog"; import { ModelEditDialog } from "./model/ModelEditDialog"; import { ModelSlotSelect, buildModelSlots } from "./model/ModelSlotSelect"; import { ModelLibraryList } from "./model/ModelLibraryList"; @@ -322,12 +326,14 @@ export const ModelConfigSection = forwardRef< try { // Partial update: only api_key + base_url are sent; the backend // leaves every other field untouched. - await modelService.updateSingleModel({ - currentDisplayName: m.displayName, - url: patch.url, - apiKey: patch.apiKey, - source: m.source, - }); + if (m.type === "llm" && m.profileSchemaVersion && m.configRevision) { + await updateModelOutlet(m.id, { expected_revision: m.configRevision, + connection: { base_url: patch.url }, ...(patch.apiKey ? { api_key: patch.apiKey } : {}) }); + } else { + await modelService.updateSingleModel({ + currentDisplayName: m.displayName, url: patch.url, apiKey: patch.apiKey, source: m.source, + }); + } } catch (e: any) { failed += 1; log.error("batch update model failed", m.displayName, e); @@ -1138,6 +1144,7 @@ export const ModelConfigSection = forwardRef< {/* -------------------- Model library list (v0 redesign) -------------------- */} { void loadModelLists(true); }} models={models} defaultSlotMap={defaultSlotMap} onCheck={verifyOneModel} @@ -1160,7 +1167,7 @@ export const ModelConfigSection = forwardRef< queryKey: CONFIG_QUERY_KEY, }); await loadModelLists(true); - if (newModel && newModel.name && newModel.type) { + if (newModel && newModel.name && newModel.type && !newModel.connectionChecked) { setTimeout(() => { verifyOneModel(newModel.name, newModel.type); }, 100); @@ -1181,7 +1188,9 @@ export const ModelConfigSection = forwardRef< /> {/* v2.6.1: per-model edit dialog (v0 design, single-page form) */} - {editingCardModel && ( + {editingCardModel && editingCardModel.type === "llm" && editingCardModel.profileSchemaVersion ? ( + setEditingCardModel(null)} onSuccess={async () => { setEditingCardModel(null); await loadModelLists(true); }} /> + ) : editingCardModel && ( & { + sslVerify: boolean; + onSslVerifyChange: (verify: boolean) => void; + timeout: number; + onTimeoutChange: (value: number) => void; + extras: string; + onExtrasChange: (value: string) => void; + onValidityChange: (valid: boolean) => void; + pending: boolean; +}) { + const { t } = useTranslation(); + return ( + + + {t("modelProfile.advanced")} + + {t("modelProfile.layout.advancedHint")} + + + ), + children: ( +
+
+ {t("modelProfile.httpGroup")} +
+
+ + onSslVerifyChange(!skip)} + /> + + + { + if (value !== null) onTimeoutChange(value); + }} + /> + +
+
+ {t("modelProfile.extraParameters")} +
+ + + +
+ ), + }, + ]} + /> + ); +} diff --git a/frontend/components/model/RequestParametersEditor.tsx b/frontend/components/model/RequestParametersEditor.tsx new file mode 100644 index 0000000000..287802c846 --- /dev/null +++ b/frontend/components/model/RequestParametersEditor.tsx @@ -0,0 +1,299 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Alert, Input, InputNumber, Select, Switch } from "antd"; +import { Plus, Trash2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { StandardButton } from "@/components/common/StandardButton"; + +type Kind = "string" | "number" | "boolean" | "object" | "array" | "null"; +interface Row { + id: number; + key: string; + kind: Kind; + value: unknown; + children: Row[]; +} +let sequence = 0; +const escapeKey = (key: string) => + key.replaceAll("~", "~0").replaceAll("/", "~1"); +function row(key: string, value: unknown = ""): Row { + const kind: Kind = + value === null + ? "null" + : Array.isArray(value) + ? "array" + : typeof value === "object" + ? "object" + : typeof value === "number" + ? "number" + : typeof value === "boolean" + ? "boolean" + : "string"; + return { + id: ++sequence, + key, + kind, + value, + children: + kind === "object" + ? Object.entries(value as Record).map(([k, v]) => + row(k, v) + ) + : kind === "array" + ? (value as unknown[]).map((v) => row("", v)) + : [], + }; +} +function parseRow(item: Row): unknown { + if (item.kind === "object") + return Object.fromEntries(parseRows(item.children, false)); + if (item.kind === "array") return item.children.map(parseRow); + if (item.kind === "null") return null; + if ( + item.kind === "number" && + (typeof item.value !== "number" || !Number.isFinite(item.value)) + ) + throw Error("invalid number"); + return item.value; +} +function parseRows(rows: Row[], paths: boolean): Array<[string, unknown]> { + const used = new Set(); + return rows.map((item) => { + const key = paths + ? item.key.startsWith("/") + ? item.key + : "/" + escapeKey(item.key) + : item.key; + if (!item.key.trim() || used.has(key)) throw Error("invalid key"); + used.add(key); + return [key, parseRow(item)]; + }); +} +export function parseRequestParameters( + value: string +): Array<{ path: string; value: unknown }> | null { + try { + const parsed: unknown = JSON.parse(value); + return Array.isArray(parsed) && + parsed.every( + (entry) => + entry && + typeof entry === "object" && + typeof entry.path === "string" && + Object.hasOwn(entry, "value") + ) + ? parsed + : null; + } catch { + return null; + } +} + +function fromJson(value: string): Row[] { + return (parseRequestParameters(value) ?? []).map((entry) => { + const parts = entry.path.slice(1).split("/"); + const decoded = parts[0].replaceAll("~1", "/").replaceAll("~0", "~"); + return row( + parts.length === 1 && !decoded.startsWith("/") ? decoded : entry.path, + entry.value + ); + }); +} + +/** Keep typed drafts local, including empty and duplicate keys, until they are valid. */ +export function RequestParametersEditor({ + value, + onChange, + disabled = false, + onValidityChange, +}: { + value: string; + onChange: (value: string) => void; + disabled?: boolean; + onValidityChange?: (valid: boolean) => void; +}) { + const { t } = useTranslation(); + const [rows, setRows] = useState(() => fromJson(value)); + const emitted = useRef(value); + const [invalid, setInvalid] = useState(false); + useEffect(() => { + if (value !== emitted.current) { + setRows(fromJson(value)); + setInvalid(false); + onValidityChange?.(true); + emitted.current = value; + } + }, [value, onValidityChange]); + const update = (next: Row[]) => { + setRows(next); + try { + const serialized = JSON.stringify( + parseRows(next, true).map(([path, val]) => ({ path, value: val })) + ); + emitted.current = serialized; + setInvalid(false); + onValidityChange?.(true); + onChange(serialized); + } catch { + setInvalid(true); + onValidityChange?.(false); + } + }; + return ( +
+

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

+ + {invalid && ( + + )} +
+ ); +} + +function Rows({ + rows, + onChange, + disabled, + array = false, +}: { + rows: Row[]; + onChange: (rows: Row[]) => void; + disabled: boolean; + array?: boolean; +}) { + const { t } = useTranslation(); + const replace = (id: number, patch: Partial) => + onChange( + rows.map((item) => (item.id === id ? { ...item, ...patch } : item)) + ); + return ( +
+ {rows.length > 0 && ( +
+ + {t( + array + ? "modelProfile.parameterIndex" + : "modelProfile.parameterName" + )} + + {t("modelProfile.parameterType")} + {t("modelProfile.parameterValue")} + +
+ )} + {rows.map((item, index) => ( +
+
+ {array ? ( + {index + 1} + ) : ( + replace(item.id, { key: e.target.value })} + /> + )} + replace(item.id, { value: e.target.value })} + /> + ) : item.kind === "number" ? ( + replace(item.id, { value: v })} + /> + ) : item.kind === "boolean" ? ( +
+ replace(item.id, { value: v })} + /> +
+ ) : ( + + {t(`modelProfile.valueType.${item.kind}`)} + + )} + } + onClick={() => onChange(rows.filter((r) => r.id !== item.id))} + /> +
+ {(item.kind === "object" || item.kind === "array") && ( +
+ replace(item.id, { children })} + /> +
+ )} +
+ ))} + } + disabled={disabled} + onClick={() => onChange([...rows, row("")])} + > + {t("modelProfile.addParameter")} + +
+ ); +} diff --git a/frontend/tests/component/modelAddDialogBatchDuplicate.test.tsx b/frontend/tests/component/modelAddDialogBatchDuplicate.test.tsx index 92935e3240..c00686a69c 100644 --- a/frontend/tests/component/modelAddDialogBatchDuplicate.test.tsx +++ b/frontend/tests/component/modelAddDialogBatchDuplicate.test.tsx @@ -62,7 +62,7 @@ const CONFLICT_DETAIL = "Name BAAI/bge-m3 is already in use, please choose another display name"; const DUP_MODEL = "BAAI/bge-m3"; -const OK_MODEL = "Qwen/Qwen3-8B"; +const OK_MODEL = "BAAI/bge-large-en"; function renderBatchDialog() { const onSuccess = vi.fn(); @@ -87,7 +87,7 @@ async function prepareSelection(models: string[]) { models.map((name) => ({ id: name, model_name: name, - model_type: "llm", + model_type: "embedding", })) ); const user = userEvent.setup(); diff --git a/frontend/tests/component/modelOutletAddForm.test.tsx b/frontend/tests/component/modelOutletAddForm.test.tsx new file mode 100644 index 0000000000..9d91ddcd83 --- /dev/null +++ b/frontend/tests/component/modelOutletAddForm.test.tsx @@ -0,0 +1,924 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { App } from "antd"; +import { ApiError } from "../../services/api"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Dialog, DialogContent, DialogTitle } from "../../components/ui/dialog"; +import { ModelOutletAddForm } from "../../app/[locale]/models/components/model/ModelOutletAddForm"; + +const api = vi.hoisted(() => ({ + listModelProfiles: vi.fn(), + listProviderProfiles: vi.fn(), + previewModelProfile: vi.fn(), + createModelOutlet: vi.fn(), + validateModelOutlet: vi.fn(), +})); +vi.mock("../../services/modelProfileService", () => api); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +const preview = { + catalog_revision: "catalog-v1", + selected: { model: null, provider: null }, + candidates: { models: [], providers: [] }, + parameter_values: {}, + field_specs: [], + capabilities: {}, + instruction_mapping: {}, + conflicts: [], +}; + +function setup(inDialog = false) { + const onDone = vi.fn(); + const onSuccess = vi.fn(); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + render( + + + {inDialog ? ( + + + Model onboarding + + + + ) : ( + + )} + + + ); + return { onDone, onSuccess }; +} +function fillIdentity(name = "custom-model") { + fireEvent.change(screen.getByLabelText("modelProfile.endpoint"), { + target: { value: "https://models.invalid/v1" }, + }); + fireEvent.change(screen.getByLabelText("modelProfile.requestModel"), { + target: { value: name }, + }); +} +const saveButton = () => + screen.getByRole("button", { name: "modelConfig.addDialog.submit" }); + +async function waitForSaveReady() { + // Resolve once: repeated role queries recalculate every AntD node's style + // and can starve the debounce timer in jsdom. Keep the state assertion real. + const button = saveButton(); + await waitFor(() => expect(button).toBeEnabled(), { timeout: 7000 }); +} + +beforeEach(() => { + vi.clearAllMocks(); + Object.values(api).forEach((mock) => mock.mockReset()); + api.listModelProfiles.mockResolvedValue({ + items: [], + total: 0, + page: 1, + page_size: 100, + catalog_revision: "catalog-v1", + }); + api.listProviderProfiles.mockResolvedValue({ + items: [], + total: 0, + page: 1, + page_size: 100, + catalog_revision: "catalog-v1", + }); + api.previewModelProfile.mockResolvedValue(preview); + api.createModelOutlet.mockResolvedValue({ + model_id: 42, + config_revision: 1, + profile: {}, + has_credential: true, + }); + api.validateModelOutlet.mockResolvedValue({ + ok: true, + error: null, + check_scope: "connection_only", + model_id: 42, + config_revision: 1, + }); +}); + +describe("LLM outlet onboarding", () => { + it("keeps credentials out of preview and checks the saved revision exactly once", async () => { + const callbacks = setup(); + fillIdentity(); + fireEvent.change(screen.getByLabelText("modelProfile.apiKey"), { + target: { value: "controlled-key" }, + }); + await waitForSaveReady(); + expect(JSON.stringify(api.previewModelProfile.mock.calls)).not.toContain( + "controlled-key" + ); + fireEvent.click(saveButton()); + await waitFor(() => expect(callbacks.onDone).toHaveBeenCalledOnce()); + expect(api.createModelOutlet).toHaveBeenCalledOnce(); + expect(api.createModelOutlet.mock.calls[0][0]).toMatchObject({ + request_model_id: "custom-model", + catalog_revision: "catalog-v1", + api_key: "controlled-key", + authentication_scheme: "bearer_token_v1", + }); + expect(api.validateModelOutlet).toHaveBeenCalledExactlyOnceWith({ + outlet_id: 42, + expected_revision: 1, + }); + expect(callbacks.onSuccess).toHaveBeenCalledWith({ + name: "custom-model", + type: "llm", + connectionChecked: true, + }); + }, 15000); + + it("UI-AUTH-11 saves an empty optional key without Bearer authentication", async () => { + setup(); + fillIdentity(); + expect(screen.queryByText("modelProfile.noAuth")).not.toBeInTheDocument(); + await waitForSaveReady(); + fireEvent.click(saveButton()); + await waitFor(() => expect(api.createModelOutlet).toHaveBeenCalledOnce()); + expect(api.createModelOutlet.mock.calls[0][0]).toMatchObject({ + api_key: null, + authentication_scheme: "no_auth_v1", + }); + }, 15000); + + it("UI-TLS-12 verifies TLS by default and skips it only when enabled", async () => { + setup(); + fillIdentity(); + fireEvent.click(screen.getByText("modelProfile.advanced")); + const toggle = screen.getByRole("switch", { + name: "modelProfile.skipTlsVerify", + }); + expect(toggle).not.toBeChecked(); + await waitForSaveReady(); + expect( + api.previewModelProfile.mock.lastCall?.[0].provider_info.ssl_verify + ).toBe(true); + fireEvent.click(toggle); + await waitForSaveReady(); + fireEvent.click(saveButton()); + await waitFor(() => expect(api.createModelOutlet).toHaveBeenCalledOnce()); + expect( + api.createModelOutlet.mock.calls[0][0].provider_info.ssl_verify + ).toBe(false); + }, 15000); + + it("reports a failed check without creating a second saved outlet", async () => { + api.validateModelOutlet.mockResolvedValue({ + ok: false, + error: { category: "MODEL_AUTHENTICATION_ERROR" }, + }); + const callbacks = setup(); + fillIdentity(); + await waitForSaveReady(); + fireEvent.click(saveButton()); + await waitFor(() => expect(callbacks.onDone).toHaveBeenCalledOnce()); + expect( + await screen.findByText("modelProfile.savedUnavailable") + ).toBeVisible(); + expect(api.createModelOutlet).toHaveBeenCalledOnce(); + }, 15000); + + it("keeps incomplete custom parameter rows editable and prohibits saving them", async () => { + setup(); + fillIdentity(); + await waitForSaveReady(); + fireEvent.click(screen.getByText("modelProfile.advanced")); + fireEvent.click( + screen.getByRole("button", { name: "modelProfile.addParameter" }) + ); + expect(saveButton()).toBeDisabled(); + expect(screen.getByText("modelProfile.extrasInvalid")).toBeVisible(); + expect(api.createModelOutlet).not.toHaveBeenCalled(); + fireEvent.change( + screen.getByRole("textbox", { name: "modelProfile.parameterName 1" }), + { target: { value: "custom_flag" } } + ); + fireEvent.change( + screen.getByRole("textbox", { name: "modelProfile.parameterValue 1" }), + { target: { value: "configured" } } + ); + await waitFor( + () => + expect( + api.previewModelProfile.mock.calls.at(-1)?.[0].extra_api_parameters + ).toEqual([{ path: "/custom_flag", value: "configured" }]), + { timeout: 7000 } + ); + await waitForSaveReady(); + }, 15000); + + it("does not save when composition or the version check fails", async () => { + api.previewModelProfile.mockRejectedValue( + new Error("MODEL_OUTLET_VERSION_CONFLICT") + ); + const callbacks = setup(); + fillIdentity(); + await screen.findByText("modelProfile.previewFailed"); + expect(saveButton()).toBeDisabled(); + expect(api.createModelOutlet).not.toHaveBeenCalled(); + expect(callbacks.onDone).not.toHaveBeenCalled(); + }, 15000); + + it("ignores an obsolete preview while the latest model is still being checked", async () => { + let resolveOld: (value: typeof preview) => void = () => {}; + let resolveNew: (value: typeof preview) => void = () => {}; + api.previewModelProfile.mockImplementation( + (request) => + new Promise((resolve) => { + if (request.request_model_id === "old-model") resolveOld = resolve; + else resolveNew = resolve; + }) + ); + const callbacks = setup(); + fillIdentity("old-model"); + await waitFor( + () => expect(api.previewModelProfile).toHaveBeenCalledOnce(), + { timeout: 7000 } + ); + fillIdentity("new-model"); + await waitFor( + () => expect(api.previewModelProfile).toHaveBeenCalledTimes(2), + { timeout: 7000 } + ); + expect(api.previewModelProfile.mock.calls[0][1].aborted).toBe(true); + await act(async () => resolveOld(preview)); + expect(saveButton()).toBeDisabled(); + await act(async () => resolveNew(preview)); + await waitForSaveReady(); + fireEvent.click(saveButton()); + await waitFor(() => expect(callbacks.onDone).toHaveBeenCalledOnce()); + expect(api.createModelOutlet.mock.calls[0][0].request_model_id).toBe( + "new-model" + ); + }, 15000); + + it("preserves structured declared parameters including false and zero", async () => { + api.previewModelProfile.mockResolvedValue({ + ...preview, + parameter_values: { custom_options: { enabled: false } }, + field_specs: [ + { + key: "custom_options", + schema: { type: "object" }, + visible: true, + editable: true, + required: false, + allowed_scopes: ["outlet"], + ui_group: "advanced", + }, + ], + }); + const callbacks = setup(); + fillIdentity(); + await waitForSaveReady(); + fireEvent.click(screen.getByText("modelProfile.advanced")); + fireEvent.change(screen.getByLabelText("custom_options"), { + target: { value: '{"enabled":false,"retries":0}' }, + }); + await waitForSaveReady(); + fireEvent.click(saveButton()); + await waitFor(() => expect(callbacks.onDone).toHaveBeenCalledOnce()); + expect(api.createModelOutlet.mock.calls[0][0].parameter_values).toEqual({ + custom_options: { enabled: false, retries: 0 }, + temperature: 0.7, + top_p: 1, + }); + }, 15000); +}); + +it("keeps provider choices inside the modal and applies the selected endpoint", async () => { + api.listProviderProfiles.mockResolvedValue({ + items: [ + { + key: "alibaba-cn", + name: "Alibaba (China)", + revision: "r1", + suggested_base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1", + }, + ], + total: 1, + page: 1, + page_size: 100, + catalog_revision: "catalog-v1", + }); + setup(true); + await waitFor(() => expect(api.listProviderProfiles).toHaveBeenCalledOnce()); + fireEvent.mouseDown(screen.getByLabelText("modelProfile.provider")); + const choice = await screen.findByText("Alibaba (China)"); + expect(screen.getByRole("dialog")).toContainElement(choice); + fireEvent.click(choice); + await waitFor(() => + expect(screen.getByLabelText("modelProfile.endpoint")).toHaveValue( + "https://dashscope.aliyuncs.com/compatible-mode/v1" + ) + ); +}, 15000); + +it("opens advanced settings for Other without rewriting the requested model ID", async () => { + setup(); + fillIdentity("deployment-custom-id"); + await waitForSaveReady(); + fireEvent.mouseDown(screen.getByLabelText("modelProfile.modelProfile")); + fireEvent.click( + await screen.findByText("modelProfile.otherProfile", { + selector: ".ant-select-item-option-content", + }) + ); + expect( + await screen.findByLabelText("modelProfile.extraParameters") + ).toBeVisible(); + expect(screen.getByLabelText("modelProfile.requestModel")).toHaveValue( + "deployment-custom-id" + ); + await waitForSaveReady(); + fireEvent.click(saveButton()); + await waitFor(() => expect(api.createModelOutlet).toHaveBeenCalledOnce()); + expect(api.createModelOutlet.mock.calls[0][0]).toMatchObject({ + selection_mode: "manual", + model_profile: null, + request_model_id: "deployment-custom-id", + }); +}, 15000); + +it("keeps a manually selected profile when the requested ID changes", async () => { + api.listModelProfiles.mockResolvedValue({ + items: [{ key: "qwen/reference", name: "Qwen Reference", revision: "m1" }], + }); + setup(); + fillIdentity("qwen-reference"); + await waitForSaveReady(); + fireEvent.mouseDown(screen.getByLabelText("modelProfile.modelProfile")); + fireEvent.click( + await screen.findByText("Qwen Reference", { + selector: ".ant-select-item-option-content", + }) + ); + fillIdentity("deployment-2"); + await waitForSaveReady(); + fireEvent.click(saveButton()); + await waitFor(() => expect(api.createModelOutlet).toHaveBeenCalledOnce()); + expect(api.createModelOutlet.mock.calls[0][0]).toMatchObject({ + model_profile: { key: "qwen/reference", revision: "m1" }, + request_model_id: "deployment-2", + }); +}, 15000); + +it("keeps an unchanged batch draft ready when its parent recreates equivalent connection values", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const onApply = vi.fn(); + const form = () => ( + + + + + + ); + const view = render(form()); + await waitFor( + () => + expect(screen.getByRole("button", { name: "common.save" })).toBeEnabled(), + { timeout: 7000 } + ); + view.rerender(form()); + expect(screen.getByRole("button", { name: "common.save" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "common.save" })); + await waitFor(() => expect(onApply).toHaveBeenCalledOnce()); + expect(api.createModelOutlet).not.toHaveBeenCalled(); +}, 15000); + +it("suggests qwen3.8, exposes thinking controls and keeps capacity settings to two inputs", async () => { + api.listModelProfiles.mockResolvedValue({ + items: [ + { + key: "alibaba/qwen3.8-27b", + name: "Qwen3.8 27B", + revision: "r1", + catalog_kind: "model", + qualification: "reference_only", + }, + ], + total: 1, + }); + api.previewModelProfile.mockImplementation(async (draft) => ({ + ...preview, + selected: { model: "alibaba/qwen3.8-27b", provider: null }, + parameter_values: draft.parameter_values, + field_specs: + draft.thinking_control !== "profile_default" + ? [ + { + key: "enable_thinking", + schema: { type: "boolean" }, + visible: true, + editable: true, + required: false, + allowed_scopes: ["outlet", "conversation"], + ui_group: "reasoning", + }, + ] + : [], + })); + setup(); + fillIdentity("qwen3.8"); + await waitFor(() => + expect( + api.previewModelProfile.mock.calls.at(-1)?.[0].model_profile + ).toEqual({ key: "alibaba/qwen3.8-27b", revision: "r1" }) + ); + expect(screen.getByText("modelProfile.approximateMatch")).toBeVisible(); + fireEvent.click(screen.getByText("modelProfile.advanced")); + expect( + screen.queryByText("modelProfile.input_limit") + ).not.toBeInTheDocument(); + expect( + screen.queryByText("modelProfile.outputReserve") + ).not.toBeInTheDocument(); + expect(screen.queryByText("modelProfile.tokenizer")).not.toBeInTheDocument(); + expect( + screen.getByRole("spinbutton", { name: "modelProfile.maximumOutput" }) + ).toBeVisible(); + expect( + screen.queryByRole("combobox", { name: "modelProfile.thinkingMode" }) + ).not.toBeInTheDocument(); + fireEvent.click( + screen.getByRole("switch", { name: "modelProfile.thinkingToggle" }) + ); + await screen.findByRole("combobox", { name: "modelProfile.thinkingMode" }); + await waitFor( + () => + expect(api.previewModelProfile.mock.calls.at(-1)?.[0]).toMatchObject({ + thinking_mode: "effort", + parameter_values: { enable_thinking: true, reasoning_effort: "low" }, + }), + { timeout: 7000 } + ); + fireEvent.mouseDown( + screen.getByRole("combobox", { name: "modelProfile.thinkingDegree" }) + ); + fireEvent.click(await screen.findByText("modelProfile.thinkingLevel.medium")); + await waitFor( + () => + expect(api.previewModelProfile.mock.calls.at(-1)?.[0]).toMatchObject({ + thinking_mode: "effort", + parameter_values: { enable_thinking: true, reasoning_effort: "medium" }, + }), + { timeout: 7000 } + ); + fireEvent.click( + screen.getByRole("switch", { name: "modelProfile.thinkingToggle" }) + ); + expect( + screen.queryByRole("combobox", { name: "modelProfile.thinkingMode" }) + ).not.toBeInTheDocument(); + await waitForSaveReady(); + fireEvent.click(saveButton()); + await waitFor(() => expect(api.createModelOutlet).toHaveBeenCalledOnce()); + expect(api.createModelOutlet.mock.calls[0][0]).toMatchObject({ + thinking_control: "enable_thinking", + thinking_mode: "effort", + parameter_values: { enable_thinking: false }, + }); +}, 30000); + +it("offers Top P as an explicit numeric value without creating a default", async () => { + api.previewModelProfile.mockResolvedValue({ + ...preview, + field_specs: [ + { + key: "top_p", + visible: true, + editable: true, + allowed_scopes: ["outlet", "conversation"], + ui_group: "advanced", + schema: { type: ["number", "null"], minimum: 0, maximum: 1 }, + }, + ], + }); + const callbacks = setup(); + fillIdentity(); + fireEvent.click(screen.getByText("modelProfile.advanced")); + const field = await screen.findByRole( + "spinbutton", + { name: "top_p" }, + { timeout: 7000 } + ); + expect(field).toHaveValue("1"); + expect(field).toHaveAttribute("aria-valuemin", "0"); + expect(field).toHaveAttribute("aria-valuemax", "1"); + fireEvent.change(field, { target: { value: "0.85" } }); + await waitForSaveReady(); + fireEvent.click(saveButton()); + await waitFor(() => expect(callbacks.onDone).toHaveBeenCalledOnce()); + expect(api.createModelOutlet.mock.calls[0][0].parameter_values.top_p).toBe( + 0.85 + ); +}, 15000); + +it("refreshes stale profile revisions without discarding a batch draft's configuration", async () => { + const atom = { + key: "deepseek/deepseek-v4-pro", + name: "DeepSeek V4 Pro", + revision: "old", + }; + api.listModelProfiles + .mockResolvedValueOnce({ items: [atom] }) + .mockResolvedValue({ items: [{ ...atom, revision: "new" }] }); + api.previewModelProfile.mockImplementation(async (draft) => { + if (draft.model_profile?.revision === "old") + throw new ApiError(409, "MODEL_OUTLET_VERSION_CONFLICT"); + return { ...preview, parameter_values: draft.parameter_values }; + }); + const onApply = vi.fn(); + render( + + + + + + ); + await screen.findByText("modelProfile.catalogChanged"); + expect(screen.getByRole("alert")).not.toHaveClass("ant-alert"); + expect( + screen.queryByText("modelProfile.catalogChangedDetail") + ).not.toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { name: "modelProfile.refreshCatalog" }) + ); + const apply = screen.getByRole("button", { name: "common.save" }); + await waitFor(() => expect(apply).toBeEnabled(), { timeout: 7000 }); + fireEvent.click(apply); + await waitFor(() => expect(onApply).toHaveBeenCalledOnce()); + expect(onApply.mock.calls[0][0]).toMatchObject({ + model_profile: { key: atom.key, revision: "new" }, + request_model_id: "route/deepseek-v4-pro", + display_name: "My model", + parameter_values: { temperature: 0.4, enable_thinking: false }, + thinking_control: "enable_thinking", + context_limits: { combined_window: 200000 }, + }); +}, 15000); + +it("edits output capability without adding a request output parameter", async () => { + api.previewModelProfile.mockImplementation(async (draft) => ({ + ...preview, + capabilities: { + context: { combined_window: 1000000, output_limit: 384000 }, + }, + parameter_values: draft.parameter_values, + })); + setup(); + fillIdentity(); + await waitForSaveReady(); + fireEvent.click(screen.getByText("modelProfile.advanced")); + const output = screen.getByRole("spinbutton", { + name: "modelProfile.maximumOutput", + }); + expect(output).toHaveValue("384000"); + expect( + api.previewModelProfile.mock.calls.at(-1)?.[0].parameter_values + ).not.toHaveProperty("max_output_tokens"); + fireEvent.change(output, { target: { value: "8192" } }); + await waitFor( + () => + expect( + api.previewModelProfile.mock.calls.at(-1)?.[0].context_limits + ).toHaveProperty("output_limit", 8192), + { timeout: 7000 } + ); + expect( + api.previewModelProfile.mock.calls.at(-1)?.[0].parameter_values + ).not.toHaveProperty("max_output_tokens"); +}, 15000); + +it("retries a failed preview without clearing the requested model", async () => { + api.previewModelProfile + .mockRejectedValueOnce(new ApiError("request_failed", "Connection error")) + .mockResolvedValue(preview); + setup(); + fillIdentity("deepseek-v4-pro"); + await screen.findByText("modelProfile.previewFailed"); + expect(saveButton()).toBeDisabled(); + fireEvent.click( + screen.getByRole("button", { name: "modelProfile.retryPreview" }) + ); + await waitForSaveReady(); + expect(screen.getByLabelText("modelProfile.requestModel")).toHaveValue( + "deepseek-v4-pro" + ); +}, 15000); + +it("shows experience defaults in the draft before the user makes an explicit change", async () => { + api.previewModelProfile.mockImplementation(async (draft) => ({ + ...preview, + parameter_values: { temperature: 0.6, ...draft.parameter_values }, + field_specs: [ + { + key: "temperature", + schema: { type: "number", minimum: 0, maximum: 2 }, + visible: true, + editable: true, + required: false, + allowed_scopes: ["outlet"], + ui_group: "sampling", + default_value: 0.6, + }, + ], + })); + setup(); + fillIdentity(); + await waitForSaveReady(); + fireEvent.click(screen.getByText("modelProfile.advanced")); + const temperature = screen.getByRole("spinbutton", { name: "temperature" }); + expect(temperature).toHaveValue("0.6"); + expect( + api.previewModelProfile.mock.calls.at(-1)?.[0].parameter_values + ).not.toHaveProperty("temperature"); + fireEvent.change(temperature, { target: { value: "0.9" } }); + await waitFor( + () => + expect( + api.previewModelProfile.mock.calls.at(-1)?.[0].parameter_values + .temperature + ).toBe(0.9), + { timeout: 7000 } + ); + await waitForSaveReady(); + fireEvent.click(saveButton()); + await waitFor(() => expect(api.createModelOutlet).toHaveBeenCalledOnce()); + expect( + api.createModelOutlet.mock.calls[0][0].parameter_values.temperature + ).toBe(0.9); + expect(api.createModelOutlet.mock.invocationCallOrder[0]).toBeLessThan( + api.validateModelOutlet.mock.invocationCallOrder[0] + ); +}, 15000); + +it("shows model-only defaults immediately and composes provider values after the endpoint is supplied", async () => { + const configuration = { + capabilities: { + context: { combined_window: 200000, output_limit: 16000 }, + reasoning: { status: "supported" }, + }, + parameter_values: { temperature: 0.2, enable_thinking: true }, + field_specs: [ + { + key: "temperature", + schema: { type: "number" }, + visible: true, + editable: true, + required: false, + allowed_scopes: ["outlet"], + ui_group: "sampling", + }, + { + key: "enable_thinking", + schema: { type: "boolean" }, + visible: true, + editable: true, + required: false, + allowed_scopes: ["outlet"], + ui_group: "reasoning", + }, + ], + }; + api.listModelProfiles.mockResolvedValue({ + items: [ + { + key: "alibaba/qwen3.7-max", + name: "Qwen3.7 Max", + revision: "r1", + configuration, + }, + ], + }); + api.previewModelProfile.mockResolvedValue({ + ...preview, + ...configuration, + parameter_values: { temperature: 0.6, enable_thinking: true }, + }); + setup(); + fireEvent.change(screen.getByLabelText("modelProfile.requestModel"), { + target: { value: "qwen3.7" }, + }); + fireEvent.click(screen.getByText("modelProfile.advanced")); + await waitFor( + () => + expect( + screen.getByRole("spinbutton", { name: "modelProfile.combined_window" }) + ).toHaveValue("200000"), + { timeout: 7000 } + ); + expect( + screen.getByRole("spinbutton", { name: "modelProfile.maximumOutput" }) + ).toHaveValue("16000"); + expect(screen.getByRole("spinbutton", { name: "temperature" })).toHaveValue( + "0.2" + ); + expect( + screen.getByRole("switch", { name: "modelProfile.thinkingToggle" }) + ).toHaveAttribute("aria-checked", "true"); + expect(api.previewModelProfile).not.toHaveBeenCalled(); + expect(saveButton()).toBeDisabled(); + fireEvent.change(screen.getByLabelText("modelProfile.endpoint"), { + target: { value: "https://models.invalid/v1" }, + }); + await waitForSaveReady(); + expect(screen.getByRole("spinbutton", { name: "temperature" })).toHaveValue( + "0.6" + ); +}, 15000); + +it("does not retain another model's composed capabilities after clearing the endpoint", async () => { + const configuration = (output: number) => ({ + capabilities: { context: { output_limit: output } }, + field_specs: [], + parameter_values: {}, + }); + api.listModelProfiles.mockResolvedValue({ + items: [ + { + key: "fixture/model-a", + name: "Model A", + revision: "a", + configuration: configuration(16000), + }, + { + key: "fixture/model-b", + name: "Model B", + revision: "b", + configuration: configuration(8000), + }, + ], + }); + api.previewModelProfile.mockResolvedValue({ + ...preview, + ...configuration(32000), + }); + setup(); + fillIdentity("model-a"); + await waitForSaveReady(); + fireEvent.click(screen.getByText("modelProfile.advanced")); + expect( + screen.getByRole("spinbutton", { name: "modelProfile.maximumOutput" }) + ).toHaveValue("32000"); + fireEvent.change(screen.getByLabelText("modelProfile.endpoint"), { + target: { value: "" }, + }); + fireEvent.change(screen.getByLabelText("modelProfile.requestModel"), { + target: { value: "model-b" }, + }); + expect( + screen.getByRole("spinbutton", { name: "modelProfile.maximumOutput" }) + ).toHaveValue("8000"); + expect(saveButton()).toBeDisabled(); +}, 15000); + +it("UI-LAYOUT-03 keeps the parameter panel visible and restores only model overrides", async () => { + const config = { + ...preview, + capabilities: { context: { combined_window: 200000, output_limit: 16000 } }, + parameter_values: { temperature: 0.4 }, + field_specs: [ + { + key: "temperature", + schema: { type: "number" }, + visible: true, + editable: true, + allowed_scopes: ["outlet"], + ui_group: "sampling", + }, + ], + }; + api.previewModelProfile.mockImplementation(async (draft) => ({ + ...config, + parameter_values: { ...config.parameter_values, ...draft.parameter_values }, + })); + setup(); + expect(screen.getByText("modelProfile.layout.emptyTitle")).toBeVisible(); + expect( + screen.queryByRole("spinbutton", { name: "temperature" }) + ).not.toBeInTheDocument(); + fillIdentity(); + await waitForSaveReady(); + expect(screen.getByRole("spinbutton", { name: "temperature" })).toBeVisible(); + // Model parameters are available without opening connection settings. + expect( + screen.queryByLabelText("modelProfile.extraParameters") + ).not.toBeInTheDocument(); + fireEvent.change(screen.getByRole("spinbutton", { name: "temperature" }), { + target: { value: "0.9" }, + }); + fireEvent.change( + screen.getByRole("spinbutton", { name: "modelProfile.combined_window" }), + { target: { value: "100000" } } + ); + await waitFor(() => + expect( + api.previewModelProfile.mock.calls.at(-1)?.[0].parameter_values + .temperature + ).toBe(0.9) + ); + fireEvent.click( + screen.getByRole("button", { name: "modelProfile.layout.reset" }) + ); + await waitFor(() => + expect(api.previewModelProfile.mock.calls.at(-1)?.[0]).toMatchObject({ + parameter_values: {}, + context_limits: {}, + provider_info: { base_url: "https://models.invalid/v1" }, + }) + ); + await waitFor( + () => + expect( + screen.getByRole("spinbutton", { name: "temperature" }) + ).toHaveValue("0.4"), + { timeout: 7000 } + ); + expect( + screen.getByRole("spinbutton", { name: "modelProfile.combined_window" }) + ).toHaveValue("200000"); +}, 15000); + +it("UI-LAYOUT-06 freezes connection identity while checking the saved outlet", async () => { + let finishCheck!: (value: { ok: boolean }) => void; + api.validateModelOutlet.mockImplementation( + () => + new Promise((resolve) => { + finishCheck = resolve; + }) + ); + const callbacks = setup(); + fillIdentity(); + await waitForSaveReady(); + fireEvent.click(saveButton()); + await waitFor(() => expect(api.validateModelOutlet).toHaveBeenCalledOnce()); + for (const label of [ + "modelProfile.provider", + "modelProfile.endpoint", + "modelProfile.requestModel", + "modelProfile.apiKey", + ]) + expect(screen.getByLabelText(label)).toBeDisabled(); + expect( + screen.getByRole("button", { name: "modelProfile.layout.reset" }) + ).toBeDisabled(); + await act(async () => finishCheck({ ok: true })); + await waitFor(() => expect(callbacks.onDone).toHaveBeenCalledOnce()); +}, 20000); diff --git a/frontend/tests/component/modelOutletBatch.test.tsx b/frontend/tests/component/modelOutletBatch.test.tsx new file mode 100644 index 0000000000..a2b876a826 --- /dev/null +++ b/frontend/tests/component/modelOutletBatch.test.tsx @@ -0,0 +1,181 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { App } from "antd"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { ModelAddDialog } from "../../app/[locale]/models/components/model/ModelAddDialog"; + +const api = vi.hoisted(() => ({ + listModelProfiles: vi.fn(), + listProviderProfiles: vi.fn(), + previewModelProfile: vi.fn(), + createModelOutlet: vi.fn(), + validateModelOutlet: vi.fn(), +})); +const legacy = vi.hoisted(() => ({ + getFullCatalog: vi.fn(), + addProviderModel: vi.fn(), + suggestCapacity: vi.fn(), + backfillDefaults: vi.fn(), +})); +vi.mock("../../services/modelProfileService", () => api); +vi.mock("../../services/modelService", () => ({ + modelService: legacy, + ModelError: class extends Error {}, +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +async function waitForConfigurationReady() { + const button = screen.getByRole("button", { name: "common.save" }); + await waitFor(() => expect(button).toBeEnabled(), { timeout: 7000 }); +} + +beforeEach(() => { + Object.values(api).forEach((mock) => mock.mockReset()); + Object.values(legacy).forEach((mock) => mock.mockReset()); + api.listModelProfiles.mockResolvedValue({ items: [] }); + api.listProviderProfiles.mockResolvedValue({ items: [] }); + api.previewModelProfile.mockResolvedValue({ + catalog_revision: "r1", + selected: { model: null, provider: null }, + field_specs: [], + parameter_values: {}, + capabilities: { + context: { combined_window: null, input_limit: null, output_limit: null }, + }, + }); + api.validateModelOutlet.mockResolvedValue({ ok: true }); + api.createModelOutlet.mockResolvedValue({ model_id: 10 }); + legacy.getFullCatalog.mockResolvedValue({ catalog: { providers: [] } }); + legacy.addProviderModel.mockResolvedValue([ + { id: "model-a", model_type: "llm" }, + { id: "model-b", model_type: "llm" }, + ]); + legacy.suggestCapacity.mockResolvedValue(null); + legacy.backfillDefaults.mockResolvedValue({}); +}); + +it.each([false, true])( + "keeps independent batch drafts and handles partial failure=%s", + async (partialFailure) => { + if (partialFailure) + api.createModelOutlet.mockImplementation(async (request) => { + if (request.request_model_id === "model-b") + throw new Error("controlled-save-failure"); + return { model_id: 10, config_revision: 1 }; + }); + const onClose = vi.fn(); + render( + + + + + + ); + fireEvent.change(screen.getByPlaceholderText("sk-..."), { + target: { value: "controlled-key" }, + }); + fireEvent.change( + screen.getByPlaceholderText("https://api.example.com/v1"), + { + target: { value: "https://models.invalid/v1" }, + } + ); + fireEvent.click( + screen.getByRole("button", { name: "modelConfig.addDialog.fetchModels" }) + ); + await screen.findByText("model-a"); + fireEvent.click( + screen.getByRole("button", { + name: "modelProfile.configureModel model-a", + }) + ); + await waitForConfigurationReady(); + expect(screen.getByLabelText("modelProfile.endpoint")).toBeDisabled(); + fireEvent.change(screen.getByLabelText("modelProfile.displayName"), { + target: { value: "Configured A" }, + }); + await waitForConfigurationReady(); + fireEvent.click(screen.getByRole("button", { name: "common.save" })); + await waitFor(() => + expect( + screen.queryByLabelText("modelProfile.requestModel") + ).not.toBeInTheDocument() + ); + expect(api.createModelOutlet).not.toHaveBeenCalled(); + fireEvent.click( + screen.getByRole("button", { + name: "modelProfile.configureModel model-b", + }) + ); + expect(screen.getByLabelText("modelProfile.displayName")).toHaveValue(""); + fireEvent.change(screen.getByLabelText("modelProfile.displayName"), { + target: { value: "Discarded B" }, + }); + fireEvent.click( + screen.getAllByRole("button", { name: "common.cancel" }).at(-1)! + ); + fireEvent.click( + screen.getByRole("button", { + name: "modelProfile.configureModel model-a", + }) + ); + expect(screen.getByLabelText("modelProfile.displayName")).toHaveValue( + "Configured A" + ); + fireEvent.click( + screen.getAllByRole("button", { name: "common.cancel" }).at(-1)! + ); + fireEvent.click(screen.getByText("modelConfig.addDialog.toggleAll")); + fireEvent.click( + screen.getByRole("button", { name: "modelConfig.addDialog.batchCheck" }) + ); + await waitFor(() => + expect(api.validateModelOutlet).toHaveBeenCalledTimes(2) + ); + await waitFor(() => + expect( + screen.getByRole("button", { name: "modelConfig.addDialog.batchCheck" }) + ).toBeEnabled() + ); + fireEvent.click( + screen.getByRole("button", { name: "modelConfig.addDialog.addSelected" }) + ); + await waitFor(() => expect(api.createModelOutlet).toHaveBeenCalledTimes(2)); + expect(api.createModelOutlet.mock.calls[0][0]).toMatchObject({ + request_model_id: "model-a", + display_name: "Configured A", + api_key: "controlled-key", + catalog_revision: "r1", + }); + expect(api.createModelOutlet.mock.calls[1][0].display_name).not.toBe( + "Discarded B" + ); + const checked = api.validateModelOutlet.mock.calls.find( + ([request]) => request.draft.request_model_id === "model-a" + )![0].draft; + expect(api.createModelOutlet.mock.calls[0][0]).toMatchObject(checked); + if (partialFailure) { + await waitFor(() => + expect(screen.queryByText("model-a")).not.toBeInTheDocument() + ); + expect(screen.getByText("model-b")).toBeVisible(); + expect(onClose).not.toHaveBeenCalled(); + } else { + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()); + } + }, + 60000 +); diff --git a/frontend/tests/component/modelOutletEditing.test.tsx b/frontend/tests/component/modelOutletEditing.test.tsx new file mode 100644 index 0000000000..210189c0d6 --- /dev/null +++ b/frontend/tests/component/modelOutletEditing.test.tsx @@ -0,0 +1,448 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { App } from "antd"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { ModelOutletEditDialog } from "../../app/[locale]/models/components/model/ModelOutletEditDialog"; +import { ConversationModelParameters } from "../../components/model/ConversationModelParameters"; + +const api = vi.hoisted(() => ({ + getModelOutlet: vi.fn(), + updateModelOutlet: vi.fn(), + getConversationModelPreferences: vi.fn(), + patchConversationModelPreferences: vi.fn(), +})); +vi.mock("../../services/modelProfileService", () => api); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +// The shared field's actual structured-value behavior is covered by modelOutletAddForm. +vi.mock("../../components/model/ModelParameterField", () => ({ + ModelParameterField: ({ + rule, + onChange, + onReset, + }: { + rule: { key: string }; + onChange: (key: string, value: unknown) => void; + onReset: (key: string) => void; + }) => ( + <> + {rule.key} + + + + ), +})); +const rule = { + key: "temperature", + visible: true, + editable: true, + allowed_scopes: ["outlet", "conversation"], + schema: { type: "number" }, +}; + +function setup(child: React.ReactNode) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + {child} + + ); +} +async function openSessionSettings() { + const entry = screen.getByRole("button", { + name: "workbench.controls.advanced", + }); + await waitFor(() => expect(entry).toBeEnabled()); + fireEvent.click(entry); +} +beforeEach(() => { + Object.values(api).forEach((method) => method.mockReset()); + api.getModelOutlet.mockResolvedValue({ + model_id: 42, + config_revision: 7, + field_specs: [rule], + has_credential: true, + profile: { + identity: { display_name: "display", request_model_id: "exact-model" }, + connection: { + base_url: "https://models.invalid/v1", + ssl_verify: true, + timeout_seconds: 60, + concurrency_limit: null, + credential_ref: { kind: "model_record", model_id: 42 }, + }, + capabilities: { + context: { + combined_window: 200000, + input_limit: null, + output_limit: 16000, + }, + }, + context_configuration: { + output_reserve_tokens: 4096, + tokenizer_family: null, + }, + parameter_values: { temperature: 0.7 }, + extra_api_parameters: [], + parameter_rules: [rule], + }, + }); + api.updateModelOutlet.mockResolvedValue({ config_revision: 8 }); + api.getConversationModelPreferences.mockResolvedValue({ + version: 4, + config_revision: 7, + parameter_values: {}, + defaults: { temperature: 0.7 }, + field_specs: [rule], + issues: [], + }); + api.patchConversationModelPreferences.mockResolvedValue(undefined); +}); + +it("UI-LAYOUT-08 edits visible saved parameters and the displayed revision without echoing credential references or clearing an omitted key", async () => { + const success = vi.fn(); + setup( + + ); + await screen.findByRole("heading", { name: "modelProfile.modelParameters" }); + await waitFor(() => + expect( + screen.getByRole("region", { name: "modelProfile.samplingGroup" }) + ).toBeVisible() + ); + expect( + screen.queryByRole("button", { name: "modelProfile.layout.reset" }) + ).not.toBeInTheDocument(); + expect( + screen.getByRole("spinbutton", { name: "modelProfile.maximumOutput" }) + ).toHaveValue("16000"); + fireEvent.click(await screen.findByText("temperature=zero")); + fireEvent.click(screen.getByRole("button", { name: "common.save" })); + await waitFor(() => expect(success).toHaveBeenCalledOnce()); + const request = api.updateModelOutlet.mock.calls[0][1]; + expect(request.expected_revision).toBe(7); + expect(request.parameter_values.temperature).toBe(0); + expect(request.parameter_values).not.toHaveProperty("max_output_tokens"); + expect(request).not.toHaveProperty("api_key"); + expect(screen.queryByText("modelProfile.clearKey")).not.toBeInTheDocument(); + expect(request.connection).not.toHaveProperty("credential_ref"); +}, 15000); + +it("keeps a conflicted edit open without retrying another revision", async () => { + api.updateModelOutlet.mockRejectedValue(new Error("stale")); + const success = vi.fn(); + setup( + + ); + await screen.findByRole("heading", { name: "modelProfile.modelParameters" }); + fireEvent.click(screen.getByRole("button", { name: "common.save" })); + await waitFor(() => + expect( + screen.getAllByText("modelProfile.editFailed").length + ).toBeGreaterThan(0) + ); + expect(success).not.toHaveBeenCalled(); + expect(api.updateModelOutlet).toHaveBeenCalledOnce(); + expect(api.updateModelOutlet.mock.calls[0][1].expected_revision).toBe(7); +}, 15000); + +it("saves only explicitly adjusted values for the current conversation and outlet", async () => { + setup(); + await openSessionSettings(); + const temperature = await screen.findByRole("spinbutton", { + name: "workbench.controls.temperature", + }); + fireEvent.change(temperature, { target: { value: "0" } }); + fireEvent.blur(temperature); + await waitFor(() => + expect( + api.patchConversationModelPreferences + ).toHaveBeenCalledExactlyOnceWith(11, 42, { + expected_version: 4, + parameter_values: { temperature: 0 }, + delete_parameters: [], + }) + ); + expect(api.updateModelOutlet).not.toHaveBeenCalled(); +}, 15000); + +it("explicitly removes a preference without copying the outlet default", async () => { + api.getConversationModelPreferences.mockResolvedValue({ + version: 5, + config_revision: 7, + parameter_values: { temperature: 0 }, + defaults: { temperature: 0.7 }, + field_specs: [rule], + issues: [], + }); + setup(); + await openSessionSettings(); + fireEvent.click( + await screen.findByRole("button", { name: "workbench.controls.restore" }) + ); + await waitFor(() => + expect( + api.patchConversationModelPreferences + ).toHaveBeenCalledExactlyOnceWith(11, 42, { + expected_version: 5, + parameter_values: {}, + delete_parameters: ["temperature"], + }) + ); + expect(api.updateModelOutlet).not.toHaveBeenCalled(); +}, 15000); + +it("keeps a failed preference save open without retrying or changing the model", async () => { + api.patchConversationModelPreferences.mockRejectedValue(new Error("stale")); + setup(); + await openSessionSettings(); + const temperature = await screen.findByRole("spinbutton", { + name: "workbench.controls.temperature", + }); + fireEvent.change(temperature, { target: { value: "0" } }); + fireEvent.blur(temperature); + await waitFor(() => + expect( + screen.getAllByText("modelProfile.preferencesFailed").length + ).toBeGreaterThan(0) + ); + expect(api.patchConversationModelPreferences).toHaveBeenCalledOnce(); + await waitFor(() => + expect( + screen.getByRole("spinbutton", { name: "workbench.controls.temperature" }) + ).toHaveValue(0) + ); + expect(api.updateModelOutlet).not.toHaveBeenCalled(); +}, 15000); + +it("requires a persisted conversation or an unsent thread scope before editing preferences", () => { + setup(); + expect( + screen.getByRole("button", { name: "workbench.controls.advanced" }) + ).toBeDisabled(); + expect(api.getConversationModelPreferences).not.toHaveBeenCalled(); +}); + +it("keeps incomplete custom request rows local and disables outlet saving", async () => { + setup( + + ); + await screen.findByRole("heading", { name: "modelProfile.modelParameters" }); + fireEvent.click(screen.getByText("modelProfile.advanced")); + fireEvent.click( + screen.getByRole("button", { name: "modelProfile.addParameter" }) + ); + expect(screen.getByRole("button", { name: "common.save" })).toBeDisabled(); + await waitFor(() => + expect(screen.getByText("modelProfile.parameterRowsInvalid")).toBeVisible() + ); + expect(api.updateModelOutlet).not.toHaveBeenCalled(); +}, 15000); + +it.each(["unknown", "unsupported"])( + "UI-SAMPLING-10 shows required Top P even when capability is %s", + async (status) => { + const saved = await api.getModelOutlet(); + saved.field_specs = [{ ...rule, key: "top_p", visible: false }]; + saved.profile.capabilities.sampling = { top_p: { status } }; + api.getModelOutlet.mockResolvedValue(saved); + setup( + + ); + await screen.findByRole("heading", { + name: "modelProfile.modelParameters", + }); + fireEvent.click(screen.getByText("modelProfile.advanced")); + await waitFor(() => expect(screen.getByText("top_p")).toBeVisible()); + } +); + +it("keeps output capability separate from an existing request default", async () => { + const saved = await api.getModelOutlet(); + saved.profile.parameter_values.max_output_tokens = 4096; + api.getModelOutlet.mockResolvedValue(saved); + setup( + + ); + await screen.findByRole("heading", { name: "modelProfile.modelParameters" }); + fireEvent.click(screen.getByText("modelProfile.advanced")); + const output = screen.getByRole("spinbutton", { + name: "modelProfile.maximumOutput", + }); + expect(output).toHaveValue("16000"); + fireEvent.change(output, { target: { value: "8000" } }); + fireEvent.click(screen.getByRole("button", { name: "common.save" })); + await waitFor(() => expect(api.updateModelOutlet).toHaveBeenCalledOnce()); + const request = api.updateModelOutlet.mock.calls[0][1]; + expect(request.context_limits.output_limit).toBe(8000); + expect(request.parameter_values.max_output_tokens).toBe(4096); + expect(request.delete_parameters).not.toContain("max_output_tokens"); +}); + +it("UI-LAYOUT-08 cancels an edited draft without saving", async () => { + const close = vi.fn(); + setup( + + ); + await screen.findByRole("heading", { name: "modelProfile.modelParameters" }); + fireEvent.click(screen.getByText("temperature=zero")); + fireEvent.click(screen.getByRole("button", { name: "common.cancel" })); + expect(close).toHaveBeenCalledOnce(); + expect(api.updateModelOutlet).not.toHaveBeenCalled(); +}); + +it("UI-LAYOUT-08 retries a failed load before exposing saved parameters", async () => { + const saved = await api.getModelOutlet(); + api.getModelOutlet + .mockReset() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValue(saved); + setup( + + ); + await screen.findByText("modelProfile.loadFailed"); + expect(screen.queryByText("temperature=zero")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "common.retry" })); + await screen.findByText("temperature=zero"); + expect(api.updateModelOutlet).not.toHaveBeenCalled(); +}); + +it.each([true, false])( + "UI-TLS-12 reflects saved TLS verification=%s and saves its inverse after toggling", + async (verify) => { + const saved = await api.getModelOutlet(); + saved.profile.connection.ssl_verify = verify; + api.getModelOutlet.mockResolvedValue(saved); + const success = vi.fn(); + setup( + + ); + await screen.findByRole("heading", { + name: "modelProfile.modelParameters", + }); + fireEvent.click(screen.getByText("modelProfile.advanced")); + const toggle = screen.getByRole("switch", { + name: "modelProfile.skipTlsVerify", + }); + expect(toggle).toHaveAttribute("aria-checked", String(!verify)); + fireEvent.click(toggle); + fireEvent.click(screen.getByRole("button", { name: "common.save" })); + await waitFor(() => expect(success).toHaveBeenCalledOnce()); + expect(api.updateModelOutlet.mock.calls[0][1].connection.ssl_verify).toBe( + !verify + ); + }, + 15000 +); + +it.each([ + [true, undefined, undefined], + [false, undefined, undefined], + [true, "", null], + [true, "***", undefined], + [true, "replacement-key", "replacement-key"], + [false, "replacement-key", "replacement-key"], +] as const)( + "UI-AUTH-13 masks saved=%s and handles input=%s", + async (hasKey, input, expected) => { + const saved = await api.getModelOutlet(); + saved.has_credential = hasKey; + api.getModelOutlet.mockResolvedValue(saved); + const success = vi.fn(); + setup( + + ); + const field = await screen.findByLabelText("modelProfile.apiKey"); + expect(field).toHaveValue(hasKey ? "********" : ""); + if (input !== undefined) + fireEvent.change(field, { target: { value: input } }); + fireEvent.click(screen.getByRole("button", { name: "common.save" })); + await waitFor(() => expect(success).toHaveBeenCalledOnce()); + const request = api.updateModelOutlet.mock.calls[0][1]; + if (expected === undefined) expect(request).not.toHaveProperty("api_key"); + else expect(request.api_key).toBe(expected); + expect(JSON.stringify(request)).not.toContain("********"); + }, + 15000 +); + +it("UI-AUTH-13 blocks mixed placeholder text from becoming a credential", async () => { + setup( + + ); + const field = await screen.findByLabelText("modelProfile.apiKey"); + fireEvent.change(field, { target: { value: "********new" } }); + expect(screen.getByRole("button", { name: "common.save" })).toBeDisabled(); + expect(api.updateModelOutlet).not.toHaveBeenCalled(); +}); + +it.each([true, false])( + "UI-AUTH-14 exposes visibility only for newly entered keys (saved=%s)", + async (hasKey) => { + const saved = await api.getModelOutlet(); + saved.has_credential = hasKey; + api.getModelOutlet.mockResolvedValue(saved); + setup( + + ); + const field = await screen.findByLabelText("modelProfile.apiKey"); + expect(field).toHaveAttribute("type", "password"); + expect( + screen.queryByRole("button", { name: "Show" }) + ).not.toBeInTheDocument(); + fireEvent.change(field, { target: { value: "replacement-key" } }); + fireEvent.click(screen.getByRole("button", { name: "Show" })); + expect(field).toHaveAttribute("type", "text"); + fireEvent.click(screen.getByRole("button", { name: "Hide" })); + expect(field).toHaveAttribute("type", "password"); + fireEvent.click(screen.getByRole("button", { name: "Show" })); + fireEvent.change(field, { target: { value: "" } }); + expect( + screen.queryByRole("button", { name: /Show|Hide/ }) + ).not.toBeInTheDocument(); + expect(field).toHaveAttribute("type", "password"); + fireEvent.change(field, { target: { value: "another-key" } }); + expect(field).toHaveAttribute("type", "password"); + // jsdom does not complete Modal's CSS entrance animation. Assert the + // credential control's own visibility, independently of modal opacity. + expect( + screen.getByRole("button", { name: "Show" }).parentElement + ).toHaveStyle({ + display: "inline-flex", + }); + if (hasKey) { + fireEvent.change(field, { target: { value: "********" } }); + expect( + screen.queryByRole("button", { name: /Show|Hide/ }) + ).not.toBeInTheDocument(); + expect(field).toHaveAttribute("type", "password"); + } + expect(api.updateModelOutlet).not.toHaveBeenCalled(); + }, + 15000 +); diff --git a/frontend/tests/component/modelProfileSuggestion.test.tsx b/frontend/tests/component/modelProfileSuggestion.test.tsx new file mode 100644 index 0000000000..bfe6d333c5 --- /dev/null +++ b/frontend/tests/component/modelProfileSuggestion.test.tsx @@ -0,0 +1,69 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { expect, it } from "vitest"; +import { + suggestModelProfile, + matchesProfileKeywords, +} from "../../app/[locale]/models/components/model/profileSuggestion"; +import type { AtomicProfileSummary } from "../../types/modelProfile"; +const profile = (key: string): AtomicProfileSummary => ({ + key, + name: key, + revision: "r1", + catalog_kind: "model", + qualification: "reference_only", +}); +it("suggests the longest contained model name without crossing unrelated or ambiguous identities", () => { + const qwen = profile("qwen3.5-plus"); + expect(suggestModelProfile("deploy-Qwen3.5-Plus-latest", [qwen])).toBe(qwen); + expect(suggestModelProfile("deepseek-v4", [qwen])).toBeUndefined(); + expect(suggestModelProfile("", [qwen])).toBeUndefined(); + expect( + suggestModelProfile("deploy-qwen3.5-plus", [ + qwen, + profile("vendor/qwen3.5-plus"), + ]) + ).toBeUndefined(); + expect(suggestModelProfile("qwen3.5-plus", [qwen, profile("qwen3.5")])).toBe( + qwen + ); +}); + +it("suggests a stable same-version profile for a short model ID and searches normalized keywords", () => { + const base = profile("alibaba/qwen3.8-27b"); + const max = profile("alibaba/qwen3.8-max"); + const older = profile("alibaba/qwen3.5-plus"); + expect(suggestModelProfile("Qwen3.8", [max, older, base])).toBe(base); + expect(suggestModelProfile("qwen3.8-max", [base, max])).toBe(max); + expect(suggestModelProfile("qwen3.9", [base, max])).toBeUndefined(); + expect(matchesProfileKeywords("QWEN 3.8 27b", base)).toBe(true); + expect(matchesProfileKeywords("DeepSeek", base)).toBe(false); +}); + +it("matches short GLM IDs inside routing prefixes while preserving minor versions", () => { + const major = profile("zhipuai/glm-5"); + const current = profile("zhipuai/glm-5.3"); + const flash = profile("zhipuai/glm-5.3-flash"); + const older = profile("zhipuai/glm-5.2"); + for (const id of [ + "zhipu/glm-5.3", + "tenant-route/GLM_5_3-latest", + "providerGLM5.3-online", + ]) { + expect(suggestModelProfile(id, [major, current, flash, older])).toBe( + current + ); + } + expect( + suggestModelProfile("zhipu/glm-5.3-flash-fast", [major, current, flash]) + ).toBe(flash); + expect( + suggestModelProfile("zhipu/glm-5.30", [major, current]) + ).toBeUndefined(); + expect( + suggestModelProfile("zhipu/glm-5.2", [major, current]) + ).toBeUndefined(); + expect( + suggestModelProfile("deploy-qwen3.80-max", [profile("alibaba/qwen3.8-max")]) + ).toBeUndefined(); +}); diff --git a/frontend/tests/component/requestParametersEditor.test.tsx b/frontend/tests/component/requestParametersEditor.test.tsx new file mode 100644 index 0000000000..277a7a9098 --- /dev/null +++ b/frontend/tests/component/requestParametersEditor.test.tsx @@ -0,0 +1,109 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { useState } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; +import { RequestParametersEditor } from "../../components/model/RequestParametersEditor"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +function Harness({ + initial, + changed, +}: { + initial: Array<{ path: string; value: unknown }>; + changed: (value: string) => void; +}) { + const [value, setValue] = useState(JSON.stringify(initial)); + return ( + { + setValue(next); + changed(next); + }} + /> + ); +} + +it("preserves typed false, zero, null, objects and arrays without a JSON text field", () => { + const changed = vi.fn(); + const initial = [ + { path: "/flag", value: false }, + { path: "/count", value: 0 }, + { path: "/empty", value: null }, + { + path: "/options", + value: { nested: { size: 3 }, tags: ["one", false, null] }, + }, + ]; + render(); + expect( + screen.queryByRole("textbox", { name: "modelProfile.extraParameters" }) + ).not.toBeInTheDocument(); + fireEvent.change( + screen.getAllByRole("textbox", { name: "modelProfile.parameterName 1" })[0], + { target: { value: "renamed" } } + ); + expect(JSON.parse(changed.mock.calls.at(-1)![0])).toEqual([ + { path: "/renamed", value: false }, + ...initial.slice(1), + ]); + fireEvent.change( + screen.getByRole("spinbutton", { name: "modelProfile.parameterValue 1" }), + { target: { value: "0" } } + ); + expect(JSON.parse(changed.mock.calls.at(-1)![0])[3].value.nested.size).toBe( + 0 + ); +}); + +it("retains incomplete and duplicate rows for correction and deletes only the selected row", () => { + const changed = vi.fn(); + render( + + ); + fireEvent.click( + screen.getByRole("button", { name: "modelProfile.addParameter" }) + ); + expect(changed).not.toHaveBeenCalled(); + fireEvent.change( + screen.getByRole("textbox", { name: "modelProfile.parameterName 2" }), + { target: { value: "flag" } } + ); + expect(screen.getByText("modelProfile.parameterRowsInvalid")).toBeVisible(); + fireEvent.change( + screen.getByRole("textbox", { name: "modelProfile.parameterName 2" }), + { target: { value: "second" } } + ); + expect(JSON.parse(changed.mock.calls.at(-1)![0])).toEqual([ + { path: "/flag", value: false }, + { path: "/second", value: "" }, + ]); + fireEvent.click(screen.getByRole("button", { name: "common.delete 1" })); + expect(JSON.parse(changed.mock.calls.at(-1)![0])).toEqual([ + { path: "/second", value: "" }, + ]); +}); + +it("keeps existing nested paths and escapes literal names instead of changing API keys", () => { + const changed = vi.fn(); + render( + + ); + fireEvent.change( + screen.getByRole("textbox", { name: "modelProfile.parameterValue 2" }), + { target: { value: "updated" } } + ); + expect(JSON.parse(changed.mock.calls.at(-1)![0])).toEqual([ + { path: "/options/flag", value: false }, + { path: "/literal~1name", value: "updated" }, + ]); +}); diff --git a/test-e2e/cases/CHAT-MODEL-CONTROLS-D2-001/case.yaml b/test-e2e/cases/CHAT-MODEL-CONTROLS-D2-001/case.yaml index ed0b9a1ce0..481d146e23 100644 --- a/test-e2e/cases/CHAT-MODEL-CONTROLS-D2-001/case.yaml +++ b/test-e2e/cases/CHAT-MODEL-CONTROLS-D2-001/case.yaml @@ -4,14 +4,14 @@ module: CHAT-MESSAGE case: case_id: CHAT-MODEL-CONTROLS-D2-001 feature_id: CHAT-MODEL-CONTROLS - requirement_ids: [CHAT-MODEL-CONTROLS-20261014, CHAT-SAMPLING-20261016] + requirement_ids: [SESSION-MODEL-UNIFY-20261020, CHAT-MODEL-CONTROLS-20261014, CHAT-SAMPLING-20261016] business_rule_ids: [CHAT-MODEL-CONTROLS-RULE-002, CHAT-MODEL-CONTROLS-RULE-003, CHAT-MODEL-CONTROLS-RULE-006] - title: 三运行端点的选模、思考部分覆盖与旧请求兼容契约 + title: 三运行端点共享选模、档案参数与旧临时请求拒绝契约 type: API-IT priority: P0 status: active automation: automated - objective: Verify actual HTTP DTOs and route adapters preserve absent overrides, explicit intent, valid raw reasoning controls and legacy payloads. + objective: Verify actual HTTP DTOs and route adapters preserve absent overrides, explicit intent, canonical profile preferences and rejection of removed temporary payloads. preconditions: - use actual FastAPI routes and existing authentication/tenant dependencies after implementation. - Isolated tenant creator has valid Agent and Skill drafts, effort/budget/plain LLM fixtures and distinctive existing model parameters. @@ -22,16 +22,16 @@ case: variants: [legacy-absent, empty-overlay, partial-overlay, disabled-with-remembered-effort, auto, none, minimal, low, medium, high, xhigh, max, budget-zero-auto, budget-min, budget-max, invalid-enum, field-null, wrong-type, budget-outside-range, conflicting-concrete-controls, conflicting-snapshot] steps: - order: 1 - action: Submit each route's existing minimal valid payload without new fields; repeat with empty NL2 generation_config and legacy model_id null; compare inherited model and parameters with the baseline. + action: Submit each route's existing minimal valid payload without new fields; repeat with empty canonical preferences; compare inherited model and parameters with the baseline. expected: Valid streams retain their existing schema and defaults; absent fields never materialize false/low overrides and no new sampling input is required. - order: 2 - action: Select the effort model and submit partial enablement, explicit false with remembered effort, true plus auto and each of seven concrete supported effort values; repeat on ordinary and creator route forms including persisted Workbench creation snapshots. + action: Select the effort model and submit partial enablement, explicit false with inactive effort removed, true plus inherited defaults and each declared effort value; repeat on ordinary and creator route forms including persisted Workbench creation snapshots. expected: Requests preserve exact enum and presence intent; false disables active reasoning, auto inherits the selected model effective default, partial effort does not implicitly enable thinking, and matching snapshot fields are accepted. - order: 3 - action: Select the budget model; submit zero auto, minimum and maximum budget; exercise existing ordinary top-level budget fields and compatible Workbench budget snapshots. - expected: Valid budgets use budget controls without a conflicting concrete effort; zero means automatic rather than a zero provider limit and old budget requests are not overwritten by defaulted generation fields. + action: Select the budget model; submit minimum and maximum declared budgets; send the same canonical preference map on all routes. + expected: Valid budgets use the declared profile domain and a single canonical preference field; resource declarations do not contain model parameters. - order: 4 - action: Submit invalid enum/type/null reasoning fields, out-of-capability effort, out-of-range budget, enabled concrete effort plus budget, contradictory legacy fields/snapshots, and invalid sampling types/ranges or conflicting sampling snapshots. + action: Submit invalid enum/type/null reasoning fields, out-of-capability effort, out-of-range budget, enabled concrete effort plus budget, removed temporary fields, and invalid sampling types/ranges or conflicting sampling snapshots. expected: Each invalid input returns 422 before SSE headers/provider execution, with field validation or model_controls_invalid detail; valid numeric sampling overrides including zero are accepted; bool/string/nonfinite/out-of-range sampling and conflicting snapshots are rejected. expected_results: - All three actual endpoints distinguish absence, disabled, automatic and concrete controls while preserving original successful stream contracts and model parameters. @@ -43,13 +43,13 @@ case: protocol: HTTP JSON request with existing SSE success and preflight JSON errors method: POST endpoint: /api/agent/run; /api/agent/nl2agent/run; /api/skills/nl2skill/run - request_expectations: [Preserve existing payload fields and route-specific schemas, Optional NL2 overrides have no implicit false or low values, Workbench snapshots and explicit runtime controls agree, Sampling overrides preserve optionality and explicit zero] + request_expectations: [Preserve existing payload fields and route-specific schemas, Optional NL2 overrides have no implicit false or low values, Resource snapshots never duplicate model preferences, Sampling overrides preserve optionality and explicit zero] response_expectations: [Valid requests retain existing 200 stream format, Invalid reasoning fields or conflicting controls return 422 before streaming] execution: profiles: [{name: mock, required_services: [http-fixture]}] acceptance: - id: AC-01 - description: Legacy and partial requests preserve presence-aware semantics across all three routes. + description: Canonical and partial requests preserve presence-aware semantics across all three routes. steps: [1, 2, 3] expected_results: [1] forbidden_side_effects: [1] diff --git a/test-e2e/cases/CHAT-MODEL-CONTROLS-D2-001/test_api.py b/test-e2e/cases/CHAT-MODEL-CONTROLS-D2-001/test_api.py index 5f81cea409..8bb1cf5229 100644 --- a/test-e2e/cases/CHAT-MODEL-CONTROLS-D2-001/test_api.py +++ b/test-e2e/cases/CHAT-MODEL-CONTROLS-D2-001/test_api.py @@ -1,98 +1,53 @@ # Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. -"""CHAT-MODEL-CONTROLS-D2-001: actual HTTP DTO and compatibility contract.""" +"""SU-AC-01/05/07: actual HTTP routes accept one profile preference contract.""" import json - import pytest - from model_controls.api import EFFORTS, api_fixture pytestmark = [pytest.mark.case_id("CHAT-MODEL-CONTROLS-D2-001"), pytest.mark.stage("D2")] - @pytest.fixture def api(monkeypatch): return api_fixture(monkeypatch) - @pytest.mark.parametrize("path", ["/api/agent/run", "/api/agent/nl2agent/run", "/api/skills/nl2skill/run"]) def test_http_control_intents(api, path): - def submit(**values): - response = api.post(path, api.payload(path, **values)) + model_id = api.records["effort"]["model_id"] + def submit(values=None, **extra): + body = api.payload(path, model_id=model_id, model_parameter_preferences={str(model_id): values or {}}, **extra) + response = api.post(path, body) assert response.status_code == 200, response.text assert response.headers["content-type"].startswith("text/event-stream") + assert "generation_config" not in api.calls[-1] return json.loads(response.text.removeprefix("data: ").strip()) - original = submit() assert (original["enabled"], original["effort"], original["temperature"], original["top_p"]) == (True, "medium", 0.43, 0.78) - assert "generation_config" not in api.calls[-1] - assert submit(generation_config={}) == original - preserved = {"history": [{"role": "user", "content": "Prior fixture turn"}], - "minio_files": [{"object_name": "fixture-attachment", "description": "Fixture text"}]} - if "skill" in path: - preserved.update(language="en", draft_snapshot={"name": "fixture_skill", "content": "Existing fixture"}) + for effort in EFFORTS: + assert submit({"enable_thinking": True, "reasoning_effort": effort})["effort"] == effort + assert submit({"enable_thinking": False})["effort"] is None + for temperature, top_p in [(0, 0), (0.7, 0.9), (2, 1)]: + result = submit({"temperature": temperature, "top_p": top_p}) + assert (result["temperature"], result["top_p"]) == (temperature, top_p) + preserved = {"history": [{"role": "user", "content": "Previous fixture turn"}], "minio_files": [{"object_name": "fixture-attachment"}]} submit(**preserved) for key, value in preserved.items(): assert api.calls[-1][key] == value - if "nl2" in path: - assert submit(generation_config=None, model_id=None) == original - for effort in EFFORTS + ["auto"]: - result = submit(model_id=api.records["effort"]["model_id"], generation_config={"deep_thinking": True, "thinking_effort": effort}) - assert result["effort"] == ("medium" if effort == "auto" else effort) - assert api.calls[-1]["generation_config"]["thinking_effort"] == effort - result = submit(generation_config={"deep_thinking": False, "thinking_effort": "xhigh"}) - assert result["enabled"] is False and result["effort"] is None - # A partial effort does not create a missing enablement field in the DTO. - submit(generation_config={"thinking_effort": "high"}) - assert "deep_thinking" not in api.calls[-1]["generation_config"] budget_id = api.records["budget"]["model_id"] - if path == "/api/agent/run": - api.configured.append(budget_id) - for budget in [0, 1024, 8192]: - result = submit(model_id=budget_id, generation_config={"deep_thinking": True, "reasoning_budget_tokens": budget}) - assert result["effort"] is None and result["budget"] == (budget or None) - if "nl2" in path: - saved = {"schema_version": 3, "mode": "skill_create" if "skill" in path else "agent_create", - "model_id": api.records["effort"]["model_id"], - "generation_config": {"deep_thinking": True, "thinking_effort": "xhigh"}} - assert submit(workbench_config=saved)["effort"] == "xhigh" - else: - assert submit(model_id=budget_id, reasoning_budget_tokens=1024)["budget"] == 1024 - for temperature, top_p in [(0, 0), (0.7, 0.9), (2, 1)]: - result = submit(generation_config={"temperature": temperature, "top_p": top_p}) - assert (result["temperature"], result["top_p"]) == (temperature, top_p) - assert api.calls[-1]["generation_config"] == {"temperature": temperature, "top_p": top_p} - assert submit(generation_config={})["temperature"] == 0.43 - if "nl2" in path: - assert submit(workbench_config={"model_id": 1, "generation_config": {"temperature": 0}})["temperature"] == 0 - + api.configured.append(budget_id) + for budget in [1024, 8192]: + response = api.post(path, api.payload(path, model_id=budget_id, model_parameter_preferences={str(budget_id): {"enable_thinking": True, "reasoning_budget_tokens": budget}})) + assert response.status_code == 200, response.text + assert json.loads(response.text.removeprefix("data: ").strip())["budget"] == budget @pytest.mark.parametrize("path", ["/api/agent/run", "/api/agent/nl2agent/run", "/api/skills/nl2skill/run"]) def test_http_rejects_invalid_overlays_before_execution(api, path): - invalid = [{"thinking_effort": "invalid"}, {"deep_thinking": True, "thinking_effort": "high", "reasoning_budget_tokens": 2000}, - {"deep_thinking": True, "reasoning_budget_tokens": 1024}, {"deep_thinking": True, "thinking_effort": "xhigh"}] - if "nl2" in path: - invalid += [{"deep_thinking": None}, {"thinking_effort": None}, {"reasoning_budget_tokens": None}, - {"deep_thinking": "false"}, {"temperature": 73}, {"top_p": 90}] - invalid += [{"temperature": -0.1}, {"temperature": 2.1}, {"top_p": -0.1}, {"top_p": 1.1}, - {"temperature": True}, {"top_p": "0.7"}] - before = len(api.calls) - for index, overlay in enumerate(invalid): - model_id = api.records["plain" if index == 3 else "effort"]["model_id"] - if path == "/api/agent/run": - api.configured.append(model_id) - response = api.post(path, api.payload(path, model_id=model_id, generation_config=overlay)) - assert response.status_code == 422, response.text - budget_id = api.records["budget"]["model_id"] - api.configured.append(budget_id) - for budget in [1, 8193, -1, 2.5]: - response = api.post(path, api.payload(path, model_id=budget_id, - generation_config={"deep_thinking": True, "reasoning_budget_tokens": budget})) + identifier = api.records["effort"]["model_id"] + for values in [{"temperature": True}, {"top_p": 1.1}, {"reasoning_effort": "invalid"}, {"enable_thinking": False, "reasoning_effort": "high"}]: + response = api.post(path, api.payload(path, model_id=identifier, model_parameter_preferences={str(identifier): values})) assert response.status_code == 422, response.text - if "nl2" in path: - for extra in [{"model_id": api.records["budget"]["model_id"]}, {"generation_config": {"thinking_effort": "high"}}, {"generation_config": {"temperature": 0.7}}]: - body = api.payload(path, workbench_config={"model_id": api.records["effort"]["model_id"], - "generation_config": {"thinking_effort": "xhigh", "temperature": 0}}, **extra) - assert api.post(path, body).status_code == 422 - assert len(api.calls) == before + assert not api.calls + for old in ["generation_config", "reasoning_effort", "reasoning_budget_tokens"]: + response = api.post(path, api.payload(path, **{old: {}})) + assert response.status_code == 422 and not api.calls diff --git a/test-e2e/cases/CHAT-MODEL-CONTROLS-D2-002/test_api.py b/test-e2e/cases/CHAT-MODEL-CONTROLS-D2-002/test_api.py index e8417424e2..5138f7aa52 100644 --- a/test-e2e/cases/CHAT-MODEL-CONTROLS-D2-002/test_api.py +++ b/test-e2e/cases/CHAT-MODEL-CONTROLS-D2-002/test_api.py @@ -36,7 +36,7 @@ def test_model_permission_and_tenant_preflight(api): api.configured = [api.records["effort"]["model_id"]] assert api.post("/api/agent/run", api.payload("/api/agent/run", model_id=plain_id)).status_code == 403 response = api.post("/api/skills/nl2skill/run", api.payload("/api/skills/nl2skill/run", model_id=plain_id, - generation_config={"deep_thinking": True, "thinking_effort": "xhigh"})) + model_parameter_preferences={str(plain_id): {"enable_thinking": True, "reasoning_effort": "xhigh"}})) assert response.status_code == 422 and api.calls == [] valid = api.records["effort"]["model_id"] assert api.post("/api/skills/nl2skill/run", api.payload("/api/skills/nl2skill/run", model_id=valid)).status_code == 200 diff --git a/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-001/case.yaml b/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-001/case.yaml index 77863962c3..1632e46b24 100644 --- a/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-001/case.yaml +++ b/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-001/case.yaml @@ -37,6 +37,9 @@ case: - order: 5 action: Clear model sampling configuration and send omitted, null, partial and explicit-zero sampling through ordinary and creator paths; compare model-list effective metadata and actual provider wire. expected: Null continues inheritance per field through Agent and model to shared SDK defaults 1.0/1.0. The displayed metadata matches model-level runtime defaults; Agent overrides retain priority only in Agent scope. Root reasoning, capacity, custom fields and independent models remain intact; stored global sampling stays null. + - order: 6 + action: Run Agent debug with canonical disabled and enabled reasoning, temperature and Top P overrides. + expected: The selected outlet receives the exact canonical values while Agent definitions and global outlet defaults remain unchanged. expected_results: - Actual selected root, provider endpoint/model and capacity source agree for ordinary and creator runtime paths. - All supported reasoning intents preserve exact semantics and existing sampling/output/custom settings. @@ -52,6 +55,11 @@ case: execution: profiles: [{name: mock, required_services: [model-provider, http-fixture]}] acceptance: + - id: AC-04 + description: Debug uses the same canonical model controls without changing persisted definitions. + steps: [6] + expected_results: [1, 2, 3] + forbidden_side_effects: [1, 2] - id: AC-01 description: Selected model identity and capacity survive the actual integration chain. steps: [1] diff --git a/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-001/execution.yaml b/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-001/execution.yaml index 1f8122415a..0bc5215961 100644 --- a/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-001/execution.yaml +++ b/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-001/execution.yaml @@ -5,6 +5,7 @@ implementations: file: test_runtime.py selector: test_ acceptance_bindings: + AC-04: [test_debug_uses_canonical_preferences_without_changing_definitions] AC-01: [test_selected_creator_runtime_wire] AC-02: [test_selected_creator_runtime_wire, test_reasoning_variants_preserve_independent_configuration, test_sampling_runtime_wire_and_persisted_intent] AC-03: [test_null_sampling_inherits_shared_defaults_and_matches_display_metadata] diff --git a/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-001/test_runtime.py b/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-001/test_runtime.py index 04f04095b9..c4d517e6f9 100644 --- a/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-001/test_runtime.py +++ b/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-001/test_runtime.py @@ -50,8 +50,13 @@ def test_selected_creator_runtime_wire(runtime, path, workbench): with runtime.storage["sessions"]() as session: stored = session.scalar(select(ConversationRecord.workbench_config).where( ConversationRecord.conversation_id == int(response.headers["conversation_id"]))) - assert stored["model_id"] == runtime.models["selected-B"] - assert stored["generation_config"]["thinking_effort"] == "xhigh" + assert "model_id" not in stored + assert session.scalar(select(ConversationRecord.selected_model_id).where( + ConversationRecord.conversation_id == int(response.headers["conversation_id"]))) == runtime.models["selected-B"] + assert "generation_config" not in stored + preferences = session.scalar(select(ConversationRecord.model_parameter_preferences).where( + ConversationRecord.conversation_id == int(response.headers["conversation_id"]))) + assert preferences[str(runtime.models["selected-B"])]["reasoning_effort"] == "xhigh" if path == "/api/agent/run" and not workbench: children = runtime.runs[-1].agent_config.worker_agents assert children and children[0].model_name == "business-C" @@ -86,8 +91,8 @@ def test_reasoning_variants_preserve_independent_configuration(runtime): assert wire, response.text assert all(row["model"] == "fixture-" + label for row in wire) root = next(m for m in runtime.runs[-1].model_config_list if m.cite_name == runtime.runs[-1].agent_config.model_name) - assert root.reasoning_effort == effort - assert root.reasoning_budget_tokens == budget + assert root.resolved_profile.parameter_values.get("reasoning_effort") == effort + assert root.resolved_profile.parameter_values.get("reasoning_budget_tokens") == budget assert root.top_p == 0.78 assert all(row.get("reasoning_effort") == effort for row in wire) if budget is not None: @@ -100,11 +105,8 @@ def test_reasoning_variants_preserve_independent_configuration(runtime): assert runtime.draft()["model_ids"] == original["model_ids"] assert runtime.draft()["model_params_override"] == original["model_params_override"] assert runtime.model_records() == original_models - from sqlalchemy import update - from database.db_models import ModelRecord - with runtime.storage["sessions"]() as session: - session.execute(update(ModelRecord).where(ModelRecord.model_id == runtime.models["selected-B"]).values( - extra_params={"enable_thinking": True, "reasoning_effort": "auto", "__custom__": {"fixture_custom": "retained"}})) + # Inheritance omits the parameter when the frozen outlet has no default. + runtime.set_outlet_defaults("selected-B", lambda values: {key: value for key, value in values.items() if key != "reasoning_effort"}) for path, workbench in paths: before_wire = len(runtime.provider.wire) response = runtime.post(path, generation={"deep_thinking": True, "thinking_effort": "auto"}, workbench=workbench, version_no=0) @@ -130,7 +132,7 @@ def test_sampling_runtime_wire_and_persisted_intent(runtime, path, workbench): assert not any('"type": "error"' in line for line in response.text.splitlines()) run = runtime.runs[-1] root = next(m for m in run.model_config_list if m.cite_name == run.agent_config.model_name) - assert set(root.explicit_sampling) == {"temperature", "top_p"} + assert {key: root.resolved_profile.parameter_values[key] for key in ("temperature", "top_p")} == {"temperature": 0, "top_p": 0.6} independent = next(m for m in run.model_config_list if m.cite_name == "business-C") assert (independent.temperature, independent.top_p) == (0.43, 0.78) assert independent.explicit_sampling == [] @@ -142,8 +144,11 @@ def test_sampling_runtime_wire_and_persisted_intent(runtime, path, workbench): with runtime.storage["sessions"]() as session: saved = session.scalar(select(ConversationRecord.workbench_config).where( ConversationRecord.conversation_id == int(response.headers["conversation_id"]))) - assert saved["generation_config"]["temperature"] == 0 - assert saved["generation_config"]["top_p"] == 0.6 + assert "generation_config" not in saved + preferences = session.scalar(select(ConversationRecord.model_parameter_preferences).where( + ConversationRecord.conversation_id == int(response.headers["conversation_id"]))) + assert preferences[str(runtime.models["selected-B"])]["temperature"] == 0 + assert preferences[str(runtime.models["selected-B"])]["top_p"] == 0.6 @pytest.mark.parametrize("path,workbench", [ @@ -154,19 +159,14 @@ def test_sampling_runtime_wire_and_persisted_intent(runtime, path, workbench): def test_null_sampling_inherits_shared_defaults_and_matches_display_metadata(runtime, path, workbench): import asyncio - from sqlalchemy import update - - from database.db_models import ModelRecord from management.services.model.management import list_models_for_tenant - with runtime.storage["sessions"]() as session: - session.execute(update(ModelRecord).where(ModelRecord.model_id == runtime.models["selected-B"]).values( - temperature=None, top_p=None)) + runtime.set_outlet_defaults("selected-B", lambda values: {**values, "temperature": 1.0, "top_p": 1.0}) records_before, draft_before = runtime.model_records(), runtime.draft() models = asyncio.run(list_models_for_tenant(runtime.storage["tenant"])) metadata = next(row for row in models if row["model_id"] == runtime.models["selected-B"]) assert metadata["effective_sampling"] == {"temperature": 1.0, "top_p": 1.0} - assert metadata["temperature"] is None and metadata["top_p"] is None + assert metadata["temperature"] == 1.0 and metadata["top_p"] == 1.0 expected_temperature = 0.56 if path == "/api/agent/run" and not workbench else 1.0 for sampling, expected in ( ({}, (expected_temperature, 1.0)), @@ -186,3 +186,24 @@ def test_null_sampling_inherits_shared_defaults_and_matches_display_metadata(run assert (independent.temperature, independent.top_p) == (0.43, 0.78) assert runtime.model_records() == records_before assert runtime.draft()["model_params_override"] == draft_before["model_params_override"] + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_debug_uses_canonical_preferences_without_changing_definitions(runtime, enabled): + before, models = runtime.draft(), runtime.model_records() + response = runtime.post("/api/agent/run", generation={ + "deep_thinking": enabled, "thinking_effort": "xhigh", "temperature": 0.2, "top_p": 0.7, + }, is_debug=True, version_no=0) + assert response.status_code == 200, response.text + assert runtime.provider.wire, response.text + wire = runtime.provider.wire[-1] + assert wire["model"] == "fixture-selected-B" + assert wire["temperature"] == 0.2 and wire["top_p"] == 0.7 + assert wire["enable_thinking"] is enabled + if enabled: + assert wire["reasoning_effort"] == "xhigh" + else: + assert "reasoning_effort" not in wire + events = [json.loads(line[6:]) for line in response.text.splitlines() if line.startswith("data: ")] + assert not any(event.get("type") == "error" for event in events), response.text + assert runtime.draft() == before and runtime.model_records() == models diff --git a/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-002/case.yaml b/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-002/case.yaml index 5b0a26698b..72d3be4e96 100644 --- a/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-002/case.yaml +++ b/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-002/case.yaml @@ -30,6 +30,9 @@ case: - order: 3 action: Start delayed B generation, cancel it, select C and retry with existing retry/conversation identifiers; deliver a delayed old B event after C starts. expected: B stops, C receives the new immutable controls/capacity and only C events update the active turn; no extra logical user turn or late B draft write overwrites C. Native retry preserves existing immutable branch rows at one message index and respects worker-drain admission; creator retry reuses its saved user ID. Old SSE is consumed after C starts and delayed provider responses are released after C where admission allows it. + - order: 4 + action: Return empty normal-stop responses with strict protocol repair both enabled and disabled; optionally return a visible answer on the second request. + expected: Exactly two identical physical requests; success recovers once, persistent empty ends with error without protocol repair or another Agent step. expected_results: - Explicit failure never becomes another model run and keeps recoverable valid state. - Provider failure and cancellation unlock the appropriate controls while retaining existing terminal/error protocols and committed valid draft state. @@ -43,6 +46,11 @@ case: execution: profiles: [{name: mock, required_services: [model-provider, http-fixture]}] acceptance: + - id: AC-03 + description: Empty stop has one retry in the unified model layer and never cascades into semantic protocol repair. + steps: [4] + expected_results: [1, 2] + forbidden_side_effects: [1, 2] - id: AC-01 description: Model loss and stream failure recover without fallback or unauthorized state changes. steps: [1, 2] diff --git a/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-002/execution.yaml b/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-002/execution.yaml index 07669788e8..e45e547452 100644 --- a/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-002/execution.yaml +++ b/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-002/execution.yaml @@ -5,6 +5,7 @@ implementations: file: test_runtime.py selector: test_ acceptance_bindings: + AC-03: [test_empty_stop_recovery_never_restarts_as_protocol_repair] AC-01: [test_unavailable_and_stream_failure_never_fall_back] AC-02: [test_cancel_retry_rejects_delayed_old_provider_output] timeout_seconds: 240 diff --git a/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-002/test_runtime.py b/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-002/test_runtime.py index e388788148..1f053b7dce 100644 --- a/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-002/test_runtime.py +++ b/test-e2e/cases/CHAT-MODEL-CONTROLS-D3-002/test_runtime.py @@ -135,7 +135,7 @@ def respond(body): assert runtime.draft()["model_ids"] == before["model_ids"] assert runtime.draft()["model_params_override"] == before["model_params_override"] root = next(m for m in runtime.runs[-1].model_config_list if m.cite_name == runtime.runs[-1].agent_config.model_name) - assert root.model_name == "fixture-default-A" and root.reasoning_effort == "minimal" + assert root.model_name == "fixture-default-A" and root.resolved_profile.parameter_values.get("reasoning_effort") == "minimal" if conversation_id is not None: with runtime.storage["sessions"]() as session: user_rows = session.scalars(select(ConversationMessage).where( @@ -144,3 +144,27 @@ def respond(body): # position; creator retries reuse the existing saved user row. assert len(user_rows) == (2 if path == "/api/agent/run" else 1) assert {row.message_index for row in user_rows} == {0} + + +@pytest.mark.parametrize("strict", [False, True]) +@pytest.mark.parametrize("recover", [False, True]) +def test_empty_stop_recovery_never_restarts_as_protocol_repair(runtime, strict, recover): + from database.db_models import AgentInfo + with runtime.storage["sessions"]() as session: + session.execute(update(AgentInfo).where(AgentInfo.agent_id == runtime.agent_id).values( + enable_protocol_repair_retry=strict)) + def respond(body): + requests = [wire for wire in runtime.provider.wire if wire.get("stream")] + return "Recovered" if recover and len(requests) >= 2 else "" + runtime.provider.responder = respond + before, models = runtime.draft(), runtime.model_records() + response = runtime.post("/api/agent/run", generation={"deep_thinking": False}, version_no=0) + assert response.status_code == 200, response.text + requests = [wire for wire in runtime.provider.wire if wire.get("stream")] + assert len(requests) == 2, response.text + assert requests[0] == requests[1] + events = [json.loads(line[6:]) for line in response.text.splitlines() if line.startswith("data: ")] + assert any(event.get("type") == "error" for event in events) is (not recover), response.text + if recover: + assert "Recovered" in response.text + assert runtime.draft() == before and runtime.model_records() == models diff --git a/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-001/case.yaml b/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-001/case.yaml index c438d335fb..9c3d17d295 100644 --- a/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-001/case.yaml +++ b/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-001/case.yaml @@ -5,6 +5,7 @@ case: case_id: CHAT-MODEL-CONTROLS-D4-001 feature_id: CHAT-MODEL-CONTROLS requirement_ids: + - SESSION-MODEL-UNIFY-20261020 - CHAT-MODEL-CONTROLS-20261014 - CHAT-WORKBENCH-REFINEMENTS-20261015 business_rule_ids: @@ -35,7 +36,6 @@ case: - budget-B - plain-model supported_efforts: - - auto - none - minimal - low @@ -59,18 +59,17 @@ case: action: Send a fixed creation request, inspect the actual frontend POST, consume deterministic clarification/structured file events, then send a follow-up from the subsequent composer. expected: Both turns use B and true/xhigh through the original route; custom defaults do not replace B. Existing agent_id, - history, attachments, language, draft_snapshot, persistence and retry fields retain their route meanings; Workbench - snapshots agree with the effective controls. NL2Agent draft updates and NL2Skill file views remain usable. + history, attachments, language, draft_snapshot, persistence and retry fields retain their route meanings; canonical conversation preference snapshots agree with the effective controls; Workbench config contains resources only. NL2Agent draft updates and NL2Skill file views remain usable. - order: 3 - action: Switch enabled effort to automatic and explicitly send; disable thinking and send; switch to budget-B and choose + action: Reset the effort override to its inherited outlet value and explicitly send; disable thinking and send; switch to budget-B and choose a valid concrete budget; then inspect plain-model controls. - expected: Requests preserve automatic versus disabled versus budget intent with no concrete effort/budget conflict; plain + expected: Requests preserve inherited versus disabled versus budget intent with no concrete effort/budget conflict; plain model has no fabricated levels. The menu's enabled state does not change capability values and original messages/drafts remain visible. Thinking off collapses the effort or budget row, and enabling restores applicable configured reasoning defaults without changing candidate capability. expected_results: - Four creators share the same capability-derived selection UI, including the initial and subsequent NL2Agent composer. - - Captured real frontend requests and Workbench snapshots match visible effective selections, while original creation events + - Captured real frontend requests and shared conversation preference snapshots match visible effective selections, while original creation events and draft interfaces remain functional. forbidden_side_effects: - No real provider calls, global model mutations or unrelated real database writes from browser mock fixtures. @@ -84,7 +83,7 @@ case: - Send first request - Consume original creation events - Send follow-up - - Exercise automatic/off/budget/plain model + - Exercise inherited/off/budget/plain model final_business_result: - Creator selection is visible and reaches real frontend serialization consistently without disturbing creation flows failure_evidence: diff --git a/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-001/test.spec.ts b/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-001/test.spec.ts index 74f45f3026..6b99a6c5ff 100644 --- a/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-001/test.spec.ts +++ b/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-001/test.spec.ts @@ -19,27 +19,30 @@ journey("CHAT-MODEL-CONTROLS-D4-001", async ({ page, contract }) => { await thinking(page, true); await openControls(page); await settings(page).getByRole("combobox").click(); - for (const label of ["自动", ...labels]) await expect(page.locator("[data-model-effort-popup]").getByText(label, { exact: true })).toBeVisible(); + for (const label of labels) await expect(page.locator("[data-model-effort-popup]").getByText(label, { exact: true })).toBeVisible(); await page.locator("[data-model-effort-popup]").getByText("极高", { exact: true }).click(); await page.keyboard.press("Escape"); - await expect(trigger(page)).toContainText("极高"); + await openControls(page); + await expect(settings(page).locator(".ant-select")).toContainText("极高"); + await page.keyboard.press("Escape"); } }); await contract.step(contract.expectedIds("steps")[1], async () => { for (const entry of creatorEntries) { await enter(page, entry); await chooseModel(page, "Effort B"); await thinking(page, true, "极高"); for (const text of ["Create fixture capability", "Refine fixture capability"]) { - const { path, body } = await send(page, state, text); + const { path, body, effective } = await send(page, state, text); expect(path).toBe(entry.endsWith("skill") ? "/api/skills/nl2skill/run" : "/api/agent/nl2agent/run"); expect(body.model_id).toBe(3); - expect(body.generation_config).toMatchObject({ deep_thinking: true, thinking_effort: "xhigh" }); + expect(effective).toMatchObject({ enable_thinking: true, reasoning_effort: "xhigh" }); + expect(body).not.toHaveProperty("generation_config"); expect(body.query).toBe(text); expect(body.history).toBeInstanceOf(Array); expect(body).not.toHaveProperty("reasoning_effort"); if (entry.startsWith("workbench")) { expect(body.persist_history).toBe(true); - expect(body.workbench_config.model_id).toBe(body.model_id); - expect(body.workbench_config.generation_config).toMatchObject(body.generation_config); + expect(body.workbench_config).not.toHaveProperty("model_id"); + expect(body.workbench_config).not.toHaveProperty("generation_config"); } if (entry.endsWith("agent")) expect(body.agent_id).toBe(42); else { expect(body.language).toBe("zh"); expect(body.draft_snapshot).toBeDefined(); } @@ -51,15 +54,15 @@ journey("CHAT-MODEL-CONTROLS-D4-001", async ({ page, contract }) => { await contract.step(contract.expectedIds("steps")[2], async () => { for (const entry of creatorEntries) { await enter(page, entry); await chooseModel(page, "Effort B"); await thinking(page, true, "自动"); - expect((await send(page, state)).body.generation_config).toMatchObject({ deep_thinking: true, thinking_effort: "auto" }); + expect((await send(page, state)).effective).toMatchObject({ enable_thinking: true, reasoning_effort: "low" }); await thinking(page, false); await openControls(page); await expect(settings(page).getByRole("combobox")).toHaveCount(0); await page.keyboard.press("Escape"); - expect((await send(page, state)).body.generation_config.deep_thinking).toBe(false); + expect((await send(page, state)).effective.enable_thinking).toBe(false); await chooseModel(page, "Budget B"); await thinking(page, true); await openControls(page); - const slider = settings(page).getByRole("slider"); await slider.fill("2048"); await page.keyboard.press("Escape"); - expect((await send(page, state)).body.generation_config).toMatchObject({ deep_thinking: true, thinking_effort: "auto", reasoning_budget_tokens: 2048 }); + const budget = settings(page).getByRole("spinbutton", { name: "reasoning_budget_tokens" }); await budget.fill("2048"); await budget.press("Enter"); await page.keyboard.press("Escape"); + expect((await send(page, state)).effective).toMatchObject({ enable_thinking: true, reasoning_budget_tokens: 2048 }); await chooseModel(page, "Plain Model"); await openControls(page); await expect(settings(page).getByRole("switch")).toBeDisabled(); await expect(settings(page).getByRole("combobox")).toHaveCount(0); @@ -69,7 +72,7 @@ journey("CHAT-MODEL-CONTROLS-D4-001", async ({ page, contract }) => { for (const id of contract.expectedIds("assertions")) await contract.assertion(id, async () => { expect(new Set(state.requests.map(r => r.path))).toEqual(new Set(["/api/agent/nl2agent/run", "/api/skills/nl2skill/run"])); expect(state.requests.length).toBe(20); - expect(state.requests.every(r => !JSON.stringify(r.body.generation_config).includes("temperature"))).toBe(true); + expect(state.requests.every(r => !Object.hasOwn(r.explicit, "temperature"))).toBe(true); expect(state.writes.filter(path => path.includes("model/") && !path.includes("list"))).toEqual([]); }); }); diff --git a/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-002/case.yaml b/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-002/case.yaml index 584bbc4418..9a56993b62 100644 --- a/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-002/case.yaml +++ b/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-002/case.yaml @@ -5,6 +5,7 @@ case: case_id: CHAT-MODEL-CONTROLS-D4-002 feature_id: CHAT-MODEL-CONTROLS requirement_ids: + - SESSION-MODEL-UNIFY-20261020 - CHAT-MODEL-CONTROLS-20261014 - CHAT-WORKBENCH-REFINEMENTS-20261015 - WB-CREATION-TOGGLE-EXIT-20261015 @@ -20,7 +21,7 @@ case: objective: Verify shared controls preserve entry-specific candidate scopes and session lifecycle while guarding running or invalid state in complete browser journeys. preconditions: - - real frontend with isolated mock identities, authorized model directory, historical Workbench snapshots and controlled + - real frontend with isolated mock identities, authorized model directory, historical shared conversation preference snapshots and controlled slow/error streams. - Agent chat is configured for a strict subset of tenant models; independent creator sessions can be reset through existing Agent session or modal lifecycle. @@ -46,7 +47,7 @@ case: steps: - order: 1 action: Inspect candidates on all six surfaces; verify ordinary Agent chat excludes tenant-B when not configured, then - select distinct permitted models in two sessions or modes and switch back. + select distinct permitted models in two threads and switch back; entering a creation mode opens a separate thread. expected: Agent chat preserves configured scope, other entries preserve tenant LLM scope; returning restores each session's own selection and retains messages, input, uploaded attachment and valid draft. Clicking the currently active Agent/Skill creation mode exits to ordinary conversation; clicking the other mode switches creation mode without altering input @@ -54,8 +55,8 @@ case: - order: 2 action: Load an existing Workbench creation snapshot, reset an independent Agent creation session, close/reopen a fresh Skill creation modal and resolve an asynchronously delayed default after a user has selected a model. - expected: Workbench restores and validates saved controls; new independent sessions follow existing reset rules; delayed - default does not overwrite explicit user choice. No new standalone cross-refresh persistence is introduced. + expected: Workbench restores and validates controls from the shared conversation API; new independent sessions follow existing reset rules; delayed + default does not overwrite explicit user choice. Both chat modes restore model selection and per-model preferences from the common conversation API after refresh; independent ephemeral creators remain draft-only. - order: 3 action: Start a delayed run on each applicable entry, attempt to change model, reasoning and advanced settings, cancel, then select another model and explicitly send again. diff --git a/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-002/test.spec.ts b/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-002/test.spec.ts index fade9f769b..2900558743 100644 --- a/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-002/test.spec.ts +++ b/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-002/test.spec.ts @@ -41,7 +41,7 @@ journey("CHAT-MODEL-CONTROLS-D4-002", async ({ page, contract }) => { await input(page).fill("Preserve this local draft"); await chooseModel(page, "Effort B"); await thinking(page, true, "极高"); await chooseModel(page, "Default A"); await chooseModel(page, "Effort B"); - await expect(trigger(page)).toContainText("极高"); + await openControls(page); await expect(settings(page).locator(".ant-select")).toContainText("极高"); await page.keyboard.press("Escape"); expect(await inputText()).toBe("Preserve this local draft"); if (entry === "workbench") { await expect(page.getByText("model-controls-fixture.txt", { exact: true }).first()).toBeVisible(); @@ -58,7 +58,7 @@ journey("CHAT-MODEL-CONTROLS-D4-002", async ({ page, contract }) => { }); await contract.step(contract.expectedIds("steps")[1], async () => { await enter(page, "workbench"); await page.locator("button[data-radix-collection-item]").filter({ hasText: "Saved skill creation" }).click(); - await expect(trigger(page)).toContainText("Effort B"); await expect(trigger(page)).toContainText("极高"); + await expect(trigger(page)).toContainText("Effort B"); await openControls(page); await expect(settings(page).locator(".ant-select")).toContainText("极高"); await page.keyboard.press("Escape"); await expect(page.getByRole("button", { name: "技能创建", exact: true })).toHaveAttribute("aria-pressed", "true"); for (const entry of ["nl2agent", "nl2skill"] as Entry[]) { await enter(page, entry); await chooseModel(page, "Effort B"); @@ -87,12 +87,12 @@ journey("CHAT-MODEL-CONTROLS-D4-002", async ({ page, contract }) => { }); await contract.step(contract.expectedIds("steps")[3], async () => { await enter(page, "workbench"); await page.locator("button[data-radix-collection-item]").filter({ hasText: "Invalid saved model" }).click(); - await expect(page.getByText("所选模型不可用,请重新选择模型", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("所选模型出口已不可用,请检查配置或手动重新选择模型。", { exact: true }).first()).toBeVisible(); await input(page).fill("Recover invalid saved selection"); await expect(composer(page).getByRole("button", { name: "发送", exact: true })).toBeDisabled(); await chooseModel(page, "Effort B"); await send(page, state, "Recovered saved selection"); state.unavailable = true; await send(page, state, "Controlled unavailable model"); - await expect(page.getByText("所选模型不可用,请重新选择模型", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("所选模型出口已不可用,请检查配置或手动重新选择模型。", { exact: true }).first()).toBeVisible(); state.unavailable = false; await chooseModel(page, "Default A"); await send(page, state, "Reselected valid model"); for (const [permission, label] of [["agent:create", "智能体创建"], ["skill:create", "技能创建"]]) { state.authorization.permissions = [permission === "agent:create" ? "skill:create" : "agent:create"]; diff --git a/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-003/case.yaml b/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-003/case.yaml index d21bf88f31..eb2467dca0 100644 --- a/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-003/case.yaml +++ b/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-003/case.yaml @@ -5,6 +5,7 @@ case: case_id: CHAT-MODEL-CONTROLS-D4-003 feature_id: CHAT-MODEL-CONTROLS requirement_ids: + - SESSION-MODEL-UNIFY-20261020 - CHAT-MODEL-CONTROLS-20261014 - WB-CREATION-TOGGLE-EXIT-20261015 - CHAT-SAMPLING-20261016 @@ -43,15 +44,15 @@ case: - above-maximum - empty-invalid-draft defaults: - temperature: configured-or-sdk-1.0 - top_p: configured-or-sdk-1.0 + temperature: effective-outlet-default + top_p: effective-outlet-default steps: - order: 1 action: Open model controls on each creator, hover/click/focus Advanced settings below reasoning effort; change both slider and numeric input across minimum, middle and maximum values; exercise invalid/empty input and restore defaults. expected: Shared panel is reachable by pointer and keyboard; numeric values and sliders stay synchronized at temperature 0-2 and Top P 0-1 with step 0.1, invalid committed values do not persist and restore clears both overrides and shows - the effective numeric defaults with matching sliders. Unconfigured models display SDK metadata defaults 1.0/1.0; + the effective numeric defaults with matching sliders. Fixture models display their declared outlet defaults 1.0/1.0; opening/focusing unchanged defaults does not create an override. - order: 2 action: Set model-A sampling overrides, switch to B and back, switch to another creation session and return; transition @@ -60,8 +61,8 @@ case: NL2Agent composer transition retains that session's local settings. - order: 3 action: Explicitly send valid creation requests after editing controls, then inspect all run bodies, Workbench creation - snapshots, model-context-derived payloads and captured configuration API writes. - expected: Explicit temperature/top_p reach generation_config and matching Workbench snapshots; untouched/reset fields + snapshots, canonical preference payloads and captured configuration API writes. + expected: Explicit temperature/top_p reach the canonical first-turn preference map or existing-conversation preference PATCH, never generation_config or Workbench resource config; untouched/reset fields are absent or null for inheritance. No global model save occurs; actual model/thinking requests remain functional. expected_results: - Creator advanced panels match the existing shared control behavior, defaults and session/model isolation. diff --git a/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-003/test.spec.ts b/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-003/test.spec.ts index 9a59d9aa4c..341a1b95d9 100644 --- a/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-003/test.spec.ts +++ b/test-e2e/cases/CHAT-MODEL-CONTROLS-D4-003/test.spec.ts @@ -15,9 +15,14 @@ journey("CHAT-MODEL-CONTROLS-D4-003", async ({ page, contract }) => { const advanced = settings(page).getByRole("button", { name: "高级设置", exact: true }); await advanced.focus(); await advanced.hover(); await expect(panel).toBeVisible(); }; + const refreshed = () => page.waitForResponse(response => response.request().method() === "GET" && + /\/model\/outlets\/\d+$|\/model_preferences\/\d+$/.test(new URL(response.url()).pathname) && response.status() === 200); const set = async (t: string, p: string) => { - await open(); await temperature.fill(t); await temperature.press("Enter"); - await topP.fill(p); await topP.press("Enter"); + await open(); + const temperatureSaved = refreshed(); + await temperature.fill(t); await temperature.press("Enter"); await temperatureSaved; await expect(temperature).toBeEnabled(); + const topPSaved = refreshed(); + await topP.fill(p); await topP.press("Enter"); await topPSaved; await expect(topP).toBeEnabled(); }; for (const id of contract.expectedIds("preconditions")) await contract.precondition(id, async () => { await enter(page, "workbench-agent"); await chooseModel(page, "Default A"); await open(); @@ -36,7 +41,9 @@ journey("CHAT-MODEL-CONTROLS-D4-003", async ({ page, contract }) => { await expect(panel.getByRole("slider", { name: "Top P 滑块", exact: true })).toHaveAttribute("aria-valuenow", pValue); } const slider = panel.getByRole("slider", { name: "温度 滑块", exact: true }); - await slider.focus(); await slider.press("ArrowLeft"); await expect(temperature).toHaveValue("1.9"); + const sliderSaved = refreshed(); + await slider.focus(); await slider.press("ArrowLeft"); await sliderSaved; + await expect(temperature).toBeEnabled(); await expect(temperature).toHaveValue("1.9"); for (const invalid of ["-0.1", "2.1", ""]) { await temperature.fill(invalid); await temperature.press("Enter"); await expect(temperature).toHaveAttribute("aria-invalid", "true"); @@ -46,8 +53,8 @@ journey("CHAT-MODEL-CONTROLS-D4-003", async ({ page, contract }) => { await expect(temperature).toHaveValue("0.6"); await expect(topP).toHaveValue("0.85"); await page.keyboard.press("Escape"); const inherited = await send(page,state); - expect(inherited.body.generation_config.temperature ?? null).toBeNull(); - expect(inherited.body.generation_config.top_p ?? null).toBeNull(); + expect(inherited.explicit.temperature ?? null).toBeNull(); + expect(inherited.explicit.top_p ?? null).toBeNull(); } }); await contract.step(contract.expectedIds("steps")[1], async () => { @@ -72,12 +79,13 @@ journey("CHAT-MODEL-CONTROLS-D4-003", async ({ page, contract }) => { await contract.step(contract.expectedIds("steps")[2], async () => { for (const entry of creatorEntries) { await enter(page, entry); await chooseModel(page, "Effort B"); await set("0.7", "0.6"); await page.keyboard.press("Escape"); - const { body } = await send(page, state); + const { body, effective } = await send(page, state); expect(body.model_id).toBe(3); - expect(body.generation_config).toMatchObject({temperature:0.7,top_p:0.6}); + expect(effective).toMatchObject({temperature:0.7,top_p:0.6}); + expect(body).not.toHaveProperty("generation_config"); expect(body).not.toHaveProperty("temperature"); expect(body).not.toHaveProperty("top_p"); if (body.workbench_config) { - expect(body.workbench_config.generation_config).toMatchObject({temperature:0.7,top_p:0.6}); + expect(body.workbench_config).not.toHaveProperty("generation_config"); } } }); diff --git a/test-e2e/cases/CHAT-MODEL-CONTROLS-D5-001/case.yaml b/test-e2e/cases/CHAT-MODEL-CONTROLS-D5-001/case.yaml new file mode 100644 index 0000000000..1dd9a8dc5b --- /dev/null +++ b/test-e2e/cases/CHAT-MODEL-CONTROLS-D5-001/case.yaml @@ -0,0 +1,51 @@ +schema_version: '1.0' +stage: D5 +module: CHAT-MESSAGE +case: + case_id: CHAT-MODEL-CONTROLS-D5-001 + feature_id: CHAT-MODEL-CONTROLS + requirement_ids: [SESSION-MODEL-UNIFY-20261020] + business_rule_ids: [CHAT-MODEL-CONTROLS-RULE-003] + title: Migrate Workbench model settings into shared conversation preferences + type: DEPLOYMENT + priority: P0 + status: active + automation: automated + objective: Prove actual PostgreSQL data upgrade preserves explicit values and unrelated conversation state. + preconditions: [Disposable UUID-owned PostgreSQL database, Historical v3 Workbench declarations and new preference columns] + test_data: + configuration: Non-secret synthetic conversation rows with zero/false values, budgets, existing preferences and resource mounts. + steps: + - order: 1 + action: Execute the production migration on representative historical and ordinary conversation rows. + expected: Old values become canonical per-model preferences, existing new values win, inactive degrees disappear, and unrelated state remains identical. + - order: 2 + action: Execute the migration again and compare every persisted field including revisions and timestamps. + expected: No row changes on repetition. + - order: 3 + action: Supply historical settings without a model identity and attempt the upgrade. + expected: Upgrade fails explicitly and leaves all original settings unchanged rather than assigning them to an arbitrary model. + expected_results: + - Exact converted values, root selection, mounts and message fixtures survive without two editable parameter stores. + - Repeated migration is idempotent including audit and revision fields. + - Missing identity does not discard historical configuration. + forbidden_side_effects: + - No production database, provider request or credential in evidence. + - No modification of published Agents, existing messages or unrelated resources. + risk_type: DEPLOYMENT + risk: Historical session settings may be lost, misassigned or applied twice during upgrade. + condition: [Actual PostgreSQL with representative historical Workbench settings] + metrics: [Value mismatch count, Unrelated-state mismatch count, Repeat-update count] + thresholds: [All mismatch counts are zero, Repeat-update count is zero] + recovery_expectations: [Ambiguous identity fails without mutating the historical settings] + acceptance: + - id: AC-01 + description: Historical settings convert exactly without changing unrelated state. + steps: [1] + expected_results: [1] + forbidden_side_effects: [1, 2] + - id: AC-02 + description: Repetition is idempotent and ambiguous identity cannot cause data loss. + steps: [2, 3] + expected_results: [2, 3] + forbidden_side_effects: [1, 2] diff --git a/test-e2e/cases/CHAT-MODEL-CONTROLS-D5-001/execution.yaml b/test-e2e/cases/CHAT-MODEL-CONTROLS-D5-001/execution.yaml new file mode 100644 index 0000000000..876cb91680 --- /dev/null +++ b/test-e2e/cases/CHAT-MODEL-CONTROLS-D5-001/execution.yaml @@ -0,0 +1,10 @@ +schema_version: '1.0' +case_id: CHAT-MODEL-CONTROLS-D5-001 +implementations: +- framework: pytest + file: test_migration.py + selector: test_su_ac_03 +acceptance_bindings: + AC-01: [test_su_ac_03_upgrade_is_exact_and_idempotent] + AC-02: [test_su_ac_03_upgrade_is_exact_and_idempotent, test_su_ac_03_missing_model_identity_fails_without_discarding_settings] +timeout_seconds: 120 diff --git a/test-e2e/cases/CHAT-MODEL-CONTROLS-D5-001/test_migration.py b/test-e2e/cases/CHAT-MODEL-CONTROLS-D5-001/test_migration.py new file mode 100644 index 0000000000..989b28bb96 --- /dev/null +++ b/test-e2e/cases/CHAT-MODEL-CONTROLS-D5-001/test_migration.py @@ -0,0 +1,95 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""SU-AC-03: actual PostgreSQL upgrade preserves settings and unrelated resources.""" + +import json +from pathlib import Path + +import pytest +from shared.factories.scratch_database import create_scratch, drop_scratch, scratch_name +from shared.postgres import postgres_sql + +pytestmark = [pytest.mark.case_id("CHAT-MODEL-CONTROLS-D5-001"), pytest.mark.stage("D5")] +MIGRATION = Path(__file__).resolve().parents[3] / "deploy/sql/migrations/v2.7.1_007_unify_conversation_model_parameters.sql" + + +@pytest.fixture +def database(): + name = scratch_name() + create_scratch(name) + try: + postgres_sql("""CREATE SCHEMA nexent; + CREATE TABLE nexent.conversation_record_t ( + conversation_id integer PRIMARY KEY, workbench_config jsonb, + workbench_config_version integer NOT NULL DEFAULT 0, + model_parameter_preferences jsonb NOT NULL DEFAULT '{}', + model_parameter_preferences_version integer NOT NULL DEFAULT 0, + updated_by text, update_time timestamptz, + message_fixture jsonb NOT NULL DEFAULT '{"messages":["keep"]}');""", database=name) + yield name + finally: + drop_scratch(name) + + +def insert(database, identifier, config, preferences=None): + # Inputs are fixed fixture values; quote SQL string literals without interpolation of secrets. + def literal(value): + return "'" + json.dumps(value).replace("'", "''") + "'::jsonb" + postgres_sql(f"INSERT INTO nexent.conversation_record_t(conversation_id, workbench_config, model_parameter_preferences) " + f"VALUES ({identifier}, {literal(config)}, {literal(preferences or {})});", database=database) + + +def rows(database): + output = postgres_sql("SELECT row_to_json(t)::text FROM nexent.conversation_record_t t ORDER BY conversation_id;", database=database) + values = [json.loads(line) for line in output.splitlines() if line.strip()] + for row in values: + row.setdefault("selected_model_id", None) + return values + + +def config(**generation): + return {"schema_version": 3, "mode": "single_agent_chat", "model_id": 42, + "generation_config": generation, "agent_mounts": [{"agent_id": 7, "version_no": 2}], + "skill_mounts": [{"skill_id": 9, "config_values": {"region": "test"}}], + "knowledge_scope": {"local": {"knowledge_ids": [4]}}} + + +def test_su_ac_03_upgrade_is_exact_and_idempotent(database): + insert(database, 1, config(deep_thinking=True, thinking_effort="auto", reasoning_budget_tokens=1024, + temperature=0, top_p=1, requested_output_tokens=2000), {"43": {"temperature": 0.9}}) + insert(database, 2, config(deep_thinking=False, thinking_effort="high", reasoning_budget_tokens=1024), + {"42": {"enable_thinking": True, "reasoning_effort": "low"}}) + insert(database, 3, config(deep_thinking=True, reasoning_budget_tokens=1024), {"42": {"enable_thinking": False}}) + insert(database, 4, {**config(deep_thinking=False, thinking_effort="low"), "model_parameters_source": "outlet_preferences"}, + {"42": {"temperature": 0.3}}) + insert(database, 5, None, {"42": {"temperature": 0.5}}) + before = rows(database) + postgres_sql(MIGRATION.read_text(), database=database) + after = rows(database) + assert after[0]["model_parameter_preferences"] == { + "42": {"enable_thinking": True, "reasoning_budget_tokens": 1024, "temperature": 0, "top_p": 1}, + "43": {"temperature": 0.9}} + assert after[1]["model_parameter_preferences"]["42"] == {"enable_thinking": True, "reasoning_effort": "low"} + assert after[2]["model_parameter_preferences"]["42"] == {"enable_thinking": False} + assert after[3]["model_parameter_preferences"] == {"42": {"temperature": 0.3}} + assert after[4] == before[4] + for old, new in zip(before[:4], after[:4]): + expected = {key: value for key, value in old["workbench_config"].items() + if key not in {"generation_config", "model_parameters_source", "schema_version", "model_id"}} + assert new["workbench_config"] == {**expected, "schema_version": 4, + **({"requested_output_tokens": 2000} if old["conversation_id"] == 1 else {})} + assert new["selected_model_id"] == 42 + assert new["message_fixture"] == old["message_fixture"] + assert new["workbench_config_version"] == old["workbench_config_version"] + 1 + postgres_sql(MIGRATION.read_text(), database=database) + assert rows(database) == after + + +def test_su_ac_03_missing_model_identity_fails_without_discarding_settings(database): + legacy = config(temperature=0.2) + legacy.pop("model_id") + insert(database, 1, legacy) + before = rows(database) + with pytest.raises(Exception): + postgres_sql(MIGRATION.read_text(), database=database) + assert rows(database) == before diff --git a/test-e2e/cases/FAF-D3-001/test.py b/test-e2e/cases/FAF-D3-001/test.py index fd2ef351a4..7dbc1c2e41 100644 --- a/test-e2e/cases/FAF-D3-001/test.py +++ b/test-e2e/cases/FAF-D3-001/test.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """FAF-D3-001: exercise the deployed Agent with its configured real model.""" import argparse @@ -120,7 +122,7 @@ def create(kind, duty, related=None): if args.run: fixture = json.loads((args.evidence / "fixtures.json").read_text(encoding="utf-8"))[args.run] body = {"query": "请按职责执行本次最终回答展示验收。", "agent_id": fixture["agent_id"], - "version_no": fixture["version_no"], "history": [], "generation_config": {"deep_thinking": False}, "is_debug": False} + "version_no": fixture["version_no"], "history": [], "model_parameter_preferences": {}, "is_debug": False} response = client.post(args.base + "/api/agent/run", json=body, stream=True, timeout=(30, 240)) response.raise_for_status() conversation = response.headers.get("conversation_id") diff --git a/test-e2e/cases/MOP-D3-001/case.yaml b/test-e2e/cases/MOP-D3-001/case.yaml new file mode 100644 index 0000000000..791168de23 --- /dev/null +++ b/test-e2e/cases/MOP-D3-001/case.yaml @@ -0,0 +1,66 @@ +schema_version: '1.0' +stage: D3 +module: MODEL-SERVICE +case: + case_id: MOP-D3-001 + feature_id: BE-MODEL-OUTLET-PERSISTENCE + business_rule_ids: [BE-MODEL-OUTLET-PERSISTENCE-RULE-01, BE-MODEL-OUTLET-PERSISTENCE-RULE-02, BE-MODEL-OUTLET-PERSISTENCE-RULE-03, BE-MODEL-OUTLET-PERSISTENCE-RULE-04] + title: Persist isolated outlets with transactional optimistic updates + type: INTEGRATION + priority: P1 + status: active + automation: automated + objective: Exercise actual PostgreSQL storage, snapshot codecs and shared transaction behavior. + integration_boundary: + - Actual PostgreSQL plus backend CRUD, shared session ownership and SDK snapshot codecs; implementation availability is a controlled test registry, no provider request occurs. + observations: [Persisted rows and revision values across separate sessions, SQL exception category and sanitized public diagnostics] + cleanup: [Drop only the isolated run-owned database and close test-owned sessions] + preconditions: [Isolated run-owned PostgreSQL database with current schema, Product database modules and SDK available at locked revision] + test_data: + configuration: Two synthetic tenants and outlets with zero/false/null parameter values and non-secret dummy credentials. + steps: + - order: 1 + action: Create and read a resolved outlet in one caller-owned transaction, commit it, then restore it through a new session. + expected: Dedicated/JSON values match, the hard output cap and request default occupy distinct columns, and audit/id/revision fields are correct; only the API key column stores the dummy credential. + - order: 2 + action: Load the same revision for two writers, commit the first update and submit the second stale update; read through another tenant and after soft deletion. + expected: One update increments revision, stale update fails without overwriting; wrong tenant and deleted rows are unavailable. + - order: 3 + action: Create/update inside a transaction that fails later, roll it back and read through another session; provoke an actual SQL error with a dummy private value. + expected: No partially committed row/configuration survives and public error/log text excludes the dummy credential. + - order: 4 + action: Persist zero/false/null preferences for two outlets in an owned conversation; reject stale and other-user writes, clear one outlet, then soft-delete the conversation. + expected: Exact values and other outlets survive; preference revisions advance independently without changing outlet defaults or model-visible metadata. + expected_results: + - Full profile survives actual PostgreSQL commit/load with single-location values and safe credential references. + - Tenant, active-row and revision constraints prevent unauthorized or stale writes, preserving the winner configuration. + - Transaction rollback restores prior state and query failures have sanitized diagnostics. + - Conversation preferences enforce ownership and active state, preserve exact values and other outlets, and never rewrite outlet defaults. + forbidden_side_effects: + - No production connections, provider calls, source-file updates or real credentials in artifacts. + - No commits or session closure by CRUD when the caller owns the transaction. + execution: + profiles: + - name: real_smoke + required_services: [] + acceptance: + - id: AC-01 + description: Actual committed row reconstructs the correct profile and credential ownership. + steps: [1] + expected_results: [1] + forbidden_side_effects: [1, 2] + - id: AC-02 + description: Concurrency, tenant and deletion checks preserve valid state. + steps: [2] + expected_results: [2] + forbidden_side_effects: [1, 2] + - id: AC-03 + description: Rollback and sanitized failure hold at the actual database boundary. + steps: [3] + expected_results: [3] + forbidden_side_effects: [1, 2] + - id: AC-04 + description: Real conversation preference storage preserves explicit values, ownership, revision and outlet independence. + steps: [4] + expected_results: [4] + forbidden_side_effects: [1, 2] diff --git a/test-e2e/cases/MOP-D3-001/execution.yaml b/test-e2e/cases/MOP-D3-001/execution.yaml new file mode 100644 index 0000000000..677b47f0bb --- /dev/null +++ b/test-e2e/cases/MOP-D3-001/execution.yaml @@ -0,0 +1,17 @@ +schema_version: '1.0' +case_id: MOP-D3-001 +implementations: +- framework: pytest + file: test.py + selector: MOP-D3-001 + profiles: [real_smoke] +acceptance_bindings: + AC-01: [test_committed_snapshot_and_transaction_ownership] + AC-02: [test_stale_writer_tenant_and_deleted_row] + AC-03: [test_rollback_and_sanitized_actual_database_failure] + AC-04: [test_conversation_preferences_commit_cas_ownership_and_outlet_independence] +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/MOP-D3-001/test.py b/test-e2e/cases/MOP-D3-001/test.py new file mode 100644 index 0000000000..726e348dd5 --- /dev/null +++ b/test-e2e/cases/MOP-D3-001/test.py @@ -0,0 +1,210 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Real PostgreSQL, product sessions and resolved outlet snapshot persistence.""" + +import json +from pathlib import Path + +import pytest +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.instruction_mapping import ( + resolve_instruction_mapping, +) +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile +from shared.factories.scratch_database import isolated_product_database +from shared.postgres import apply_product_postgres_env +from sqlalchemy import select, text, update +from sqlalchemy.exc import DBAPIError + +pytestmark = [pytest.mark.case_id("MOP-D3-001"), pytest.mark.stage("D3")] +REF = VersionedReference(ref="db_boundary_v1", version=1) +REGISTRY = {key: [REF] for key in ( + "protocol", "auth", "adapter", "code_converter", "native_converter", "parameter_transform" +)} +DUMMY = "non-secret-test-credential" + + +def parameter(key, schema): + return { + "key": key, "schema": schema, "visible": True, "editable": True, + "required": False, "allowed_scopes": ["outlet", "conversation"], + "ui_group": "common", "owner": "provider", "source": {"rule_id": "db-fixture"}, + "api_binding": {"path": "/" + key, "transform_ref": REF.ref, "transform_version": 1}, + } + + +def profile(): + role = VersionedReference(ref="system_only_v1", version=1) + return ResolvedModelProfile.model_validate({ + "schema_version": 1, "outlet_id": None, "config_revision": 1, + "identity": {"tenant_id": "db-tenant-a", "display_name": "outlet fixture", + "model_type": "llm", "provider_id": "custom-provider", + "request_model_id": "Exact/Model", "provider_key": None, "model_profile_key": None}, + "connection": {"base_url": "https://fixture.invalid/v1", "ssl_verify": False, + "timeout_seconds": 120, "concurrency_limit": None, + "credential_ref": {"kind": "model_record", "model_id": None}}, + "connection_rules": {"credential_kind": "model_record", "relative_path": "/chat/completions", + "method": "POST", "headers": {}}, + "context_configuration": {"output_reserve_tokens": None, "tokenizer_family": None, + "capacity_source": "operator", "capability_profile_version": None}, + "capabilities": {"context": {"combined_window": 200000, "output_limit": 32768}}, "instruction_mapping": resolve_instruction_mapping(role).model_dump(mode="json"), + "parameter_rules": [parameter("temperature", {"type": "integer"}), + parameter("custom_switch", {"type": "boolean"}), + parameter("custom_null", {"type": "null"}), + parameter("max_output_tokens", {"type": "integer", "minimum": 1})], + "parameter_values": {"temperature": 0, "custom_switch": False, "custom_null": None, "max_output_tokens": 4096}, + "extra_api_parameters": [{"path": "/vendor/option", "value": False}], + "bindings": {**{key: REF.model_dump() for key in REGISTRY if key != "parameter_transform"}, + "role_mapping": role.model_dump()}, + "provenance": {"atomic_refs": {"model": None, "provider": None, "provider_model": None}, + "interpretation_versions": {"source_parser": None, "source_mapping": None, + "supplement_schema": None, "composer": None}, + "overrides": [], "field_sources": [], "provider_metadata": {}}, + }) + + +@pytest.fixture +def database(monkeypatch): + monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[3] / "backend")) + apply_product_postgres_env() + from consts.exceptions import ModelOutletConflictError, ModelOutletDatabaseError + from database import client, model_outlet_db + from database.db_models import ModelRecord + + with isolated_product_database(): + yield client, model_outlet_db, ModelRecord, ModelOutletConflictError, ModelOutletDatabaseError + + +def create(db, session=None): + return db.create_resolved_model_record(profile(), "creator", "db-tenant-a", + registered_references=REGISTRY, api_key=DUMMY, db_session=session) + + +def read(db, identifier, session=None, tenant="db-tenant-a"): + return db.get_resolved_model_record(identifier, tenant, registered_references=REGISTRY, db_session=session) + + +def change(db, snapshot, session=None): + return db.update_resolved_model_record(snapshot, snapshot.config_revision, "editor", "db-tenant-a", + registered_references=REGISTRY, db_session=session) + + +def test_committed_snapshot_and_transaction_ownership(database): + client, db, model, _, _ = database + with client.db_client.session_maker() as session: + identifier = create(db, session) + loaded = read(db, identifier, session) + assert session.in_transaction() + assert loaded.outlet_id == identifier and loaded.config_revision == 1 + # A separate connection cannot see an uncommitted creation. + assert read(db, identifier) is None + session.commit() + restored = read(db, identifier) + assert restored.parameter_values == {"temperature": 0, "custom_switch": False, "custom_null": None, "max_output_tokens": 4096} + assert type(restored.parameter_values["temperature"]) is int + assert restored.connection.credential_ref.model_id == identifier + with client.get_db_session() as session: + row = session.execute(select(model.__table__).where(model.model_id == identifier)).mappings().one() + assert row["api_key"] == DUMMY + assert DUMMY not in json.dumps(row["resolved_profile"]) + assert DUMMY not in json.dumps(row["extra_params"]) + assert row["temperature"] == 0 + assert row["max_output_tokens"] == 32768 and row["default_max_output_tokens"] == 4096 + assert "max_output_tokens" not in row["extra_params"]["parameter_values"] + assert "temperature" not in row["extra_params"]["parameter_values"] + assert row["created_by"] == row["updated_by"] == "creator" + assert row["create_time"] is not None and row["update_time"] is not None + assert row["delete_flag"] == "N" + + +def test_stale_writer_tenant_and_deleted_row(database): + client, db, model, conflict, _ = database + identifier = create(db) + with client.db_client.session_maker() as first, client.db_client.session_maker() as second: + winner, loser = read(db, identifier, first), read(db, identifier, second) + winner.parameter_values["temperature"] = 1 + loser.parameter_values["temperature"] = 2 + assert change(db, winner, first) == 2 + first.commit() + with pytest.raises(conflict): + change(db, loser, second) + second.rollback() + current = read(db, identifier) + assert current.config_revision == 2 and current.parameter_values["temperature"] == 1 + assert read(db, identifier, tenant="db-tenant-b") is None + with client.get_db_session() as session: + row = session.execute(select(model.__table__).where(model.model_id == identifier)).mappings().one() + assert row["created_by"] == "creator" and row["updated_by"] == "editor" + session.execute(update(model).where(model.model_id == identifier).values(delete_flag="Y")) + assert read(db, identifier) is None + + +def test_rollback_and_sanitized_actual_database_failure(database, caplog): + client, db, _, _, database_error = database + identifier = create(db) + with client.db_client.session_maker() as session: + transient = create(db, session) + changed = read(db, identifier, session) + changed.parameter_values["temperature"] = 3 + assert change(db, changed, session) == 2 + # Force a real server-side failure after both writes. + with pytest.raises(DBAPIError) as failure: + session.execute(text("SELECT 1 / 0")) + assert "division by zero" in str(failure.value) + session.rollback() + assert read(db, transient) is None + stable = read(db, identifier) + assert stable.config_revision == 1 and stable.parameter_values["temperature"] == 0 + # A scratch-only constraint causes an actual INSERT error containing a bound dummy value. + with client.get_db_session() as session: + session.execute(text("ALTER TABLE nexent.model_record_t ADD CONSTRAINT outlet_fixture_reject CHECK (api_key IS NULL) NOT VALID")) + with pytest.raises(database_error) as sanitized: + create(db) + assert str(sanitized.value) == "MODEL_OUTLET_DATABASE_ERROR" + assert sanitized.value.__suppress_context__ + assert DUMMY not in str(sanitized.value) and DUMMY not in caplog.text + assert read(db, identifier).config_revision == 1 + + +def test_conversation_preferences_commit_cas_ownership_and_outlet_independence(database): + client, db, _, conflict, _ = database + from database import conversation_model_preferences_db as preferences + from database.db_models import ConversationRecord + from consts.exceptions import ConversationNotFoundError + + first, second = create(db), create(db) + outlet_before = read(db, first) + with client.get_db_session() as session: + row = ConversationRecord(conversation_title="Parameter preference fixture", created_by="owner", + updated_by="owner", delete_flag="N") + session.add(row) + session.flush() + conversation_id = row.conversation_id + assert preferences.model_preferences(conversation_id, "owner") == { + "version": 0, "model_parameter_preferences": {}, + } + explicit = {"temperature": 0, "custom_switch": False, "custom_null": None} + preferences.model_preferences(conversation_id, "owner", expected_version=0, outlet_id=first, + resolve_values=lambda saved: explicit) + current = preferences.model_preferences(conversation_id, "owner", expected_version=1, outlet_id=second, + resolve_values=lambda saved: {"temperature": 1}) + assert current == {"version": 2, "model_parameter_preferences": { + str(first): explicit, str(second): {"temperature": 1}, + }} + with pytest.raises(conflict): + preferences.model_preferences(conversation_id, "owner", expected_version=1, outlet_id=first, + resolve_values=lambda saved: {}) + with pytest.raises(ConversationNotFoundError): + preferences.model_preferences(conversation_id, "another-user", expected_version=2, outlet_id=first, + resolve_values=lambda saved: pytest.fail("Must check ownership before callback")) + assert preferences.model_preferences(conversation_id, "owner") == current + cleared = preferences.model_preferences(conversation_id, "owner", expected_version=2, outlet_id=first, + resolve_values=lambda saved: {}) + assert cleared == {"version": 3, "model_parameter_preferences": {str(second): {"temperature": 1}}} + assert read(db, first) == outlet_before + with client.get_db_session() as session: + stored = session.get(ConversationRecord, conversation_id) + assert stored.runtime_metadata == {} and stored.workbench_config is None + stored.delete_flag = "Y" + with pytest.raises(ConversationNotFoundError): + preferences.model_preferences(conversation_id, "owner") diff --git a/test-e2e/cases/MOS-D5-001/case.yaml b/test-e2e/cases/MOS-D5-001/case.yaml new file mode 100644 index 0000000000..edc9b47c16 --- /dev/null +++ b/test-e2e/cases/MOS-D5-001/case.yaml @@ -0,0 +1,57 @@ +schema_version: '1.0' +stage: D5 +module: MODEL-SERVICE +case: + case_id: MOS-D5-001 + feature_id: BE-MODEL-OUTLET-SCHEMA + business_rule_ids: [BE-MODEL-OUTLET-SCHEMA-RULE-01, BE-MODEL-OUTLET-SCHEMA-RULE-02, BE-MODEL-OUTLET-SCHEMA-RULE-03] + title: Upgrade and repeat model outlet schema without data loss + type: DEPLOYMENT + priority: P1 + status: active + automation: automated + objective: Exercise incremental SQL and PostgreSQL metadata/data behavior against a pre-upgrade model table. + preconditions: + - Isolated PostgreSQL database created for the current run; no production database is permitted. + - Repository migration runner and SQL are available at the locked product revision. + test_data: + configuration: Synthetic model rows with non-secret connection values; transaction-scoped failure injection. + steps: + - order: 1 + action: Prepare the prior model table with representative existing endpoint, model, capacity, false/zero inference values and an extra parameter bag; execute the new schema SQL transactionally. + expected: Existing values are unchanged and new capabilities remain unknown; config_revision becomes 1. + - order: 2 + action: Query PostgreSQL column types, nullability, defaults and comments; repeat the SQL and compare existing rows and metadata. + expected: All new columns match the design and repeat execution preserves metadata and values. + - order: 3 + action: Inject a deliberate SQL failure after migration inside a transaction, confirm rollback, then run the unchanged migration successfully. + expected: Failed transaction leaves no partial new schema; the successful retry restores the complete extension. + expected_results: + - Prior model configuration values are unchanged; new capability/profile fields remain NULL and config_revision is 1. + - Actual PostgreSQL types, nullability, default and English comments match ORM/design; repeated execution is idempotent. + - Injected failure rolls back all migration changes and normal retry succeeds. + forbidden_side_effects: + - No production connection, real provider call or credential value in logs/artifacts. + - No new atomic profile tables, capability inference or modification of previously merged SQL. + risk_type: DEPLOYMENT + risk: Schema changes may rewrite model settings, lose unknown states or partially apply. + condition: [Isolated actual PostgreSQL with a pre-upgrade schema and existing model rows] + metrics: [Existing value mismatch count, New column metadata mismatch count, Partial schema count after rollback] + thresholds: [All mismatch counts are zero, Partial schema count is zero] + recovery_expectations: [The unchanged SQL succeeds after the intentionally failed transaction] + acceptance: + - id: AC-01 + description: Preserve legacy values and unknown capabilities during upgrade. + steps: [1] + expected_results: [1] + forbidden_side_effects: [1, 2] + - id: AC-02 + description: Validate actual metadata and idempotent repetition. + steps: [2] + expected_results: [2] + forbidden_side_effects: [1, 2] + - id: AC-03 + description: Roll back failed execution and recover by retry. + steps: [3] + expected_results: [3] + forbidden_side_effects: [1, 2] diff --git a/test-e2e/cases/MOS-D5-001/execution.yaml b/test-e2e/cases/MOS-D5-001/execution.yaml new file mode 100644 index 0000000000..72aa6b6d07 --- /dev/null +++ b/test-e2e/cases/MOS-D5-001/execution.yaml @@ -0,0 +1,16 @@ +schema_version: '1.0' +case_id: MOS-D5-001 +implementations: +- framework: pytest + file: test.py + selector: MOS-D5-001 + profiles: [real_smoke] +acceptance_bindings: + AC-01: [test_upgrade_preserves_values_and_repeats] + AC-02: [test_upgrade_preserves_values_and_repeats] + AC-03: [test_failed_upgrade_rolls_back_and_recovers] +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/MOS-D5-001/test.py b/test-e2e/cases/MOS-D5-001/test.py new file mode 100644 index 0000000000..6b9ef14287 --- /dev/null +++ b/test-e2e/cases/MOS-D5-001/test.py @@ -0,0 +1,113 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Actual PostgreSQL upgrade, idempotence and transactional recovery.""" + +import json +from pathlib import Path + +import psycopg2 +import pytest +from shared.factories.scratch_database import isolated_schema_database +from shared.postgres import postgres_connect_kwargs, postgres_scalar, postgres_sql + +pytestmark = [pytest.mark.case_id("MOS-D5-001"), pytest.mark.stage("D5")] +ROOT = Path(__file__).resolve().parents[3] +COLUMNS = { + "reasoning_supported": "boolean", + "reasoning_controls": "jsonb", + "native_tool_call_supported": "boolean", + "structured_output_supported": "boolean", + "temperature_supported": "boolean", + "top_p_supported": "boolean", + "input_modalities": "jsonb", + "output_modalities": "jsonb", + "enable_thinking": "boolean", + "reasoning_effort": "character varying", + "reasoning_budget_tokens": "integer", + "protocol_id": "character varying", + "resolved_profile": "jsonb", + "profile_schema_version": "integer", + "config_revision": "integer", +} + + +def migration_sql(): + # D5 deliberately locks the deployment artifact, rather than a business API. + candidates = list((ROOT / "deploy/sql/migrations").glob("*_model_outlet_profile_columns.sql")) + assert len(candidates) == 1 + return candidates[0].read_text(encoding="utf-8") + + +def prepare_previous_table(): + postgres_sql("TRUNCATE nexent.model_record_t CASCADE;") + postgres_sql("ALTER TABLE nexent.model_record_t " + ", ".join( + f'DROP COLUMN IF EXISTS "{name}"' for name in COLUMNS + ) + ";") + postgres_sql(""" + INSERT INTO nexent.model_record_t + (model_name, model_factory, base_url, context_window_tokens, + max_input_tokens, max_output_tokens, ssl_verify, temperature, top_p, + extra_params, tenant_id, delete_flag) + VALUES ('Exact/OLD', 'custom-provider', 'https://fixture.invalid/v1', + 200000, 180000, 20000, false, 0, 0, + '{"enable_thinking":false,"vendor_limit":0}'::jsonb, + 'schema-test', 'N'); + """) + return json.loads(postgres_scalar( + "SELECT row_to_json(t) FROM nexent.model_record_t t;" + )) + + +def metadata(): + return json.loads(postgres_scalar(""" + SELECT json_agg(json_build_object( + 'name', a.attname, 'type', format_type(a.atttypid, a.atttypmod), + 'not_null', a.attnotnull, + 'default', pg_get_expr(d.adbin, d.adrelid), + 'comment', col_description(a.attrelid, a.attnum)) ORDER BY a.attname) + FROM pg_attribute a LEFT JOIN pg_attrdef d + ON d.adrelid=a.attrelid AND d.adnum=a.attnum + WHERE a.attrelid='nexent.model_record_t'::regclass + AND a.attnum>0 AND NOT a.attisdropped; + """)) + + +def test_upgrade_preserves_values_and_repeats(): + with isolated_schema_database(): + before = prepare_previous_table() + postgres_sql("BEGIN;\n" + migration_sql() + "\nCOMMIT;") + after = json.loads(postgres_scalar("SELECT row_to_json(t) FROM nexent.model_record_t t;")) + assert {key: after[key] for key in before} == before + assert after["config_revision"] == 1 + assert all(after[key] is None for key in COLUMNS if key != "config_revision") + initial_metadata = metadata() + fields = {field["name"]: field for field in initial_metadata} + for name, expected_type in COLUMNS.items(): + field = fields[name] + expected = "character varying(30)" if expected_type == "character varying" else expected_type + assert field["type"] == expected + assert field["not_null"] is (name == "config_revision") + assert field["default"] == ("1" if name == "config_revision" else None) + assert field["comment"] and field["comment"].isascii() + postgres_sql("BEGIN;\n" + migration_sql() + "\nCOMMIT;") + assert metadata() == initial_metadata + assert json.loads(postgres_scalar("SELECT row_to_json(t) FROM nexent.model_record_t t;")) == after + + +def test_failed_upgrade_rolls_back_and_recovers(): + with isolated_schema_database(): + before = prepare_previous_table() + connection = psycopg2.connect(**postgres_connect_kwargs()) + try: + with pytest.raises(psycopg2.errors.DivisionByZero), connection, connection.cursor() as cursor: + cursor.execute(migration_sql()) + cursor.execute("SELECT 1 / 0;") + finally: + connection.close() + assert not set(COLUMNS).intersection(field["name"] for field in metadata()) + assert json.loads(postgres_scalar("SELECT row_to_json(t) FROM nexent.model_record_t t;")) == before + postgres_sql("BEGIN;\n" + migration_sql() + "\nCOMMIT;") + assert set(COLUMNS).issubset(field["name"] for field in metadata()) + restored = json.loads(postgres_scalar("SELECT row_to_json(t) FROM nexent.model_record_t t;")) + assert {key: restored[key] for key in before} == before + assert restored["config_revision"] == 1 diff --git a/test-e2e/cases/MPC-D1-001/case.yaml b/test-e2e/cases/MPC-D1-001/case.yaml new file mode 100644 index 0000000000..a93a9b9ff2 --- /dev/null +++ b/test-e2e/cases/MPC-D1-001/case.yaml @@ -0,0 +1,105 @@ +schema_version: '1.0' +stage: D1 +module: SDK-MODEL-SERVICE +case: + case_id: MPC-D1-001 + feature_id: SDK-MODEL-PROFILE-COMPOSITION + business_rule_ids: + - SDK-MODEL-PROFILE-COMPOSITION-RULE-01 + - SDK-MODEL-PROFILE-COMPOSITION-RULE-02 + - SDK-MODEL-PROFILE-COMPOSITION-RULE-03 + title: Compose pinned sources and explicit Nexent supplements atomically + type: SDK-UT + priority: P1 + status: active + automation: automated + objective: Execute real source loading, supplement interpretation, registered instruction/parameter + policy and provider-model composition as one read-only component. + preconditions: + - The SDK contains pinned source TOML and explicit versioned Nexent supplement rules. + - Temporary copies may alter supplement bytes and digests without modifying repository + files. + test_data: + provider: alibaba-cn + request_model_id: qwen3.5-plus + steps: + - order: 1 + action: Load the complete supplemented catalog and compose the selected exact + provider-model draft, then validate default and explicit parameter values. + In temporary rules, layer model/provider/combination experience defaults and capability changes. + expected: Capabilities retain source evidence; instruction and API references + are explicit; upstream facts are combined before model, provider and exact-combination + supplements, and users see the effective defaults. Values obey permissions and conditions + without transport or persistence. + - order: 2 + action: Change one declared supplement in a temporary copy and reload; compare + repeated loads and previously returned snapshots. + expected: Affected catalog/profile revisions and effective policy change deterministically; + original source facts and prior snapshots remain unchanged. + - order: 3 + action: Load invalid supplement versions/references, contradictory equal-priority + overrides and invalid conditional values through the same component. + expected: Each failure has a structured value-free diagnostic, no partial publication + or mutation, and no silently selected conflicting behavior. + expected_results: + - The full source/supplement/policy component produces a complete exact provider-model + draft with traceable facts and overrides. + - Supplement contents and interpretation versions participate in deterministic revision + identity. + - Invalid rules, conflicts and values fail explicitly without hidden fallback or + partial catalogs. + forbidden_side_effects: + - No provider HTTP request, credential environment read, tenant database write or + tool execution occurs. + - Repository TOML and previously returned snapshots remain unchanged. + unit_boundary: Real collaboration across the existing source catalog, versioned + supplement reader, instruction registry, parameter policy and draft composition. + Existing MPN-D1-001 proves source-only publication; existing parameter UT proves + isolated values and permissions. Neither proves supplement/source conflicts, complete + bound draft generation and supplement revision propagation. Local temporary files + control only the filesystem boundary. + inputs: + - Pinned China-region source catalog and explicit Nexent supplement files + - Exact profile selection, controlled supplement changes and invalid rules/values + assertions: + - Composition preserves identity, source facts, explicit override evidence and registered + policy references + - User values are checked against the effective composed parameter rules + - Changed supplement semantics change relevant revisions without mutating prior + results + - Invalid references and conflicts prevent complete catalog publication + execution: + profiles: + - name: mock + required_services: [] + acceptance: + - id: AC-01 + description: Complete supplemented draft is traceable and enforces effective parameter/instruction + contracts without external execution. + steps: + - 1 + expected_results: + - 1 + forbidden_side_effects: + - 1 + - 2 + - id: AC-02 + description: Supplement changes produce deterministic relevant revision changes + while preserving source facts and prior snapshots. + steps: + - 2 + expected_results: + - 2 + forbidden_side_effects: + - 1 + - 2 + - id: AC-03 + description: Invalid supplements, references, conflicts and explicit values fail + atomically with sanitized diagnostics. + steps: + - 3 + expected_results: + - 3 + forbidden_side_effects: + - 1 + - 2 diff --git a/test-e2e/cases/MPC-D1-001/execution.yaml b/test-e2e/cases/MPC-D1-001/execution.yaml new file mode 100644 index 0000000000..4b172419a5 --- /dev/null +++ b/test-e2e/cases/MPC-D1-001/execution.yaml @@ -0,0 +1,16 @@ +schema_version: '1.0' +case_id: MPC-D1-001 +implementations: +- framework: pytest + file: test.py + selector: test_complete_supplemented_policy_and_source_trace + profiles: + - mock +acceptance_bindings: + AC-01: + - test_complete_supplemented_policy_and_source_trace + - test_experience_layers_follow_source_baseline + AC-02: + - test_supplement_revision_changes_without_mutating_prior_snapshots + AC-03: + - test_invalid_rules_references_conflicts_and_values_are_atomic diff --git a/test-e2e/cases/MPC-D1-001/test.py b/test-e2e/cases/MPC-D1-001/test.py new file mode 100644 index 0000000000..afd29b1d1c --- /dev/null +++ b/test-e2e/cases/MPC-D1-001/test.py @@ -0,0 +1,305 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""MPC-D1-001: real source, supplement and composed policy component contract.""" + +import hashlib +import os +import shutil +import socket +import tomllib +from pathlib import Path + +import pytest + +from nexent.core.model_service.profiles.composition import ( + ProfileCompositionError, + compose_provider_model_policy, +) +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError +from nexent.core.model_service.profiles.resolver import load_profile_catalog +from nexent.core.model_service.profiles.supplements import SupplementReadError + + +ROOT = Path(__file__).resolve().parents[3] / "sdk/nexent/core/model_service/profiles" +COMBINATION = "rules/providers/alibaba-cn/qwen3.5-plus.toml" +REFERENCE = VersionedReference(ref="component_boundary_v1", version=1) +TRANSFORM = VersionedReference(ref="identity_v1", version=1) +BINDING_NAMES = ("protocol", "auth", "adapter", "code_converter", "native_converter") +pytestmark = [pytest.mark.case_id("MPC-D1-001"), pytest.mark.stage("D1")] + + +@pytest.fixture +def no_external_access(monkeypatch): + def forbidden(*args, **kwargs): + raise AssertionError( + "Profile composition crossed an external execution boundary" + ) + + monkeypatch.setattr(socket, "create_connection", forbidden) + monkeypatch.setattr(socket.socket, "connect", forbidden) + monkeypatch.setattr(os, "getenv", forbidden) + + +def hashes(root): + return { + path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in root.rglob("*.toml") + } + + +def copied_root(tmp_path): + root = tmp_path / "profiles" + shutil.copytree(ROOT, root) + return root + + +def change_rule(root, path, transform): + file = root / path + old = file.read_bytes() + new = transform(old.decode("utf-8")).encode("utf-8") + assert new != old + file.write_bytes(new) + manifest = root / "rules/manifest.toml" + old_hash, new_hash = ( + hashlib.sha256(old).hexdigest(), + hashlib.sha256(new).hexdigest(), + ) + blocks = manifest.read_text().split("[[rules]]") + matches = 0 + for index, block in enumerate(blocks[1:], 1): + entry = tomllib.loads(block) + if entry["path"] == path: + assert entry["content_sha256"] == old_hash + blocks[index] = block.replace(old_hash, new_hash) + matches += 1 + assert matches == 1 + manifest.write_text("[[rules]]".join(blocks)) + + +def compose(catalog, *, values=None, registry=None): + # Implementation availability is the controlled service boundary; no transport + # implementation or provider execution is claimed by this component Case. + references = {name: [REFERENCE] for name in BINDING_NAMES} + references["parameter_transform"] = [TRANSFORM] + return compose_provider_model_policy( + catalog, + provider_key="alibaba-cn", + request_model_id="qwen3.5-plus", + model_profile_key=None, + base_bindings={name: REFERENCE.model_dump() for name in BINDING_NAMES}, + base_instruction=VersionedReference(ref="system_only_v1", version=1), + registered_references=references if registry is None else registry, + submitted_values={} if values is None else values, + ) + + +def test_complete_supplemented_policy_and_source_trace(no_external_access): + before = hashes(ROOT) + catalog = load_profile_catalog(ROOT) + policy = compose(catalog) + assert ( + len(catalog.models), + len(catalog.providers), + len(catalog.provider_models), + ) == (26, 6, 35) + assert policy.provider_key == "alibaba-cn" + assert policy.model_profile_key == "alibaba/qwen3.5-plus" + assert policy.request_model_id == "qwen3.5-plus" + assert policy.capabilities["context"]["combined_window"] == 1_000_000 + assert policy.capabilities["tools"]["status"] == "supported" + assert policy.capabilities["reasoning"]["controls"][0]["values"] == (False, True) + assert policy.instruction_mapping["mappings"]["application_instruction"] == "system" + assert policy.parameter_values["enable_thinking"] is True + assert policy.parameter_bindings["enable_thinking"] == { + "path": "/enable_thinking", + "transform_ref": "identity_v1", + "transform_version": 1, + } + assert ( + compose(catalog, values={"enable_thinking": False}).parameter_values[ + "enable_thinking" + ] + is False + ) + hosted = catalog.provider_models["alibaba-cn/qwen3.5-plus"] + origin = hosted.source.field_origins["/capabilities/context/combined_window"] + assert origin.mapping.source.source_path == "limit.context" + assert ( + origin.mapping.source.file_path + == "providers/alibaba-cn/models/qwen3.5-plus.toml" + ) + assert hosted.source.source_refs + assert all( + ref.catalog_revision == hosted.source.source_refs[0].catalog_revision + for ref in hosted.source.source_refs + ) + assert policy.overrides[-1]["path"] == "/reasoning/controls" + original_controls = policy.overrides[-1]["original_value"] + assert original_controls[0]["values"] == (True, False) + assert original_controls[1]["parameter_key"] == "reasoning_budget_tokens" + assert original_controls[1]["min"] is None + assert original_controls[1]["max"] == 81920 + assert policy.capabilities["reasoning"]["controls"][1]["min"] is None + assert "reasoning_budget_tokens" not in policy.parameter_values + budget = compose(catalog, values={"reasoning_budget_tokens": 50}) + assert budget.parameter_values["reasoning_budget_tokens"] == 50 + assert ( + budget.parameter_bindings["reasoning_budget_tokens"]["path"] + == "/thinking_budget" + ) + with pytest.raises(ParameterValidationError, match="PARAMETER_DEPENDENCY"): + compose( + catalog, values={"enable_thinking": False, "reasoning_budget_tokens": 50} + ) + assert policy.overrides[-1]["source"] == COMBINATION + assert "alibabacloud.com" in policy.overrides[-1]["evidence"] + with pytest.raises(TypeError): + policy.parameter_values["enable_thinking"] = False + assert hashes(ROOT) == before + + +def test_supplement_revision_changes_without_mutating_prior_snapshots( + no_external_access, tmp_path +): + before = hashes(ROOT) + root = copied_root(tmp_path) + first = load_profile_catalog(root) + old_policy = compose(first) + repeated = load_profile_catalog(root) + assert repeated.revision == first.revision + assert compose(repeated).revision == old_policy.revision + change_rule( + root, + COMBINATION, + lambda text: text.replace("default_value = true", "default_value = false"), + ) + changed = load_profile_catalog(root) + new_policy = compose(changed) + assert changed.revision != first.revision + assert new_policy.revision != old_policy.revision + assert new_policy.parameter_values["enable_thinking"] is False + assert old_policy.parameter_values["enable_thinking"] is True + key = "alibaba-cn/qwen3.5-plus" + assert changed.provider_models[key].revision != first.provider_models[key].revision + assert ( + changed.provider_models[key].source.profile_revision + == first.provider_models[key].source.profile_revision + ) + assert ( + changed.models["alibaba/qwen3.5-plus"].revision + == first.models["alibaba/qwen3.5-plus"].revision + ) + assert load_profile_catalog(root).revision == changed.revision + assert hashes(ROOT) == before + + +def test_invalid_rules_references_conflicts_and_values_are_atomic( + no_external_access, tmp_path +): + before = hashes(ROOT) + root = copied_root(tmp_path / "version") + catalog = load_profile_catalog(root) + policy = compose(catalog) + for invalid in (None, "false", 0): + with pytest.raises(ParameterValidationError, match="PARAMETER_VALUE_INVALID"): + compose(catalog, values={"enable_thinking": invalid}) + with pytest.raises(ProfileCompositionError, match="PROFILE_BINDING_UNREGISTERED"): + compose(catalog, registry={}) + change_rule( + root, + COMBINATION, + lambda text: text.replace("schema_version = 1", "schema_version = 9"), + ) + with pytest.raises(SupplementReadError, match="SUPPLEMENT_RULE_INVALID"): + load_profile_catalog(root) + references_root = copied_root(tmp_path / "references") + change_rule( + references_root, + COMBINATION, + lambda text: text.replace( + 'transform_ref = "identity_v1"', 'transform_ref = "unregistered_v1"' + ), + ) + with pytest.raises( + ParameterValidationError, match="PARAMETER_TRANSFORM_UNREGISTERED" + ): + compose(load_profile_catalog(references_root)) + conflict_root = copied_root(tmp_path / "conflict") + change_rule( + conflict_root, + COMBINATION, + lambda text: ( + text + + '\n[[capability_overrides]]\npath = "/reasoning/controls"\nvalue = []\nreason = "Conflict fixture"\nevidence = "Case-controlled declaration"\n' + ), + ) + with pytest.raises(ProfileCompositionError, match="PROFILE_CONFLICT"): + load_profile_catalog(conflict_root) + condition_root = copied_root(tmp_path / "condition") + conditional_parameter = '\n[[parameters]]\nkey = "fixture_detail"\nvisible = true\neditable = true\nrequired = false\nallowed_scopes = ["outlet"]\nui_group = "fixture"\nowner = "combination"\n[parameters.schema]\ntype = "string"\n[parameters.source]\nref = "case-controlled"\n[parameters.api_binding]\npath = "/fixture_detail"\ntransform_ref = "identity_v1"\ntransform_version = 1\n[parameters.condition]\nkey = "enable_thinking"\nop = "eq"\nvalue = true\n' + change_rule(condition_root, COMBINATION, lambda text: text + conditional_parameter) + conditional_catalog = load_profile_catalog(condition_root) + assert ( + compose( + conditional_catalog, values={"fixture_detail": "allowed"} + ).parameter_values["fixture_detail"] + == "allowed" + ) + with pytest.raises( + ParameterValidationError, match="PARAMETER_DEPENDENCY" + ) as captured: + compose( + conditional_catalog, + values={"enable_thinking": False, "fixture_detail": "private-value"}, + ) + assert "private-value" not in str(captured.value) + assert policy.parameter_values["enable_thinking"] is True + assert load_profile_catalog(ROOT).revision == catalog.revision + assert hashes(ROOT) == before + + +def test_experience_layers_follow_source_baseline(no_external_access, tmp_path): + root = copied_root(tmp_path) + original = load_profile_catalog(root) + source_output = original.provider_models["alibaba-cn/qwen3.5-plus"].source.capabilities["context"]["output_limit"] + layers = [ + ("rules/models/alibaba/qwen3.5-plus.toml", "model", 16000, 0.2), + ("rules/providers/alibaba-cn/provider.toml", "provider", 12000, 0.4), + (COMBINATION, "combination", 8000, 0.6), + ] + previous = compose(original) + for path, owner, output, temperature in layers: + addition = f""" +[[capability_overrides]] +path = "/context/output_limit" +value = {output} +reason = "Case-controlled experience correction" +evidence = "MPC-D1-001 fixture" + +[[parameters]] +key = "temperature" +schema = {{ type = "number", minimum = 0, maximum = 2 }} +visible = true +editable = true +required = false +allowed_scopes = ["outlet"] +ui_group = "sampling" +owner = "{owner}" +source = {{ ref = "experience-fixture" }} +default_value = {temperature} +api_binding = {{ path = "/temperature", transform_ref = "identity_v1", transform_version = 1 }} +""" + change_rule(root, path, lambda text: text + addition) + current = load_profile_catalog(root) + policy = compose(current) + assert policy.capabilities["context"]["output_limit"] == output + assert policy.parameter_values["temperature"] == temperature + output_changes = [item for item in policy.overrides if item["path"] == "/context/output_limit"] + assert output_changes[-1]["source"] == path + assert policy.revision != previous.revision + assert current.provider_models["alibaba-cn/qwen3.5-plus"].source.capabilities["context"]["output_limit"] == source_output + assert compose(current, values={"temperature": 0.9}).parameter_values["temperature"] == 0.9 + previous = policy + assert compose(original).parameter_values.get("temperature") is None + assert load_profile_catalog(ROOT).revision == original.revision diff --git a/test-e2e/cases/MPN-D1-001/case.yaml b/test-e2e/cases/MPN-D1-001/case.yaml new file mode 100644 index 0000000000..6c49f9c084 --- /dev/null +++ b/test-e2e/cases/MPN-D1-001/case.yaml @@ -0,0 +1,69 @@ +schema_version: '1.0' +stage: D1 +module: SDK-MODEL-SERVICE +case: + case_id: MPN-D1-001 + feature_id: SDK-MODEL-PROFILE-NORMALIZATION + business_rule_ids: + - SDK-MODEL-PROFILE-NORMALIZATION-RULE-01 + - SDK-MODEL-PROFILE-NORMALIZATION-RULE-02 + - SDK-MODEL-PROFILE-NORMALIZATION-RULE-03 + title: Pinned catalog composes source semantics and origins atomically + type: SDK-UT + priority: P1 + status: active + automation: automated + objective: The catalog component checks submission inputs, inherits and normalizes the complete pinned inventory with deterministic versions and exact provenance, rejecting invalid catalogs without partial publication. + preconditions: + - The repository contains the pinned 67-source inventory and code-owned source conversion policies. + - Controlled temporary copies may alter source bytes, declared digests and conversion policies without changing repository files. + test_data: + source_count: 67 + model_count: 26 + provider_count: 6 + provider_model_count: 35 + steps: + - order: 1 + action: Load the pinned inventory through the catalog component twice and inspect China-region model and provider entries. + expected: Both loads produce the same immutable complete catalog and revision; identity, inherited capability limits and field origins agree with the pinned source data. + - order: 2 + action: Change a declared source value in a temporary catalog and update only its manifest digest; then change the registered conversion policy version in a controlled context. + expected: Each valid source or rule change changes the catalog revision and the affected normalized value or mapping provenance; the original catalog remains unchanged. + - order: 3 + action: Introduce an invalid consumed field, invalid inherited model reference or unregistered upstream version in controlled copies and execute the submission checker; also confirm reference metadata does not prevent conversion. + expected: Each full load fails with an explicit sanitized source diagnostic; no partial catalog is returned and a previously loaded catalog remains usable. + expected_results: + - The complete pinned inventory is normalized through real submission checking, reading, inheritance and conversion collaboration; runtime does not invoke the submission validator. + - Revision identity accounts for source bytes and mapping/parser versions; field origins retain source and rule ownership. + - Invalid catalogs fail atomically without mutating the previously returned catalog. + forbidden_side_effects: + - No provider HTTP requests, environment credential reads or database writes occur. + - No mutation of repository source files or previously returned immutable catalog values occurs. + unit_boundary: Real SDK catalog component collaboration across inventory reading, submission validation, inheritance, conversion, profile normalization and revision calculation; temporary local catalog files control the external filesystem boundary. Existing UT covers each helper independently, but not full inventory publication, cross-layer provenance and revision behavior. + inputs: + - Complete pinned authored TOML inventory and code-owned conversion policies + - Controlled source/rule changes and invalid catalog variants + assertions: + - Complete counts, China provider identities, selected exact model capabilities and provenance survive the full component pipeline + - Relevant source and mapping changes affect revision; repeated unchanged loads are deterministic + - Errors return structured sanitized diagnostics and never a partially published or mutated catalog + execution: + profiles: + - name: mock + required_services: [] + acceptance: + - id: AC-01 + description: Full submission checking and runtime normalization preserve capabilities and source provenance without external services or runtime submission validation. + steps: [1] + expected_results: [1, 2] + forbidden_side_effects: [1, 2] + - id: AC-02 + description: Valid source and mapping changes produce a changed deterministic revision while preserving the original snapshot. + steps: [2] + expected_results: [2] + forbidden_side_effects: [2] + - id: AC-03 + description: Invalid catalogs fail atomically and leave previously loaded results usable. + steps: [3] + expected_results: [3] + forbidden_side_effects: [1, 2] diff --git a/test-e2e/cases/MPN-D1-001/execution.yaml b/test-e2e/cases/MPN-D1-001/execution.yaml new file mode 100644 index 0000000000..a09fb9eafe --- /dev/null +++ b/test-e2e/cases/MPN-D1-001/execution.yaml @@ -0,0 +1,16 @@ +schema_version: '1.0' +case_id: MPN-D1-001 +implementations: +- framework: pytest + file: test.py + selector: test_complete_catalog_capabilities_origins_and_immutable_snapshots + profiles: + - mock +acceptance_bindings: + AC-01: + - test_runtime_does_not_execute_submission_checks_and_reference_metadata_is_unrestricted + - test_complete_catalog_capabilities_origins_and_immutable_snapshots + AC-02: + - test_source_and_rule_changes_affect_revision_and_preserve_original + AC-03: + - test_invalid_catalog_never_publishes_partial_results diff --git a/test-e2e/cases/MPN-D1-001/test.py b/test-e2e/cases/MPN-D1-001/test.py new file mode 100644 index 0000000000..cd6e4e9b70 --- /dev/null +++ b/test-e2e/cases/MPN-D1-001/test.py @@ -0,0 +1,232 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""MPN-D1-001: complete catalog publication through real internal collaborators.""" + +import hashlib +import os +import shutil +import socket +import tomllib +from pathlib import Path + +import pytest +from nexent.core.model_service.profiles._models_dev.reader import SourceReadError +from nexent.core.model_service.profiles.resolver import ( + PARSER_REF, + load_source_catalog, +) + +from nexent.core.model_service.profiles.submission import check_source_catalog + +ROOT = Path(__file__).resolve().parents[3] / "sdk/nexent/core/model_service/profiles" +pytestmark = [pytest.mark.case_id("MPN-D1-001"), pytest.mark.stage("D1")] + + +@pytest.fixture +def no_external_access(monkeypatch): + def forbidden(*args, **kwargs): + raise AssertionError( + "Catalog loading crossed an external service/credential boundary" + ) + + monkeypatch.setattr(socket, "create_connection", forbidden) + monkeypatch.setattr(socket.socket, "connect", forbidden) + monkeypatch.setattr(os, "getenv", forbidden) + + +def copy_catalog(tmp_path, name): + root = tmp_path / name + shutil.copytree(ROOT, root) + return root + + +def modify_source(root, path, transform): + file = root / path + old = file.read_bytes() + updated = transform(old.decode("utf-8")).encode("utf-8") + assert updated != old + file.write_bytes(updated) + manifest = root / "manifest.toml" + old_hash = hashlib.sha256(old).hexdigest() + new_hash = hashlib.sha256(updated).hexdigest() + blocks = manifest.read_text().split("[[sources]]") + matches = 0 + for index, block in enumerate(blocks[1:], 1): + entry = tomllib.loads(block) + if entry["path"] == path: + assert entry["content_sha256"] == old_hash + blocks[index] = block.replace(old_hash, new_hash) + matches += 1 + assert matches == 1 + manifest.write_text("[[sources]]".join(blocks)) + + +def test_complete_catalog_capabilities_origins_and_immutable_snapshots( + no_external_access, +): + before = { + path.relative_to(ROOT).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in ROOT.rglob("*.toml") + } + first = check_source_catalog(ROOT) + second = load_source_catalog(ROOT) + assert (len(first.models), len(first.providers), len(first.provider_models)) == ( + 26, + 6, + 35, + ) + assert first.revision == second.revision + assert first.parser_ref == PARSER_REF + assert set(first.providers) == { + "alibaba-cn", + "volcengine", + "volcengine-coding-plan", + "siliconflow-cn", + "deepseek", + "tokenpony", + } + assert "alibaba-cn/MiniMax/MiniMax-M2.7" in first.provider_models + entry = first.provider_models["alibaba-cn/qwen3.8-max"] + assert entry.capabilities["context"]["combined_window"] == 1_000_000 + assert entry.capabilities["tools"]["status"] == "supported" + origin = entry.field_origins["/capabilities/tools/status"] + assert origin.mapping.source.file_path == "models/alibaba/qwen3.8-max.toml" + assert origin.mapping.source.source_path == "tool_call" + assert origin.mapping.rule_id == "provider_model.tool_call" + assert origin.mapping.rule_version == 1 + assert origin.normalization_ref == first.parser_ref + assert {reference.key for reference in entry.source_refs} == { + "alibaba/qwen3.8-max", + "alibaba-cn/qwen3.8-max", + "alibaba-cn", + } + assert all( + reference.catalog_revision == first.revision for reference in entry.source_refs + ) + plus = first.provider_models["alibaba-cn/qwen3.5-plus"] + budget = plus.capabilities["reasoning"]["controls"][1] + assert budget["min"] is None and budget["max"] == 81920 + assert ( + first.provider_models["alibaba-cn/glm-5.1"].capabilities["tools"]["status"] + == "supported" + ) + assert ( + first.provider_models["volcengine/deepseek-v4-flash-ga-260731"].capabilities[ + "reasoning" + ]["status"] + == "supported" + ) + assert ( + first.provider_models["volcengine-coding-plan/minimax-m3"].capabilities[ + "context" + ]["combined_window"] + == 1048576 + ) + with pytest.raises(TypeError): + first.models["new"] = entry + with pytest.raises(TypeError): + plus.capabilities["reasoning"]["controls"][0]["values"][0] = False + after = { + path.relative_to(ROOT).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in ROOT.rglob("*.toml") + } + assert before == after + + +def test_source_and_rule_changes_affect_revision_and_preserve_original( + tmp_path, no_external_access, monkeypatch +): + original = load_source_catalog(ROOT) + changed_source = copy_catalog(tmp_path, "source") + modify_source( + changed_source, + "models/alibaba/qwen3.8-max.toml", + lambda text: text.replace("tool_call = true", "tool_call = false", 1), + ) + changed = load_source_catalog(changed_source) + key = "alibaba-cn/qwen3.8-max" + assert changed.revision != original.revision + assert ( + changed.provider_models[key].profile_revision + != original.provider_models[key].profile_revision + ) + assert changed.provider_models[key].capabilities["tools"]["status"] == "unsupported" + assert original.provider_models[key].capabilities["tools"]["status"] == "supported" + assert ( + changed.provider_models["alibaba-cn/qwen3.5-plus"].profile_revision + == original.provider_models["alibaba-cn/qwen3.5-plus"].profile_revision + ) + from dataclasses import replace + from nexent.core.model_service.profiles import resolver + with monkeypatch.context() as patch: + patch.setattr(resolver, "SOURCE_MAPPING", replace(resolver.SOURCE_MAPPING, mapping_version=2)) + remapped = load_source_catalog(ROOT) + assert remapped.revision != original.revision + assert remapped.provider_models[key].profile_revision != original.provider_models[key].profile_revision + assert remapped.mapping_version == 2 + assert load_source_catalog(ROOT).revision == remapped.revision + assert original.mapping_version == 1 + assert load_source_catalog(ROOT).revision == original.revision + + +@pytest.mark.parametrize( + "variant,category,source_path", + [ + ("unhandled", "SOURCE_FIELD_UNHANDLED", "reasoning_effort"), + ("base", "SOURCE_BASE_MODEL_MISSING", "base_model"), + ("type", "SOURCE_VALUE_INVALID", "tool_call"), + ("upstream", "SOURCE_SCHEMA_UNSUPPORTED", "upstream_revision"), + ], +) +def test_invalid_catalog_never_publishes_partial_results( + tmp_path, no_external_access, variant, category, source_path +): + original = load_source_catalog(ROOT) + root = copy_catalog(tmp_path, variant) + if variant == "unhandled": + modify_source( + root, + "models/alibaba/qwen3.8-max.toml", + lambda text: 'reasoning_effort = "sensitive-marker"\n' + text, + ) + elif variant == "base": + modify_source( + root, + "providers/alibaba-cn/models/qwen3.8-max.toml", + lambda text: text.replace("alibaba/qwen3.8-max", "missing/model", 1), + ) + elif variant == "upstream": + path = root / "manifest.toml" + path.write_text(path.read_text().replace(original.upstream_revision, "a" * 40)) + else: + modify_source(root, "models/alibaba/qwen3.8-max.toml", + lambda text: text.replace("tool_call = true", 'tool_call = "invalid"', 1)) + with pytest.raises(SourceReadError) as error: + check_source_catalog(root) + assert error.value.category == category + assert error.value.source_path == source_path + assert "sensitive-marker" not in str(error.value) + assert str(tmp_path) not in str(error.value) + assert ( + original.provider_models["alibaba-cn/qwen3.8-max"].capabilities["tools"][ + "status" + ] + == "supported" + ) + assert load_source_catalog(ROOT).revision == original.revision + + + +def test_runtime_does_not_execute_submission_checks_and_reference_metadata_is_unrestricted(tmp_path, monkeypatch): + from nexent.core.model_service.profiles import submission + root = copy_catalog(tmp_path, "reference") + modify_source(root, "models/alibaba/qwen3.8-max.toml", + lambda text: text + '\n[[benchmarks]]\nfuture_score = "free-form"\n') + checked = check_source_catalog(root) + def forbidden(*args, **kwargs): + raise AssertionError("Runtime invoked submission schema validation") + monkeypatch.setattr(submission, "validate_source_document", forbidden) + runtime = load_source_catalog(root) + assert runtime.revision == checked.revision + assert runtime.models["alibaba/qwen3.8-max"].reference_metadata["benchmarks"][-1]["future_score"] == "free-form" + assert runtime.models["alibaba/qwen3.8-max"].capabilities == load_source_catalog(ROOT).models["alibaba/qwen3.8-max"].capabilities diff --git a/test-e2e/cases/MPR-D1-001/case.yaml b/test-e2e/cases/MPR-D1-001/case.yaml new file mode 100644 index 0000000000..4f5d8524b4 --- /dev/null +++ b/test-e2e/cases/MPR-D1-001/case.yaml @@ -0,0 +1,74 @@ +schema_version: '1.0' +stage: D1 +module: MODEL-SERVICE +case: + case_id: MPR-D1-001 + feature_id: SDK-MODEL-OUTLET-RESOLUTION + business_rule_ids: + - SDK-MODEL-OUTLET-RESOLUTION-RULE-01 + - SDK-MODEL-OUTLET-RESOLUTION-RULE-02 + - SDK-MODEL-OUTLET-RESOLUTION-RULE-03 + title: Compose and restore source-independent complete outlets + type: SDK-UT + priority: P1 + status: active + automation: automated + objective: Prove real source/policy/construction/codec collaboration with only implementation availability controlled. + preconditions: + - Shipped sources available; controlled registry does not claim production adapter availability. + test_data: + configuration: Shipped Alibaba Qwen and custom endpoint fixtures; no real credentials. + steps: + - order: 1 + action: Read shipped TOML/supplements, compose Alibaba Qwen, construct and encode/restore a complete outlet. + expected: Exact identity, parameters, mapping and source/override provenance survive. + - order: 2 + action: Construct custom provider with an explicitly chosen model atom, encode its row, modify copied source and disable + catalog access before loading. + expected: User request identity stays distinct from atom identity; saved snapshot is unchanged without default reapplication + or external calls. + - order: 3 + action: Try stale catalog revision, inconsistent policy selection, unavailable binding and protected extra request paths. + expected: Invalid combinations fail before publishing storage output; diagnostics exclude submitted private values. + expected_results: &id001 + - Exact identity, parameters, mapping and source/override provenance survive. + - User request identity stays distinct from atom identity; saved snapshot is unchanged without default reapplication or + external calls. + - Invalid combinations fail before publishing storage output; diagnostics exclude submitted private values. + forbidden_side_effects: + - No network/provider execution, database write or shipped source mutation. + - No raw credentials/private parameter values in diagnostics or source access during saved-row loading. + acceptance: + - id: AC-01 + description: Exact identity, parameters, mapping and source/override provenance survive. + steps: + - 1 + expected_results: + - 1 + forbidden_side_effects: + - 1 + - 2 + - id: AC-02 + description: User request identity stays distinct from atom identity; saved snapshot is unchanged without default reapplication + or external calls. + steps: + - 2 + expected_results: + - 2 + forbidden_side_effects: + - 1 + - 2 + - id: AC-03 + description: Invalid combinations fail before publishing storage output; diagnostics exclude submitted private values. + steps: + - 3 + expected_results: + - 3 + forbidden_side_effects: + - 1 + - 2 + unit_boundary: Real source reader, supplement loader, composer, outlet constructor and row codecs; only implementation availability + is controlled. + inputs: + - Shipped Alibaba Qwen policy, explicit custom model/provider selection, copied source changes and invalid combinations. + assertions: *id001 diff --git a/test-e2e/cases/MPR-D1-001/execution.yaml b/test-e2e/cases/MPR-D1-001/execution.yaml new file mode 100644 index 0000000000..e83a8ba3ff --- /dev/null +++ b/test-e2e/cases/MPR-D1-001/execution.yaml @@ -0,0 +1,16 @@ +schema_version: '1.0' +case_id: MPR-D1-001 +implementations: +- framework: pytest + file: test.py + selector: MPR-D1-001 + profiles: [mock] +acceptance_bindings: + AC-01: [test_shipped_policy_constructs_and_restores_complete_outlet] + AC-02: [test_custom_selection_survives_source_changes_without_reloading] + AC-03: [test_invalid_collaborating_inputs_do_not_publish_a_snapshot] +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/MPR-D1-001/test.py b/test-e2e/cases/MPR-D1-001/test.py new file mode 100644 index 0000000000..8ccc67249e --- /dev/null +++ b/test-e2e/cases/MPR-D1-001/test.py @@ -0,0 +1,165 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Actual source-to-outlet collaboration, independent of traditional unit tests.""" + +import hashlib +import shutil +import socket +from dataclasses import replace +from pathlib import Path + +import pytest +from nexent.core.model_service.profiles.composition import compose_provider_model_policy +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.outlet_resolution import ( + OutletResolutionError, + construct_resolved_model_profile, +) +from nexent.core.model_service.profiles.outlet_storage import ( + decode_resolved_model_profile, + encode_resolved_model_profile, +) +from nexent.core.model_service.profiles.resolved_profile import ( + ConnectionRules, + ContextConfiguration, + OutletConnection, +) +from nexent.core.model_service.profiles.resolver import load_profile_catalog + +ROOT = Path(__file__).resolve().parents[3] / "sdk/nexent/core/model_service/profiles" +REFERENCE = VersionedReference(ref="component_available_v1", version=1) +REGISTRY = {key: [REFERENCE] for key in ("protocol", "auth", "adapter", "code_converter", "native_converter")} +REGISTRY["parameter_transform"] = [VersionedReference(ref="identity_v1", version=1)] +pytestmark = [pytest.mark.case_id("MPR-D1-001"), pytest.mark.stage("D1")] + + +def source_hashes(root): + return {str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() for path in root.rglob("*.toml")} + + +@pytest.fixture(autouse=True) +def external_boundaries_and_source_ownership(monkeypatch): + before = source_hashes(ROOT) + def forbidden(*args, **kwargs): + raise AssertionError("Outlet construction crossed a network/database boundary") + monkeypatch.setattr(socket.socket, "connect", forbidden) + monkeypatch.setattr(socket, "create_connection", forbidden) + yield + assert source_hashes(ROOT) == before + + +def policy(catalog, *, provider="alibaba-cn", model="qwen3.5-plus", atom=None, values=None): + return compose_provider_model_policy( + catalog, provider_key=provider, request_model_id=model, model_profile_key=atom, + base_bindings={key: REFERENCE.model_dump() for key in REGISTRY if key != "parameter_transform"}, + base_instruction=VersionedReference(ref="system_only_v1", version=1), + registered_references=REGISTRY, submitted_values={} if values is None else values, + ) + + +def construct(catalog, draft, **changes): + arguments = { + "tenant_id": "component-tenant", "display_name": "source outlet", "model_type": "llm", + "provider_id": "custom-or-known-provider", + "connection": OutletConnection(base_url="https://fixture.invalid/v1", ssl_verify=True, + timeout_seconds=120, concurrency_limit=2, + credential_ref={"kind": "model_record", "model_id": None}), + "connection_rules": ConnectionRules(credential_kind="model_record", relative_path="/chat/completions", + method="POST", headers={"Accept": "application/json"}), + "context_configuration": ContextConfiguration(output_reserve_tokens=None, tokenizer_family=None, + capacity_source="profile", capability_profile_version=None), + "registered_references": REGISTRY, + } + arguments.update(changes) + return construct_resolved_model_profile(draft, catalog, **arguments) + + +def saved_row(snapshot): + row = encode_resolved_model_profile(snapshot, registered_references=REGISTRY) + row["model_id"] = 7 + return row + + +def expected_saved(snapshot): + document = snapshot.model_dump(mode="json") + document["outlet_id"] = 7 + document["connection"]["credential_ref"]["model_id"] = 7 + return document + + +def test_shipped_policy_constructs_and_restores_complete_outlet(): + catalog = load_profile_catalog(ROOT) + snapshot = construct(catalog, policy(catalog, values={"enable_thinking": True, "reasoning_budget_tokens": 0})) + row = saved_row(snapshot) + restored = decode_resolved_model_profile(row, registered_references=REGISTRY) + assert restored.model_dump(mode="json") == expected_saved(snapshot) + assert restored.identity.request_model_id == "qwen3.5-plus" + assert restored.parameter_values == {"enable_thinking": True, "reasoning_budget_tokens": 0} + assert row["enable_thinking"] is True and row["reasoning_budget_tokens"] == 0 + assert row["extra_params"]["parameter_values"] == {} + assert "api_key" not in row + assert restored.bindings.role_mapping == VersionedReference(ref="system_only_v1", version=1) + assert restored.instruction_mapping.supported_roles == ["system", "user", "assistant", "tool"] + refs = restored.provenance.atomic_refs + assert refs.model.key == "alibaba/qwen3.5-plus" + assert refs.provider.key == "alibaba-cn" + assert refs.provider_model.key == "alibaba-cn/qwen3.5-plus" + for reference in (refs.model, refs.provider, refs.provider_model): + assert hashlib.sha256((ROOT / reference.file_path).read_bytes()).hexdigest() == reference.content_sha256 + assert any(item.path == "/reasoning/controls" for item in restored.provenance.overrides) + assert any(item.target_path == "/capabilities/context/combined_window" for item in restored.provenance.field_sources) + assert restored.provenance.interpretation_versions.composer.version == 2 + + +def test_custom_selection_survives_source_changes_without_reloading(tmp_path, monkeypatch): + copied = tmp_path / "profiles" + shutil.copytree(ROOT, copied) + catalog = load_profile_catalog(copied) + draft = policy(catalog, provider=None, model="tenant/Exact-Request-Name", atom="alibaba/qwen3.5-plus") + snapshot = construct(catalog, draft) + row = saved_row(snapshot) + original_reference = snapshot.provenance.atomic_refs.model + source = copied / original_reference.file_path + source.write_text('invalid = [', encoding="utf-8") + assert hashlib.sha256(source.read_bytes()).hexdigest() != original_reference.content_sha256 + def forbidden(*args, **kwargs): + raise AssertionError("Saved outlet tried loading catalog or source files") + monkeypatch.setattr("nexent.core.model_service.profiles.resolver.load_profile_catalog", forbidden) + monkeypatch.setattr("nexent.core.model_service.profiles.resolver.load_source_catalog", forbidden) + # Source access is forbidden at the filesystem boundary as well as the public loader. + read_bytes, read_text = Path.read_bytes, Path.read_text + def guarded_bytes(path, *args, **kwargs): + if path.is_relative_to(copied): + return forbidden() + return read_bytes(path, *args, **kwargs) + def guarded_text(path, *args, **kwargs): + if path.is_relative_to(copied): + return forbidden() + return read_text(path, *args, **kwargs) + monkeypatch.setattr(Path, "read_bytes", guarded_bytes) + monkeypatch.setattr(Path, "read_text", guarded_text) + restored = decode_resolved_model_profile(row, registered_references=REGISTRY) + assert restored.model_dump(mode="json") == expected_saved(snapshot) + assert restored.identity.request_model_id == "tenant/Exact-Request-Name" + assert restored.model_profile_key == "alibaba/qwen3.5-plus" + assert restored.provenance.atomic_refs.provider is None + assert restored.provenance.atomic_refs.provider_model is None + assert restored.parameter_values == {} + + +def test_invalid_collaborating_inputs_do_not_publish_a_snapshot(): + catalog = load_profile_catalog(ROOT) + draft = policy(catalog) + variants = [ + (replace(catalog, revision="updated-catalog"), draft, {}, "PROFILE_CATALOG_REVISION_MISMATCH"), + (catalog, replace(draft, model_profile_key=None), {}, "PROFILE_SELECTION_INVALID"), + (catalog, draft, {"registered_references": {}}, "PROFILE_RESOLUTION_INVALID"), + (catalog, draft, {"protected_paths": ["/model"], + "extra_api_parameters": [{"path": "/model", "value": "private-parameter-value"}]}, + "PROFILE_RESOLUTION_INVALID"), + ] + for selected_catalog, selected_policy, arguments, expected_category in variants: + with pytest.raises(OutletResolutionError) as failure: + construct(selected_catalog, selected_policy, **arguments) + assert failure.value.category == expected_category + assert "private-parameter-value" not in str(failure.value) diff --git a/test-e2e/cases/MST-D3-001/case.yaml b/test-e2e/cases/MST-D3-001/case.yaml new file mode 100644 index 0000000000..add224cc9c --- /dev/null +++ b/test-e2e/cases/MST-D3-001/case.yaml @@ -0,0 +1,95 @@ +schema_version: '1.0' +stage: D3 +module: MODEL-SERVICE +case: + case_id: MST-D3-001 + feature_id: SDK-MODEL-TRANSPORT + business_rule_ids: + - SDK-MODEL-TRANSPORT-RULE-01 + - SDK-MODEL-TRANSPORT-RULE-02 + - SDK-MODEL-TRANSPORT-RULE-03 + title: Send model requests through actual HTTP and decode complete streams + type: INTEGRATION + priority: P1 + status: active + automation: automated + objective: Execute common sender and adapter through an isolated real local HTTP + peer. + integration_boundary: + - Actual local HTTP server, HTTPX sync/async transport, SSE decoder and OpenAI-compatible + adapter; controlled response fixtures, no hosted inference. + observations: + - Peer request captures, ordered decoded messages/events, request counts and response + closure. + cleanup: + - Close clients, stop the run-owned HTTP server and remove run-owned temporary artifacts. + preconditions: + - Product transport and adapter installed at locked revision + - Loopback HTTP server available to this run + test_data: + configuration: Non-secret dummy authentication and controlled JSON/SSE/error responses. + steps: + - order: 1 + action: Construct a model through the actual NexentAgent factory with a supplied + resolved outlet, then send normal and streaming requests through sync and async APIs; capture + requests at the HTTP peer, including the resolved gateway asynchronous stream + and authorized resource conversion after restoring a saved row snapshot. + expected: Exact root/path/body/auth and one physical request; content, reasoning, + tool arguments and usage are preserved. + - order: 2 + action: Serve multiline and fragmented SSE frames, including final tool calls + and usage. + expected: Ordered events and complete final message agree; partial tool-call fragments + never become completed calls. + - order: 3 + action: Serve transient errors before visible output, context overflow followed by + rebuilt messages, authentication errors, malformed/truncated streams and stop a stream early. + expected: Only ModelService may retry a transient pre-output failure within its + declared attempt budget; authentication, visible partial output and cancellation + never replay. Errors are sanitized and owned HTTP resources and shared permits + are released. Context recovery retains frozen credentials and changes only messages + within the same physical attempt budget. + expected_results: + - Actual HTTP request matches the prepared outlet configuration in sync and async + paths. + - Complete SSE output preserves message kinds, calls and usage with stable ordering. + - Retry counts obey the explicit ModelService budget before visible output; failure, + partial output and cancellation release resources and never hide a replay or private + provider message. + forbidden_side_effects: + - No hosted provider, production connections, tool execution or real credentials. + - No retry outside the declared pre-output budget, AgentMode change or secret content + in diagnostics. + execution: + profiles: + - name: mock + required_services: [] + acceptance: + - id: AC-01 + description: Exact request reaches the real HTTP peer. + steps: + - 1 + expected_results: + - 1 + forbidden_side_effects: + - 1 + - 2 + - id: AC-02 + description: Ordered complete stream reconstructs response kinds without premature + execution. + steps: + - 2 + expected_results: + - 2 + forbidden_side_effects: + - 1 + - 2 + - id: AC-03 + description: Failure and cancellation are explicit, sanitized and do not replay. + steps: + - 3 + expected_results: + - 3 + forbidden_side_effects: + - 1 + - 2 diff --git a/test-e2e/cases/MST-D3-001/empty_response_cases.py b/test-e2e/cases/MST-D3-001/empty_response_cases.py new file mode 100644 index 0000000000..62c5695ccd --- /dev/null +++ b/test-e2e/cases/MST-D3-001/empty_response_cases.py @@ -0,0 +1,215 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Legacy empty-response policy over the same real HTTP peer and sender.""" + +import json +from threading import Event +from types import SimpleNamespace + +import pytest +from nexent.core.agent_runtime_support.contracts import ( + ResourceItem, + RuntimeResourceView, +) +from nexent.core.agents.agent_model import ModelConfig +from nexent.core.agents.nexent_agent import NexentAgent +from nexent.core.concurrency.cancellation import RunCancellationScope +from nexent.core.context_runtime.contracts import ContextEvidence, FinalContext +from nexent.core.gateway.modality.llm.llm_adapter import LLMRequest +from nexent.core.gateway.modality.llm.openai import OpenAILLMAdapter +from nexent.core.gateway.model_context import LLMContext +from nexent.core.model_errors import ModelErrorCode, ModelInvocationTerminalError +from nexent.core.model_service.compatibility.legacy_chat_model import LegacyChatModel +from nexent.core.model_service.contracts import ModelMessage, ToolDefinition +from nexent.core.model_service.profiles.outlet_storage import ( + decode_resolved_model_profile, + encode_resolved_model_profile, +) +from nexent.core.model_service.registry import model_service_references +from nexent.core.model_service.resource_conversion import ResourceConverterRegistry +from nexent.core.utils.observer import MessageObserver + + +@pytest.mark.parametrize('scenario,retry,calls,succeeds', [ + ('legacy_empty_fail', False, 1, False), + ('legacy_empty_budget', True, 3, True), + ('legacy_reasoning_empty', True, 1, False), +]) +def test_legacy_empty_policy(peer, request, scenario, retry, calls, succeeds): + module = request.module + service, _request, profile, limiter, journal, credentials = module.setup(peer, scenario) + observer = MessageObserver() + model = LegacyChatModel(profile, service, observer=observer) + messages = [{'role': 'user', 'content': 'hello'}] + if succeeds: + response = model.generate(messages, _retry_empty_response=retry) + assert response.content == '中间正文' + assert response.model_attempt_number == 3 + assert response.tool_calls[0].id == 'call-peer' + else: + with pytest.raises(ModelInvocationTerminalError) as caught: + model.generate(messages, _retry_empty_response=retry) + assert caught.value.error_code == ModelErrorCode.EMPTY_RESPONSE_EXHAUSTED + actual = module.records(journal, scenario=scenario) + assert len(actual) == calls and len(credentials) == 1 + assert not limiter._entries + assert all('_retry_empty_response' not in value['body'] for value in actual) + assert all(value['body'] == actual[0]['body'] for value in actual) + + events = [json.loads(item) for item in observer.message_query] + phases = [event['phase'] for event in events if 'phase' in event] + assert phases == (['begin', 'rollback', 'begin', 'commit'] if succeeds else ['begin', 'rollback']) + + diagnostics = model.last_response_diagnostics + assert diagnostics['finish_reason'] == ('tool_calls' if succeeds else 'stop') + assert diagnostics['input_tokens'] == 10 + assert diagnostics['output_tokens'] == (5 if succeeds else 0) + assert diagnostics['content_char_count'] == (len('中间正文') if succeeds else 0) + assert diagnostics['reasoning_char_count'] == (len('consider') if succeeds else len('thought') if scenario == 'legacy_reasoning_empty' else 0) + assert model.last_prompt_cache_usage is None + + +def test_legacy_context_recovery_shares_real_http_budget(peer, request): + module = request.module + scenario = 'legacy_context_budget' + service, _, profile, limiter, journal, credentials = module.setup(peer, scenario) + model = LegacyChatModel(profile, service) + evidence = ContextEvidence(compression_attempted=True) + rebuilt = [{'role': 'user', 'content': 'short context'}] + callbacks = [] + + def rebuild(): + assert not limiter._entries + assert len(module.records(journal, scenario=scenario)) == 2 + callbacks.append(True) + return FinalContext(messages=rebuilt, evidence=evidence) + + response = model.generate([{'role': 'user', 'content': 'long context'}], context_rebuild=rebuild) + actual = module.records(journal, scenario=scenario) + assert response.content == '中间正文' and response.model_attempt_number == 3 + assert len(actual) == 3 and len(credentials) == len(callbacks) == 1 + assert actual[0]['body'] == actual[1]['body'] + assert actual[2]['body']['messages'] == rebuilt + assert {key: value for key, value in actual[2]['body'].items() if key != 'messages'} == { + key: value for key, value in actual[0]['body'].items() if key != 'messages' + } + assert all('context_rebuild' not in item['body'] for item in actual) + assert model.last_context_evidence is evidence and not limiter._entries + phases = [json.loads(item)['phase'] for item in model.observer.message_query if '"phase"' in item] + assert phases == ['begin', 'commit'] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('scenario,available', [('probe', True), ('auth', False)]) +async def test_legacy_connectivity_uses_real_service_defaults(peer, request, scenario, available): + module = request.module + service, _, profile, limiter, journal, credentials = module.setup(peer, scenario) + model = LegacyChatModel(profile, service) + assert await model.check_connectivity() is available + actual = module.records(journal, scenario=scenario) + assert len(actual) == len(credentials) == 1 and not limiter._entries + body = actual[0]['body'] + assert body['messages'] == [{'role': 'user', 'content': 'Hello'}] + assert body['model'] == profile.identity.request_model_id and body['stream'] is False + assert body['enable_thinking'] is False + assert 'max_tokens' not in body and 'tools' not in body + + +def test_actual_agent_factory_uses_resolved_outlet(peer, request): + module = request.module + scenario = 'agent_factory' + _, _, profile, _, journal, _ = module.setup(peer, scenario) + agent = NexentAgent.__new__(NexentAgent) + agent.tenant_id = profile.identity.tenant_id + agent.stop_event = Event() + agent.cancellation_scope = RunCancellationScope(agent.stop_event) + agent.observer = MessageObserver() + agent.model_config_list = [ModelConfig( + cite_name='chosen', model_name=profile.identity.request_model_id, + url=profile.connection.base_url, api_key='dummy-loopback', resolved_profile=profile, + temperature=0.9, + )] + model = agent.create_model('chosen') + response = model.generate([{'role': 'user', 'content': 'hello'}], stop_sequences=['Observation:', 'Calling tools:']) + assert isinstance(model, LegacyChatModel) and model.cancellation_scope is agent.cancellation_scope + assert response.content == '中间正文' and response.token_usage.input_tokens == 10 + assert response.tool_calls[0].id == 'call-peer' + actual = module.records(journal, scenario=scenario) + assert len(actual) == 1 and actual[0]['authorization'] == 'Bearer dummy-loopback' + assert actual[0]['body']['enable_thinking'] is False + assert actual[0]['body'].get('temperature') == profile.parameter_values.get('temperature') + assert actual[0]['body']['stop'] == ['Observation:', 'Calling tools:'] + assert 'stop' not in profile.parameter_values + + +@pytest.mark.asyncio +async def test_resolved_gateway_stream_uses_real_async_transport(peer, request): + module = request.module + _, _, profile, _, journal, _ = module.setup(peer, "stream") + scope = RunCancellationScope() + adapter = OpenAILLMAdapter(LLMContext( + model_name=profile.identity.request_model_id, base_url=profile.connection.base_url, + api_key="dummy-loopback", modality="llm", factory="openai", + tenant_id=profile.identity.tenant_id, resolved_profile=profile, cancellation_scope=scope, + )) + stream = await adapter.stream(LLMRequest(messages=[{"role": "user", "content": "hello"}])) + chunks = [chunk async for chunk in stream] + assert "".join(chunk.content or "" for chunk in chunks) == "中间正文" + calls = [call for chunk in chunks for call in chunk.tool_calls or []] + assert "".join(call.function.arguments for call in calls) == '{"n":0}' + usages = [chunk.token_usage for chunk in chunks if chunk.token_usage is not None] + assert len(usages) == 1 and usages[0].input_tokens == 10 and usages[0].output_tokens == 5 + actual = module.records(journal, scenario="stream") + assert len(actual) == 1 and actual[0]["authorization"] == "Bearer dummy-loopback" + assert actual[0]["body"]["stream_options"] == {"include_usage": True} + assert actual[0]["body"]["enable_thinking"] is False + assert adapter._model.cancellation_scope is scope + + +@pytest.mark.parametrize("native,status", [(False, "supported"), (True, "supported"), (True, "unknown")]) +def test_resource_conversion_and_saved_snapshot_reach_real_peer(peer, request, native, status): + module = request.module + scenario = "resource_conversion" + selected = {} + selections = [] + + def read_outlet(model_id, context): + selections.append((model_id, context.tenant_id)) + return selected[model_id] + + service, _, profile, limiter, journal, credentials = module.setup(peer, scenario, outlet_reader=read_outlet) + values = profile.model_dump() + values["outlet_id"] = 42 + values["connection"]["credential_ref"]["model_id"] = 42 + values["capabilities"]["tools"]["status"] = status + profile = profile.__class__.model_validate(values) + registry = model_service_references() + row = encode_resolved_model_profile(profile, registered_references=registry) + selected[42] = decode_resolved_model_profile(row, registered_references=registry) + frozen = service.bind_outlet(42, SimpleNamespace(tenant_id="loopback-tenant")) + assert selections == [(42, "loopback-tenant")] + assert frozen is not selected[42] and not credentials and not limiter._entries + assert not module.records(journal, scenario=scenario) + resources = RuntimeResourceView( + instructions=[ResourceItem(source="platform_instruction", order=1, + message=ModelMessage(semantic_role="platform_instruction", content_parts=[{"type": "text", "text": "instructions"}]))], + references=[ResourceItem(source="knowledge", order=2, + message=ModelMessage(semantic_role="reference_data", content_parts=[{"type": "text", "text": "selected knowledge"}]))], + current_user_input=ResourceItem(source="current_input", order=3, + message=ModelMessage(semantic_role="user_input", content_parts=[{"type": "text", "text": "hello"}])), + tool_definitions=[ToolDefinition(name="lookup", description="Find data", input_schema={"type": "object"})], + ) + reference = frozen.bindings.native_converter if native else frozen.bindings.code_converter + converted = ResourceConverterRegistry().resolve(reference).convert(resources, frozen) + result = service.invoke(converted.request, frozen) + assert result.content_parts == [{"type": "text", "text": "answer"}] + assert result.usage.input_tokens == 10 + actual = module.records(journal, scenario=scenario) + assert len(actual) == len(credentials) == 1 and not limiter._entries + body = actual[0]["body"] + assert body["messages"][0] == {"role": "system", "content": "instructions"} + assert body["messages"][1] == {"role": "user", "content": "selected knowledge"} + assert body["messages"][-1] == {"role": "user", "content": "hello"} + assert bool(body.get("tools")) == (native and status == "supported") + assert converted.tool_protocol == ("native" if native and status == "supported" else "text_block") + assert frozen == profile and row["native_tool_call_supported"] == (None if status == "unknown" else True) diff --git a/test-e2e/cases/MST-D3-001/execution.yaml b/test-e2e/cases/MST-D3-001/execution.yaml new file mode 100644 index 0000000000..029971bd27 --- /dev/null +++ b/test-e2e/cases/MST-D3-001/execution.yaml @@ -0,0 +1,18 @@ +schema_version: '1.0' +case_id: MST-D3-001 +timeout_seconds: 120 +implementations: +- framework: pytest + file: test.py + selector: MST-D3-001 + profiles: [mock] +acceptance_bindings: + # Auxiliary backend text flow preserves output and does not add an outer retry. + AC-01: [test_exact_request_reaches_real_peer, test_legacy_model_interface_uses_real_transport_and_retains_usage, test_legacy_connectivity_uses_real_service_defaults, test_actual_agent_factory_uses_resolved_outlet, test_resource_conversion_and_saved_snapshot_reach_real_peer, test_auxiliary_prompt_uses_saved_outlet_http_without_outer_retry, test_long_text_preparation_uses_saved_outlet_real_http] + AC-02: [test_fragmented_stream_retains_kinds_calls_and_usage, test_resolved_gateway_stream_uses_real_async_transport] + AC-03: [test_retry_error_and_partial_output_boundaries, test_cancellation_closes_blocked_real_response, test_sync_cancellation_reclaims_blocked_managed_worker, test_legacy_empty_policy, test_legacy_context_recovery_shares_real_http_budget, test_auxiliary_prompt_uses_saved_outlet_http_without_outer_retry] +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] diff --git a/test-e2e/cases/MST-D3-001/http_peer.py b/test-e2e/cases/MST-D3-001/http_peer.py new file mode 100644 index 0000000000..430482eeb8 --- /dev/null +++ b/test-e2e/cases/MST-D3-001/http_peer.py @@ -0,0 +1,192 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Run-owned loopback HTTP peer; never connects to a hosted service.""" + +import json +import socket +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + + +def frame(delta=None, finish=None, **extra): + document = { + "id": "peer-response", + "model": "qwen3.5-plus", + "choices": [ + {"index": 0, "delta": delta or {}, "finish_reason": finish}, + ], + } | extra + # Multiple data lines form one JSON document, exercising actual SSE framing. + text = json.dumps(document, ensure_ascii=False) + return ( + "data: " + text.replace(', "choices":', ',\ndata: "choices":') + "\r\n\r\n" + ).encode() + + +def stream_frames(): + return [ + b"\xef\xbb\xbf: comment\r\n\r\n", + frame({"role": "assistant", "reasoning_content": "consider"}), + frame({"content": "中间正文"}), + frame( + { + "tool_calls": [ + { + "index": 0, + "id": "call-peer", + "type": "function", + "function": {"name": "test_tool", "arguments": '{"n":'}, + } + ] + } + ), + frame({"tool_calls": [{"index": 0, "function": {"arguments": "0}"}}]}), + frame(finish="tool_calls"), + b'data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}\n\n', + b"data: [DONE]\n\n", + ] + + +class Peer(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def record(self, value): + with self.server.journal.open("a") as handle: + handle.write(json.dumps(value, ensure_ascii=False) + "\n") + + def json_response(self, status, body): + payload = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(payload) + + def chunk(self, payload): + # Actual chunked HTTP boundaries split UTF-8 and SSE newlines arbitrarily. + for index in range(0, len(payload), 3): + fragment = payload[index : index + 3] + self.wfile.write(f"{len(fragment):x}\r\n".encode() + fragment + b"\r\n") + self.wfile.flush() + + def do_POST(self): + scenario = parse_qs(urlsplit(self.path).query)["scenario"][0] + self.server.counts[scenario] = self.server.counts.get(scenario, 0) + 1 + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + self.record( + { + "kind": "request", + "path": self.path, + "scenario": scenario, + "attempt": self.server.counts[scenario], + "body": body, + "authorization": self.headers.get("Authorization"), + } + ) + self.close_connection = True + disconnected = False + try: + if scenario == "auth": + return self.json_response( + 401, {"error": {"message": "private-provider-detail"}} + ) + if ( + scenario == "exhaust" + or scenario == "legacy_empty_budget" and self.server.counts[scenario] == 1 + or scenario == "legacy_context_budget" and self.server.counts[scenario] == 1 + or scenario == "retry" + and self.server.counts[scenario] == 1 + ): + return self.json_response( + 503, {"error": {"message": "private-provider-detail"}} + ) + if scenario == "legacy_context_budget" and self.server.counts[scenario] == 2: + return self.json_response(400, {"error": { + "code": "context_length_exceeded", "message": "private-provider-detail", + }}) + if scenario == "disconnect" and self.server.counts[scenario] == 1: + self.connection.shutdown(socket.SHUT_RDWR) + return + if scenario == "header_stall": + self.record({"kind": "waiting", "scenario": scenario}) + self.connection.settimeout(3) + disconnected = self.connection.recv(1) == b"" + return + if not body["stream"]: + return self.json_response( + 200, + { + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "answer", + "reasoning_content": "consider", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + }, + }, + ) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Transfer-Encoding", "chunked") + self.send_header("Connection", "close") + self.end_headers() + if scenario == "stall": + self.chunk(b": waiting\n\n") + self.record({"kind": "waiting", "scenario": scenario}) + self.connection.settimeout(3) + disconnected = self.connection.recv(1) == b"" + return + if scenario in {"legacy_empty_fail", "legacy_reasoning_empty"} or ( + scenario == "legacy_empty_budget" and self.server.counts[scenario] == 2 + ): + if scenario == "legacy_reasoning_empty": + self.chunk(frame({"reasoning_content": "thought"})) + self.chunk(frame(finish="stop")) + self.chunk(b'data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":0}}\n\n') + self.chunk(b"data: [DONE]\n\n") + elif scenario == "malformed": + self.chunk(b"data: invalid-json\n\n") + elif scenario in {"truncate", "visible_disconnect"}: + self.chunk(frame({"content": "partial"})) + if scenario == "visible_disconnect": + self.connection.shutdown(socket.SHUT_RDWR) + return + else: + for payload in stream_frames(): + self.chunk(payload) + self.wfile.write(b"0\r\n\r\n") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + disconnected = True + except TimeoutError: + self.record({"kind": "peer_timeout", "scenario": scenario}) + finally: + self.record( + {"kind": "finished", "scenario": scenario, "disconnected": disconnected} + ) + + +if __name__ == "__main__": + server = HTTPServer(("127.0.0.1", 0), Peer) + server.journal = Path(sys.argv[2]) + server.counts = {} + Path(sys.argv[1]).write_text(str(server.server_port)) + try: + server.serve_forever(poll_interval=0.05) + finally: + server.server_close() diff --git a/test-e2e/cases/MST-D3-001/prompt_generation_cases.py b/test-e2e/cases/MST-D3-001/prompt_generation_cases.py new file mode 100644 index 0000000000..d151b87b3d --- /dev/null +++ b/test-e2e/cases/MST-D3-001/prompt_generation_cases.py @@ -0,0 +1,144 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Backend auxiliary prompt flow over the actual saved-outlet gateway and HTTP sender.""" + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +import pytest +from consts.exceptions import AppException +from nexent.core.gateway.modality.llm.openai import OpenAILLMAdapter +from nexent.core.gateway.model_context import LLMContext +from nexent.core.model_service.profiles.outlet_storage import ( + encode_resolved_model_profile, +) +from nexent.core.model_service.registry import model_service_references + + +@pytest.mark.parametrize("scenario,expected", [("stream", "中间正文"), ("auth", ""), ("visible_disconnect", "partial")]) +def test_auxiliary_prompt_uses_saved_outlet_http_without_outer_retry(peer, request, monkeypatch, scenario, expected): + main = request.module + _, _, profile, _, journal, _ = main.setup(peer, scenario) + row = encode_resolved_model_profile(profile, registered_references=model_service_references()) + gateway = OpenAILLMAdapter(LLMContext( + model_name=profile.identity.request_model_id, base_url=profile.connection.base_url, + api_key="dummy-loopback", modality="llm", factory="openai", + tenant_id=profile.identity.tenant_id, resolved_profile=profile, + )) + + def read(model_id, tenant_id): + assert model_id == 42 and tenant_id == profile.identity.tenant_id + return row + + def resolve(record, tenant_id, **kwargs): + assert record is row and tenant_id == profile.identity.tenant_id + assert "temperature" not in kwargs and "top_p" not in kwargs + return gateway + + for name, values in { + "consts.const": {"MESSAGE_ROLE": {"SYSTEM": "system", "USER": "user"}, + "THINK_START_PATTERN": "", "THINK_END_PATTERN": ""}, + "database.model_management_db": {"get_model_by_model_id": read}, + "management.services.model.adapter": {"get_llm_adapter_from_config": resolve}, + }.items(): + module = ModuleType(name) + module.__dict__.update(values) + monkeypatch.setitem(sys.modules, name, module) + path = Path(__file__).resolve().parents[3] / "backend/management/services/model/invocation.py" + spec = importlib.util.spec_from_file_location("_prompt_flow_d3", path) + prompt = importlib.util.module_from_spec(spec) + spec.loader.exec_module(prompt) + callbacks = [] + if scenario == "stream": + assert prompt.call_llm_for_system_prompt(42, "hello", "system", callbacks.append, + profile.identity.tenant_id) == expected + else: + with pytest.raises(AppException): + prompt.call_llm_for_system_prompt(42, "hello", "system", callbacks.append, profile.identity.tenant_id) + assert (callbacks[-1] if callbacks else "") == expected + actual = main.records(journal, scenario=scenario) + assert len(actual) == 1 + assert actual[0]["body"]["messages"] == [ + {"role": "system", "content": "system"}, {"role": "user", "content": "hello"}, + ] + assert actual[0]["body"]["enable_thinking"] is False + assert gateway._model.last_attempt_number == 1 + + +def test_long_text_preparation_uses_saved_outlet_real_http(peer, request, monkeypatch): + from nexent.core.gateway.model_context import LongContextLLMContext + from nexent.core.gateway.modality.llm.openai import OpenAILongContextLLMAdapter + + main = request.module + scenario = "long_text" + _, _, profile, _, journal, _ = main.setup(peer, scenario) + adapter = OpenAILongContextLLMAdapter(LongContextLLMContext( + model_name=profile.identity.request_model_id, base_url=profile.connection.base_url, + api_key="dummy-loopback", modality="llm_long_context", factory="openai", + tenant_id=profile.identity.tenant_id, resolved_profile=profile, + )) + monkeypatch.setattr(adapter, "_get_tokenizer", lambda: None) + response, percentage = adapter.analyze_long_text("selected text", "instructions", "analyze") + assert response.content == "中间正文" and percentage == "100" + actual = main.records(journal, scenario=scenario) + assert len(actual) == 1 + assert actual[0]["body"]["messages"] == [ + {"role": "system", "content": "instructions"}, + {"role": "user", "content": "analyze\n\nselected text"}, + ] + assert actual[0]["body"]["enable_thinking"] is False + assert "max_tokens" not in actual[0]["body"] + assert adapter._model.profile == profile + + +@pytest.mark.parametrize("enabled,expected", [(True, "enabled"), (False, "disabled")]) +def test_saved_parameter_transform_reaches_actual_http_body(peer, request, enabled, expected): + from nexent.core.model_service.profiles.outlet_storage import decode_resolved_model_profile + + main = request.module + scenario = "parameter-transform" + service, call, profile, limiter, journal, _ = main.setup(peer, scenario) + data = profile.model_dump() + data["outlet_id"] = data["connection"]["credential_ref"]["model_id"] = 42 + data["parameter_values"]["enable_thinking"] = enabled + rule = next(item for item in data["parameter_rules"] if item["key"] == "enable_thinking") + rule["api_binding"] = {"path": "/thinking/type", "transform_ref": "thinking_toggle_v1", "transform_version": 1} + profile = profile.__class__.model_validate(data) + refs = model_service_references() + row = encode_resolved_model_profile(profile, registered_references=refs) + restored = decode_resolved_model_profile(row, registered_references=refs) + service.invoke(call.model_copy(update={"outlet_id": 42}), restored) + actual = main.records(journal, scenario=scenario) + assert len(actual) == 1 + assert actual[0]["body"]["thinking"] == {"type": expected} + assert "enable_thinking" not in actual[0]["body"] + assert restored.parameter_values["enable_thinking"] is enabled + assert not limiter._entries + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_migrated_legacy_configuration_reaches_shared_http_without_extra_defaults(peer, request, enabled): + from services.model_outlet_migration_service import build_legacy_outlet_snapshot + + main = request.module + scenario = "legacy-migration" + service, call, _, limiter, journal, _ = main.setup(peer, scenario) + root, _ = peer + record = {"model_id": 42, "tenant_id": "loopback-tenant", "model_type": "llm", + "model_name": "unlisted-legacy-model", "model_factory": "custom", "api_key": "dummy-loopback", + "base_url": f"{root}/v1?scenario={scenario}", "temperature": 0, "top_p": None, + "extra_params": {"enable_thinking": enabled, "__custom__": {"custom_options": {"flag": False, "count": 0}}}} + profile = build_legacy_outlet_snapshot(record, legacy_capability=None, migrated_at="2026-10-14T12:00:00Z") + service.invoke(call.model_copy(update={"outlet_id": 42}), profile) + actual = main.records(journal, scenario=scenario) + assert len(actual) == 1 + body = actual[0]["body"] + assert body["model"] == "unlisted-legacy-model" and body["temperature"] == 0 and body["top_p"] is None + assert body["enable_thinking"] is enabled + assert body["custom_options"] == {"flag": False, "count": 0} + assert "thinking_budget" not in body and "max_tokens" not in body + assert profile.capabilities.context.combined_window == 200000 + assert profile.provenance.provider_metadata["migration"]["reviewed_revision"] is None + assert not limiter._entries diff --git a/test-e2e/cases/MST-D3-001/test.py b/test-e2e/cases/MST-D3-001/test.py new file mode 100644 index 0000000000..772efc7099 --- /dev/null +++ b/test-e2e/cases/MST-D3-001/test.py @@ -0,0 +1,496 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Actual loopback HTTP integration of ModelService, sender, SSE and adapter.""" + +import asyncio +import json +import subprocess +import sys +import time +from pathlib import Path + +import pytest +from nexent.core.agents.core_agent import CoreAgent +from nexent.core.concurrency.cancellation import RunCancellationScope, RunTerminated +from nexent.core.concurrency.manager import ThreadManager +from nexent.core.concurrency.models import LanePolicy, ManagedTaskSpec +from nexent.core.model_service.adapters.openai_chat import OpenAIChatAdapter +from nexent.core.model_service.compatibility.legacy_chat_model import LegacyChatModel +from nexent.core.model_service.concurrency import ModelConcurrencyLimiter +from nexent.core.model_service.contracts import ( + ModelMessage, + ModelRequest, + ToolDefinition, +) +from nexent.core.model_service.profiles.composition import compose_provider_model_policy +from nexent.core.model_service.profiles.contracts import VersionedReference +from nexent.core.model_service.profiles.outlet_resolution import ( + construct_resolved_model_profile, +) +from nexent.core.model_service.profiles.resolved_profile import ( + ConnectionRules, + ContextConfiguration, + OutletConnection, +) +from nexent.core.model_service.profiles.resolver import load_profile_catalog +from nexent.core.model_service.registry import AuthenticationRegistry, model_service_references +from nexent.core.model_service.service import ModelService, ModelServiceError +from nexent.core.models.capacity_budget import ( + CapacityReservePolicy, + ContextBudgetCalculator, + ContextBudgetCapacityMismatch, +) +from nexent.core.models.capacity_resolver import resolve_capacity +from smolagents.models import CODEAGENT_RESPONSE_FORMAT + +from .empty_response_cases import test_legacy_empty_policy # noqa: F401 - Collected by pytest. +from .empty_response_cases import test_legacy_context_recovery_shares_real_http_budget, test_legacy_connectivity_uses_real_service_defaults # noqa: F401 - Collected by pytest. +from .empty_response_cases import test_actual_agent_factory_uses_resolved_outlet, test_resolved_gateway_stream_uses_real_async_transport, test_resource_conversion_and_saved_snapshot_reach_real_peer # noqa: F401 - Collected by pytest. +from .prompt_generation_cases import test_auxiliary_prompt_uses_saved_outlet_http_without_outer_retry, test_long_text_preparation_uses_saved_outlet_real_http # noqa: F401 + +from .prompt_generation_cases import test_saved_parameter_transform_reaches_actual_http_body # noqa: F401 + +from .prompt_generation_cases import test_migrated_legacy_configuration_reaches_shared_http_without_extra_defaults # noqa: F401 + +pytestmark = [pytest.mark.case_id("MST-D3-001"), pytest.mark.stage("D3")] + + +@pytest.fixture +def peer(tmp_path): + ready, journal = tmp_path / "ready", tmp_path / "requests.jsonl" + process = subprocess.Popen( + [ + sys.executable, + str(Path(__file__).with_name("http_peer.py")), + str(ready), + str(journal), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + deadline = time.monotonic() + 5 + while not ready.exists(): + assert process.poll() is None, "Loopback peer exited before becoming ready" + assert time.monotonic() < deadline, "Loopback peer startup timed out" + time.sleep(0.01) + yield f"http://127.0.0.1:{int(ready.read_text())}", journal + finally: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=3) + assert process.poll() is not None + + +def records(journal, *, kind="request", scenario=None): + lines = journal.read_text().splitlines() if journal.exists() else [] + values = [json.loads(line) for line in lines] + return [ + value + for value in values + if value["kind"] == kind and (scenario is None or value["scenario"] == scenario) + ] + + +def setup(peer, scenario, *, outlet_reader=None): + root, journal = peer + catalog = load_profile_catalog() + refs = dict(model_service_references()) + auth = AuthenticationRegistry() + policy = compose_provider_model_policy( + catalog, + provider_key="alibaba-cn", + request_model_id="qwen3.5-plus", + model_profile_key=None, + base_bindings={ + key: value[0].model_dump() + for key, value in refs.items() + if key != "parameter_transform" + }, + base_instruction=VersionedReference(ref="system_only_v1", version=1), + registered_references=refs, + submitted_values={"enable_thinking": False}, + ) + profile = construct_resolved_model_profile( + policy, + catalog, + tenant_id="loopback-tenant", + display_name="controlled peer", + model_type="llm", + provider_id="alibaba", + connection=OutletConnection( + base_url=f"{root}/v1?scenario={scenario}", + ssl_verify=True, + timeout_seconds=5, + concurrency_limit=1, + credential_ref={"kind": "model_record", "model_id": None}, + ), + connection_rules=ConnectionRules( + credential_kind="model_record", + relative_path="chat/completions", + method="POST", + headers={}, + ), + context_configuration=ContextConfiguration( + output_reserve_tokens=None, + tokenizer_family=None, + capacity_source="profile", + capability_profile_version=None, + ), + registered_references=refs, + ) + limiter = ModelConcurrencyLimiter() + credential_reads = [] + + def credentials(tenant_id, reference): + assert tenant_id == "loopback-tenant" + credential_reads.append(reference) + return "dummy-loopback" + + service = ModelService(lambda snapshot: auth.sender_for(snapshot, credentials), limiter=limiter, outlet_reader=outlet_reader) + request = ModelRequest( + request_id=f"request-{scenario}", + outlet_id=None, + config_revision=1, + messages=[ + ModelMessage( + semantic_role="system", + content_parts=[{"type": "text", "text": "instructions"}], + ), + ModelMessage( + semantic_role="user", + content_parts=[{"type": "text", "text": "question"}], + ), + ], + ) + return service, request, profile, limiter, journal, credential_reads + + +@pytest.mark.parametrize("deferred,suppressed,accepted", [ + (False, False, True), (True, False, True), (True, False, False), + (False, True, True), (True, True, True), +]) +def test_legacy_model_interface_uses_real_transport_and_retains_usage(peer, deferred, suppressed, accepted): + service, _request, profile, limiter, journal, credential_reads = setup(peer, "stream") + snapshot = profile.model_dump() + for key, schema in (("response_format", {"type": "object"}),): + snapshot["parameter_rules"].append({ + "key": key, "schema": schema, "visible": False, "editable": True, + "required": False, "allowed_scopes": ["outlet"], "ui_group": "legacy", "owner": "provider", + "source": {"ref": "loopback_fixture_v1", "version": 1}, + "api_binding": {"path": "/" + key, "transform_ref": "identity_v1", "transform_version": 1}, + }) + profile = profile.__class__.model_validate(snapshot) + model = LegacyChatModel(profile, service) + capacity = resolve_capacity(model_id=model.model_id, provider=model.model_factory, + operator_overrides={"context_window_tokens": 200000}, capability_profiles={}) + model.capacity_snapshot = {"capacity_fingerprint": capacity.fingerprint, + "provider": capacity.provider, "model_name": capacity.model_name} + model.context_budget_snapshot = ContextBudgetCalculator().calculate_context_budget( + capacity_snapshot=capacity, reserve_policy=CapacityReservePolicy()) + result = model([ + {"role": "system", "content": "Code instructions"}, + {"role": "tool-response", "content": "observation"}, + ], stop_sequences=["Observation:", "Calling tools:"], response_format=CODEAGENT_RESPONSE_FORMAT, + _defer_attempt_commit=deferred, _suppress_attempt_stream=suppressed) + assert result.content == "中间正文" + assert result.raw.reasoning_parts == [{"type": "text", "text": "consider"}] + assert result.tool_calls[0].id == "call-peer" + assert result.tool_calls[0].function.arguments == '{"n":0}' + assert result.token_usage.input_tokens == 10 and result.token_usage.output_tokens == 5 + assert model.last_finish_reason == "tool_calls" and result.model_attempt_number == 1 + def phases(): + return [json.loads(item)["phase"] for item in model.observer.message_query + if json.loads(item)["type"] == "model_attempt_control"] + assert phases() == ([] if suppressed else ["begin"] if deferred else ["begin", "commit"]) + if suppressed: + assert model.observer.message_query == [] + elif deferred: + core = CoreAgent.__new__(CoreAgent) + core.observer, core.step_number = model.observer, 1 + core._resolve_deferred_model_attempt(result, accepted=accepted) + core._resolve_deferred_model_attempt(result, accepted=accepted) + assert phases() == ["begin", "commit" if accepted else "rollback"] + assert result.model_attempt_commit_deferred is False + captured = records(journal) + assert len(captured) == 1 and len(credential_reads) == 1 + assert captured[0]["body"]["messages"] == [ + {"role": "system", "content": "Code instructions"}, + {"role": "user", "content": "observation"}, + ] + assert captured[0]["body"]["stop"] == ["Observation:", "Calling tools:"] + assert captured[0]["body"]["response_format"] == CODEAGENT_RESPONSE_FORMAT + assert captured[0]["body"]["max_tokens"] == model.context_budget_snapshot.requested_output_tokens + assert "stop" not in model.profile.parameter_values + model.capacity_snapshot["capacity_fingerprint"] = "stale" + with pytest.raises(ContextBudgetCapacityMismatch): + model([{"role": "user", "content": "must not send"}]) + assert len(records(journal)) == 1 and len(credential_reads) == 1 + assert not limiter._entries + + +async def consume(service, request, profile, asynchronous, **kwargs): + if asynchronous: + async with service.astream(request, profile, **kwargs) as events: + return [event async for event in events] + with service.stream(request, profile, **kwargs) as events: + return list(events) + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.asyncio +async def test_exact_request_reaches_real_peer(peer, asynchronous): + service, request, profile, limiter, journal, credential_reads = setup( + peer, "normal" + ) + result = ( + await service.ainvoke(request, profile) + if asynchronous + else service.invoke(request, profile) + ) + assert result.content_parts == [{"type": "text", "text": "answer"}] + assert result.reasoning_parts == [{"type": "text", "text": "consider"}] + assert result.usage.input_tokens == 10 and result.usage.output_tokens == 5 + captured = records(journal) + assert ( + len(captured) == 1 + and captured[0]["path"] == "/v1/chat/completions?scenario=normal" + ) + assert captured[0]["authorization"] == "Bearer dummy-loopback" + assert captured[0]["body"] == { + "model": "qwen3.5-plus", + "messages": [ + {"role": "system", "content": "instructions"}, + {"role": "user", "content": "question"}, + ], + "stream": False, + "enable_thinking": False, + } + assert len(credential_reads) == 1 and not limiter._entries + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.asyncio +async def test_fragmented_stream_retains_kinds_calls_and_usage(peer, asynchronous): + service, request, profile, limiter, journal, credential_reads = setup( + peer, "stream" + ) + tool = ToolDefinition( + name="test_tool", + description="Controlled declaration", + input_schema={ + "type": "object", + "properties": {"n": {"type": "integer"}}, + "required": ["n"], + }, + ) + request = request.model_copy(update={"stream": True, "tools": [tool]}) + events = await consume(service, request, profile, asynchronous) + assert [event.type for event in events] == [ + "reasoning_delta", + "text_delta", + "tool_call_delta", + "tool_call_delta", + "usage", + "message_completed", + ] + assert [event.sequence for event in events] == list(range(1, 7)) + assert len({event.message_id for event in events}) == 1 + final = events[-1].message + assert final.content_parts == [{"type": "text", "text": "中间正文"}] + assert final.reasoning_parts == [{"type": "text", "text": "consider"}] + assert final.finish_reason == "tool_calls" and final.usage.total_tokens == 15 + assert final.tool_calls[0].provider_call_id == "call-peer" and final.tool_calls[ + 0 + ].parsed_arguments == {"n": 0} + assert events[2].tool_call_delta.arguments_delta == '{"n":' + assert events[3].tool_call_delta.arguments_delta == "0}" + assert ( + events[2].tool_call_delta.local_call_id + == events[3].tool_call_delta.local_call_id + == final.tool_calls[0].local_call_id + ) + captured = records(journal) + assert len(captured) == 1 and captured[0]["body"]["stream_options"] == { + "include_usage": True + } + assert captured[0]["body"]["tools"] == [ + { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + "parameters": tool.input_schema, + }, + } + ] + assert len(credential_reads) == 1 and not limiter._entries + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.asyncio +async def test_retry_error_and_partial_output_boundaries(peer, asynchronous): + for scenario, expected_count in [ + ("retry", 2), + ("disconnect", 2), + ("exhaust", 3), + ("auth", 1), + ]: + service, request, profile, limiter, journal, credential_reads = setup( + peer, scenario + ) + if scenario in {"exhaust", "auth"}: + with pytest.raises(ModelServiceError) as caught: + if asynchronous: + await service.ainvoke(request, profile) + else: + service.invoke(request, profile) + assert caught.value.error.category == ( + "MODEL_SERVICE_UNAVAILABLE" + if scenario == "exhaust" + else "MODEL_AUTHENTICATION_ERROR" + ) + assert ( + caught.value.error.attempt == expected_count + and not caught.value.error.will_retry + ) + assert "private-provider-detail" not in caught.value.error.model_dump_json() + else: + result = ( + await service.ainvoke(request, profile) + if asynchronous + else service.invoke(request, profile) + ) + assert result.content_parts == [{"type": "text", "text": "answer"}] + captured = records(journal, scenario=scenario) + assert len(captured) == expected_count and len(credential_reads) == 1 + assert all(item["body"] == captured[0]["body"] for item in captured) + assert not limiter._entries + for scenario, category in [ + ("malformed", "MODEL_PROTOCOL_ERROR"), + ("truncate", "MODEL_PROTOCOL_ERROR"), + ("visible_disconnect", "MODEL_NETWORK_ERROR"), + ]: + service, request, profile, limiter, journal, _ = setup(peer, scenario) + request = request.model_copy(update={"stream": True}) + events = await consume(service, request, profile, asynchronous) + assert events[-1].type == "error" and events[-1].error.category == category + assert not events[-1].error.will_retry and not any( + event.type == "message_completed" for event in events + ) + if scenario != "malformed": + assert events[0].type == "text_delta" and events[0].delta == "partial" + assert len(records(journal, scenario=scenario)) == 1 and not limiter._entries + service, request, profile, limiter, journal, _ = setup(peer, "early_exit") + request = request.model_copy(update={"stream": True}) + scope = RunCancellationScope() + if asynchronous: + async with service.astream(request, profile, scope=scope) as events: + assert (await anext(events)).type == "reasoning_delta" + scope.cancel() + with pytest.raises(RunTerminated): + await anext(events) + with pytest.raises(StopAsyncIteration): + await anext(events) + else: + with service.stream(request, profile, scope=scope) as events: + assert next(events).type == "reasoning_delta" + scope.cancel() + with pytest.raises(RunTerminated): + next(events) + with pytest.raises(StopIteration): + next(events) + assert len(records(journal, scenario="early_exit")) == 1 and not limiter._entries + + +@pytest.mark.asyncio +@pytest.mark.parametrize("scenario", ["stall", "header_stall"]) +async def test_cancellation_closes_blocked_real_response(peer, scenario): + service, request, profile, limiter, journal, _ = setup(peer, scenario) + request = request.model_copy(update={"stream": True}) + scope = RunCancellationScope() + task = asyncio.create_task(consume(service, request, profile, True, scope=scope)) + try: + async with asyncio.timeout(2): + while not records(journal, kind="waiting", scenario=scenario): + await asyncio.sleep(0.01) + scope.cancel() + with pytest.raises(RunTerminated): + await asyncio.wait_for(task, timeout=2) + async with asyncio.timeout(2): + while not records(journal, kind="finished", scenario=scenario): + await asyncio.sleep(0.01) + assert ( + records(journal, kind="finished", scenario=scenario)[0]["disconnected"] + is True + ) + assert not records(journal, kind="peer_timeout") + assert len(records(journal, scenario=scenario)) == 1 and not limiter._entries + finally: + if not task.done(): + task.cancel() + try: + await task + except (asyncio.CancelledError, RunTerminated): + pass + + +@pytest.mark.parametrize("scenario", ["stall", "header_stall"]) +def test_sync_cancellation_reclaims_blocked_managed_worker(peer, scenario): + service, request, profile, limiter, journal, _ = setup(peer, scenario) + request = request.model_copy(update={"stream": True}) + scope = RunCancellationScope() + manager = ThreadManager( + service_name="loopback-test", + lane_policies={ + "agent-run": LanePolicy( + name="agent-run", + max_workers=1, + max_queue_size=0, + cancel_grace_seconds=2, + shutdown_grace_seconds=4, + ), + }, + ) + manager.start() + + def consume_blocked(): + with service.stream(request, profile, scope=scope) as events: + return list(events) + + execution = manager.submit( + "agent-run", + ManagedTaskSpec( + task_name="blocked-model-stream", + owner="MST-D3-001", + close_hook=scope.cancel, + ), + consume_blocked, + ) + try: + deadline = time.monotonic() + 2 + while not records(journal, kind="waiting", scenario=scenario): + assert time.monotonic() < deadline + time.sleep(0.01) + time.sleep(0.05) + scope.cancel() + with pytest.raises(RunTerminated): + execution.future.result(timeout=2) + assert manager.snapshot().active_count == 0 + assert not limiter._entries and len(records(journal, scenario=scenario)) == 1 + deadline = time.monotonic() + 1 + while not records(journal, kind="finished", scenario=scenario): + assert time.monotonic() < deadline + time.sleep(0.01) + assert ( + records(journal, kind="finished", scenario=scenario)[0]["disconnected"] + is True + ) + assert not records(journal, kind="peer_timeout") + finally: + scope.cancel() + result = asyncio.run(manager.shutdown(timeout=4)) + assert not result.stuck_execution_ids diff --git a/test-e2e/cases/PW-MODEL-01/case.yaml b/test-e2e/cases/PW-MODEL-01/case.yaml index ed4298593b..14837ae590 100644 --- a/test-e2e/cases/PW-MODEL-01/case.yaml +++ b/test-e2e/cases/PW-MODEL-01/case.yaml @@ -18,6 +18,8 @@ case: - 可创建Draft Agent test_data: legacy_asset_declaration: config/models.yaml;config/secrets.env + new_outlet_context_window: 200000 + new_outlet_output_limit: 4096 steps: - order: 1 action: 从 config/models.yaml 读取当前真实模型名称、类型、URL 和凭据引用,不在脚本中自造名称。 @@ -26,16 +28,16 @@ case: action: 进入模型页,打开“添加模型”。 expected: 连通性请求到达真实 Provider,reasoning 模型不会因空 content/timeout 误判。 - order: 3 - action: 优先在“从服务商导入”选择配置对应 Provider 和模型;若目录未提供该模型,则使用“自定义接入”。 + action: 在单个添加的 LLM 快捷表单填写服务地址、实际模型名称及本地凭据,使用自动匹配的档案建议或自定义提供商。 expected: 推理参数和容量持久化且刷新不丢失。 - order: 4 - action: 设置显示名、容量和当前模型支持的推理参数,执行真实连通性检查。 + action: 设置显示名、上下文窗口和最大输出长度(模型出口上限),保存出口并执行真实连通性检查;记录实际保存的声明参数和容量。 expected: 编辑请求不含 api_key/apiKey,已保存凭据未被空值覆盖,保存后原配置模型仍可连通。 - order: 5 - action: 仅在状态为可用后保存;刷新页面并核对持久化。 + action: 检查保存后的真实连通性结果为可用;刷新页面,比较完整参数值与容量,并选择该模型。 expected: Agent 可选择并真实调用该模型。 - order: 6 - action: 重新进入该模型的编辑弹窗,API Key 保持空白并保存;捕获 /api/model/update 请求后再次执行真实连通性检查。 + action: 重新进入统一出口编辑,API Key 保持空白并保存;捕获 PATCH /api/model/outlets/{id},比较原参数和容量,并再次执行真实连通性检查。 expected: DOM、日志、trace 和报告不出现 API Key。 - order: 7 action: 创建 run_id Agent,选择该模型并保存,进入调试发起最小真实请求。 @@ -51,6 +53,23 @@ case: - Agent 可选择并真实调用该模型。 - DOM、日志、trace 和报告不出现 API Key。 forbidden_side_effects: [] + acceptance: + - id: AC-SOURCE + description: Model identity and URL come from the configured real asset and credentials are not exposed. + steps: [1, 2, 3] + expected_results: [1, 6] + - id: AC-PERSIST + description: The saved outlet is available and its actual capacity and declared parameters survive refresh. + steps: [4, 5] + expected_results: [2, 3] + - id: AC-CREDENTIAL + description: Canonical editing omits credentials, retains configuration and still reaches the real provider. + steps: [6] + expected_results: [4] + - id: AC-AGENT + description: The selected model completes a real Agent request and only owned resources are cleaned. + steps: [7, 8] + expected_results: [5, 6] source: legacy_workbook_sha256: aa4b32414d30f12b3b64e14236268145e556bd7cfc5d3eb8df80731608ebc022 legacy_sheet: 03_Playwright_Journey @@ -59,10 +78,10 @@ case: journey: - 从 config/models.yaml 读取当前真实模型名称、类型、URL 和凭据引用,不在脚本中自造名称。 - 进入模型页,打开“添加模型”。 - - 优先在“从服务商导入”选择配置对应 Provider 和模型;若目录未提供该模型,则使用“自定义接入”。 - - 设置显示名、容量和当前模型支持的推理参数,执行真实连通性检查。 - - 仅在状态为可用后保存;刷新页面并核对持久化。 - - 重新进入该模型的编辑弹窗,API Key 保持空白并保存;捕获 /api/model/update 请求后再次执行真实连通性检查。 + - 在 LLM 快捷表单填写配置来源的服务地址、精确模型名称和凭据。 + - 配置上下文容量和模型出口输出上限,保存并检查真实连通性。 + - 刷新并比较实际持久化的参数及容量。 + - 使用统一 PATCH 编辑保留密钥,并再次检查真实连通性。 - 创建 run_id Agent,选择该模型并保存,进入调试发起最小真实请求。 - 清理 Agent;仅删除本次新建且非共享的模型。 final_business_result: diff --git a/test-e2e/cases/PW-MODEL-01/execution.yaml b/test-e2e/cases/PW-MODEL-01/execution.yaml index 90e3389ff7..c19f411a95 100644 --- a/test-e2e/cases/PW-MODEL-01/execution.yaml +++ b/test-e2e/cases/PW-MODEL-01/execution.yaml @@ -1,5 +1,10 @@ schema_version: '1.0' case_id: PW-MODEL-01 +acceptance_bindings: + AC-SOURCE: [PW-MODEL-01] + AC-PERSIST: [PW-MODEL-01] + AC-CREDENTIAL: [PW-MODEL-01] + AC-AGENT: [PW-MODEL-01] implementations: - framework: playwright file: test.spec.ts diff --git a/test-e2e/cases/PW-MODEL-01/test.spec.ts b/test-e2e/cases/PW-MODEL-01/test.spec.ts index 13a5712117..1c7abdd29f 100644 --- a/test-e2e/cases/PW-MODEL-01/test.spec.ts +++ b/test-e2e/cases/PW-MODEL-01/test.spec.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + import type { Locator } from "playwright/test"; import { journey } from "../../infra/automation/d4/runner/journey"; import { executeFixedScenario } from "../../infra/automation/d4/runner/scenario"; @@ -24,6 +26,19 @@ journey("PW-MODEL-01", async (context) => { let responseText = ""; let credentialPreservingEdit: Record = {}; let modelAlreadyExists = false; + let savedConfiguration: Record; + + contract.deferCleanup(async () => { + if (agentId) await agents.delete(agentId); + if (models.createdOutletId) { + const response = await page.request.get(`/api/model/outlets/${models.createdOutletId}`); + if (response.ok()) { + const outlet = (await response.json()).data; + expect(outlet.model_id).toBe(models.createdOutletId); + await models.deleteModel(outlet.profile.identity.display_name); + } else expect(response.status()).toBe(404); + } + }); await executeFixedScenario(context, { preconditions: [ @@ -63,6 +78,9 @@ journey("PW-MODEL-01", async (context) => { const alignment = modelAlreadyExists ? await models.alignPersistedModel(asset, modelName, "llm") : "new"; const status = modelAlreadyExists ? await models.verifyPersistedModel(modelName, "llm") : await models.verifyDraft(modal); expect(status).toBe(200); + savedConfiguration = await models.savedLlmConfiguration(modelName); + expect(savedConfiguration.model).toBe(asset.model); + expect(savedConfiguration.baseUrl).toBe(asset.baseUrl); return `${modelAlreadyExists ? alignment : "temporary"} configured real model connectivity made a provider call and returned available`; }, async () => { @@ -72,11 +90,13 @@ journey("PW-MODEL-01", async (context) => { registerReadyAsset("models", "d4_llm_display_name", modelName, "PW-MODEL-01"); await page.reload({ waitUntil: "domcontentloaded" }); await models.expectModel(modelName, "llm"); + expect(await models.savedLlmConfiguration(modelName)).toEqual(savedConfiguration); await models.configureModel(modelName, "llm"); return `${modelAlreadyExists ? "reused" : "added"} the verified real model, selected it and recovered the persisted display name`; }, async () => { credentialPreservingEdit = await models.saveEditWithoutReplacingCredential(modelName, "llm"); + expect(await models.savedLlmConfiguration(modelName)).toEqual(savedConfiguration); return "opened the real Edit Model UI, left API Key blank, saved, and rechecked real connectivity"; }, async () => { @@ -108,7 +128,11 @@ journey("PW-MODEL-01", async (context) => { return "the UI model name, URL and llm type came from the config/models.yaml snapshot"; }, async () => "captured 200 from the real provider-connectivity request without treating an empty reasoning-model content field as failure", - async () => "capacity and supported reasoning parameters survived the model refresh and selection path", + async () => { + expect(savedConfiguration.context).toBeTruthy(); + expect(savedConfiguration.parameters).toBeTruthy(); + return "read-back capacity and declared request parameters matched after refresh and credential-preserving edit"; + }, async () => { expect("api_key" in credentialPreservingEdit || "apiKey" in credentialPreservingEdit).toBeFalsy(); return "blank edit did not send a sentinel or overwrite the stored credential"; diff --git a/test-e2e/cases/UT-SDK-AUTO-9A76928E3A7B1ADF/case.yaml b/test-e2e/cases/UT-SDK-AUTO-9A76928E3A7B1ADF/case.yaml index 4c03038a5b..8fe98f98c8 100644 --- a/test-e2e/cases/UT-SDK-AUTO-9A76928E3A7B1ADF/case.yaml +++ b/test-e2e/cases/UT-SDK-AUTO-9A76928E3A7B1ADF/case.yaml @@ -23,7 +23,7 @@ case: action: 在本地工作区读取与“ModelConcurrencyLimiter acquire/release、同key冲突limit、等待超时、stop_event取消与引用归零回收|核心流程”相关的固定 fixture/配置;测试代码不得读取生产凭据。 expected: acquire 正常返回 ModelConcurrencyPermit 实例;同 key 复用同一条目(limit 一致时不冲突),不同 key 相互独立。 - order: 2 - action: 直接导入 sdk/nexent/core/models/model_concurrency.py 的 ModelConcurrencyLimiter、ModelConcurrencyPermit、ModelConcurrencyExceeded,每个用例新建独立 + action: 直接导入 sdk/nexent/core/model_service/concurrency.py 的 ModelConcurrencyLimiter、ModelConcurrencyPermit、ModelConcurrencyExceeded,每个用例新建独立 ModelConcurrencyLimiter 实例,不使用模块级全局 model_concurrency_limiter,避免用例串扰。 expected: limit<=0 或 timeout_seconds<=0 时 acquire 抛 ValueError,且不留残留的 _entries 条目。 - order: 3 diff --git a/test-e2e/cases/UT-SDK-AUTO-9A76928E3A7B1ADF/test.py b/test-e2e/cases/UT-SDK-AUTO-9A76928E3A7B1ADF/test.py index 6756e9f6da..8666b2a92c 100644 --- a/test-e2e/cases/UT-SDK-AUTO-9A76928E3A7B1ADF/test.py +++ b/test-e2e/cases/UT-SDK-AUTO-9A76928E3A7B1ADF/test.py @@ -1,10 +1,12 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + from __future__ import annotations import threading import pytest -from nexent.core.models.model_concurrency import ( +from nexent.core.model_service.concurrency import ( ModelConcurrencyExceeded, ModelConcurrencyLimiter, ModelConcurrencyPermit, diff --git a/test-e2e/changes/bugs/MODEL-ONBOARDING-FEEDBACK-20261016.yaml b/test-e2e/changes/bugs/MODEL-ONBOARDING-FEEDBACK-20261016.yaml new file mode 100644 index 0000000000..cc6c836c09 --- /dev/null +++ b/test-e2e/changes/bugs/MODEL-ONBOARDING-FEEDBACK-20261016.yaml @@ -0,0 +1,40 @@ +schema_version: '1.0' +change: + change_id: MODEL-ONBOARDING-FEEDBACK-20261016 + change_type: bugfix + bug_id: MODEL-ONBOARDING-FEEDBACK-20261016 + title: Recover stale profile previews and improve matching and inherited output limits + description: Fix catalog suggestions and distinguish inherited output caps from explicit request defaults; recover stale catalog revisions explicitly without discarding batch configuration. + observed_behavior: Short prefixed GLM IDs fail matching and output limit inputs omit known catalog values. Batch thinking previews returned HTTP 409 after catalog revision changes; generic errors provided no recovery action. Model matching without an endpoint left basic capabilities blank because complete preview was disabled; the conflict banner was oversized. + expected_behavior: Prefix/suffix text does not hide a known exact model/version; known capability caps display independently of request defaults and edits update only capability limits; thinking toggles retain a valid request definition; model-only configuration is shown before connection details, and version conflicts use a compact recoverable notice. + escaped_stage: D4 + coverage_gap: + type: missing_assertion + description: Existing suggestion tests cover long Qwen IDs but not short prefixed GLM IDs; form tests do not assert inherited output cap display separately from the sent parameter, model-only display without connection details, or compact version-conflict recovery. + feature_contract_changed: false + affected_features: + existing: [V5-PW-MODEL-01] + added: [] + modified: [] + retired: [] + affected_cases: + existing: [PW-MODEL-01] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: strengthen + reason: Add isolated matching and inherited-limit regressions in the existing frontend suites; cover explicit revision refresh and retry without changing user configuration. + behaviors: + - Prefixed short model names match the same exact version without crossing minor versions or ambiguous identities. + - Output limit display and editing use context.output_limit; existing max_output_tokens request defaults stay separate and are not rewritten by capability edits. + - Batch form uses the same preview and configuration rules as single add. + - Known model-only configuration appears before endpoint entry; stale composed data never follows a changed model or endpoint. + - Version conflict recovery remains explicit and preserves user values using a compact inline notice. + test_selectors: + - frontend/tests/component/modelProfileSuggestion.test.tsx + - frontend/tests/component/modelOutletAddForm.test.tsx + - frontend/tests/component/modelOutletEditing.test.tsx + - frontend/tests/component/modelProfileLimits.test.tsx + - test/backend/services/test_model_profile_service.py + notes: User clarified that the model access field edits capability rather than request defaults; dynamic request budgeting remains pending separate verification. Existing SPEC and technical design updated directly. Existing PW-MODEL-01 is regression coverage; new assertions and manual dev checks remain distinct from formal D4 execution. No VLM changes. Dev preview recovery is tested separately from final container verification, which remains pending. diff --git a/test-e2e/changes/bugs/MODEL-ONBOARDING-LAYOUT-20261015.yaml b/test-e2e/changes/bugs/MODEL-ONBOARDING-LAYOUT-20261015.yaml new file mode 100644 index 0000000000..9eb9cd6a53 --- /dev/null +++ b/test-e2e/changes/bugs/MODEL-ONBOARDING-LAYOUT-20261015.yaml @@ -0,0 +1,38 @@ +schema_version: '1.0' +change: + change_id: MODEL-ONBOARDING-LAYOUT-20261015 + change_type: bugfix + bug_id: MODEL-ONBOARDING-LAYOUT-20261015 + title: Make single and batch LLM onboarding usable with one profile draft editor + description: Reorder basic fields into a responsive two-row layout, move profile selection into the basic form and reuse the draft editor for per-model batch configuration. + observed_behavior: Single onboarding spreads essential fields vertically and hides profile selection behind a manual toggle; batch rows use a different legacy configuration editor. + expected_behavior: Provider, credential and editable URL precede model ID, profile and display name; Other expands advanced settings; batch row drafts remain independent and use the same configuration for checking and saving. + escaped_stage: D4 + coverage_gap: + type: missing_assertion + description: Existing PW-MODEL-01 proves saved outlet and Agent behavior but does not assert basic-field layout, Other expansion or batch draft cancellation/isolation. Strengthen frontend regression assertions and record supplementary built-in-browser evidence without claiming the unchanged fixed runner proves those interactions. + feature_contract_changed: false + traditional_ut: + decision: strengthen + reason: Reuse onboarding UT and add targeted suggestion and batch-state regressions; no Python production behavior changes. + behaviors: + - Manual model profiles do not rewrite request model IDs or get replaced by automatic matches. + - Other expands advanced settings; malformed parameters still block saving. + - Batch configuration is local until applied, cancellation preserves prior values and rows do not share overrides. + - Connection checking and creation use the same configured draft; non-LLM paths are preserved. + - Equivalent parent rerenders retain preview readiness; changed or stale drafts remain blocked. + test_selectors: + - frontend/tests/component/modelOutletAddForm.test.tsx + - frontend/tests/component/modelOutletBatch.test.tsx + - frontend/tests/component/modelProfileSuggestion.test.tsx + affected_features: + existing: [V5-PW-MODEL-01] + added: [] + modified: [] + retired: [] + affected_cases: + existing: [PW-MODEL-01] + added: [] + modified: [] + retired: [] + notes: Approved user sketch recorded in model-access-native-agent/SPEC.md and TECHNICAL-DESIGN.md. Keep actual browser/layout evidence separate from formal execution. No new D1 duplicates component UT; VLM form and transport remain outside this change. diff --git a/test-e2e/changes/refactors/model-access-simplification.yaml b/test-e2e/changes/refactors/model-access-simplification.yaml new file mode 100644 index 0000000000..16ae890f34 --- /dev/null +++ b/test-e2e/changes/refactors/model-access-simplification.yaml @@ -0,0 +1,29 @@ +schema_version: '1.0' +change: + change_id: MODEL-ACCESS-SIMPLIFY-20261019 + change_type: refactor + title: Move profile checks before submission and unify internal parameter handling + description: Replace external mapping rules with code policies, restrict source checks to consumed facts before submission, and normalize legacy inputs at read boundaries. + affected_features: + existing: [SDK-MODEL-PROFILE-COMPOSITION, SDK-MODEL-PROFILE-PARAMETERS, BE-MODEL-OUTLET-PERSISTENCE] + added: [] + modified: [SDK-MODEL-PROFILE-NORMALIZATION] + retired: [] + affected_cases: + existing: [MPC-D1-001, MOP-D3-001] + added: [] + modified: [MPN-D1-001] + retired: [] + feature_contract_changed: true + traditional_ut: + decision: strengthen + reason: Reuse existing mapping, catalog, Agent, preference and update tests; replace removed rule-file assumptions with conversion and submission/runtime boundary assertions. + behaviors: + - Validate consumed facts before submission without validating reference metadata + - Preserve conversion values, source lineage, unknown false and zero semantics + - Normalize historical inputs once and preserve scope, deletion and thinking dependencies + test_selectors: + - test/sdk/core/model_service/test_source_mapping.py + - test/sdk/core/model_service/test_catalog_normalization.py + - test/backend/services/test_agent_model_parameters.py + notes: User approved SIMPLIFY-01 on 2026-10-19; remote PR mutations require separate approval of local stack partitions. Formal runtime and migration cases remain required regressions, not replaced by UT. diff --git a/test-e2e/changes/requirements/MODEL-CATALOG-CHINA-20261016.yaml b/test-e2e/changes/requirements/MODEL-CATALOG-CHINA-20261016.yaml new file mode 100644 index 0000000000..31d2f60ee8 --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-CATALOG-CHINA-20261016.yaml @@ -0,0 +1,33 @@ +schema_version: '1.0' +change: + change_id: MODEL-CATALOG-CHINA-20261016 + change_type: requirement + requirement_id: MODEL-CATALOG-CHINA-20261016 + title: Expand China provider and model source inventory + description: Union the existing inventory with reviewed China providers and current model families; keep authored local provider evidence distinct from copied upstream sources. + affected_features: + existing: [SDK-MODEL-PROFILE-CATALOG, SDK-MODEL-PROFILE-NORMALIZATION] + added: [] + modified: [SDK-MODEL-PROFILE-CATALOG] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [MPN-D1-001] + retired: [] + traditional_ut: + decision: strengthen + reason: Existing integrity and parser suites own source selection and validation. Extend inventory assertions and local-origin error/provenance checks without duplicating them as a new D1. + behaviors: + - Preserve the complete old inventory and add the requested provider/model records. + - Every copied combination with declared reasoning controls has exact versioned request bindings and preserves its domains without invented defaults. + - All copied bytes match pinned hashes and inheritance dependencies are present. + - Local provider sources require explicit origin, revision and official documentation URL. + - Local references retain local provenance through normalization; no live-service qualification is inferred. + test_selectors: + - test/sdk/core/model_service/test_catalog_inventory.py + - test/sdk/core/model_service/test_source_reader.py + - test/sdk/core/model_service/test_catalog_normalization.py + - test/backend/services/test_model_profile_catalog_matrix.py + no_case_change_reason: Reuse MPN-D1-001 only for unchanged atomic normalization regression. New inventory/origin assertions belong to UT; dev API/browser inspection verifies visibility. No model execution or new adapter is introduced and live provider qualification remains separate. + notes: Existing resolved outlets and VLM behavior stay unchanged. Final container verification is pending before push. diff --git a/test-e2e/changes/requirements/MODEL-EXPERIENCE-OVERLAY-20261016.yaml b/test-e2e/changes/requirements/MODEL-EXPERIENCE-OVERLAY-20261016.yaml new file mode 100644 index 0000000000..76e8f0fe62 --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-EXPERIENCE-OVERLAY-20261016.yaml @@ -0,0 +1,30 @@ +schema_version: '1.0' +change: + change_id: MODEL-EXPERIENCE-OVERLAY-20261016 + change_type: requirement + requirement_id: MODEL-EXPERIENCE-OVERLAY-20261016 + title: Apply Nexent experience rules after upstream profile composition + description: Compose source facts first, then model/provider/exact-combination supplements and permitted user values; preview and save share the same policy, retaining save-before-connection-check. + affected_features: + existing: [SDK-MODEL-PROFILE-COMPOSITION, V5-PW-MODEL-01] + added: [] + modified: [SDK-MODEL-PROFILE-COMPOSITION] + retired: [] + affected_cases: + existing: [MPC-D1-001, PW-MODEL-01] + added: [] + modified: [MPC-D1-001] + retired: [] + traditional_ut: + decision: strengthen + reason: Existing composition tests enforce the previous priority; add explicit multi-layer defaults/capabilities and preview assertions while retaining invalid-rule validation. + behaviors: + - All upstream capability facts precede model, provider and exact-combination experience overrides. + - Later-layer declarations override the same key and preserve unrelated keys; permitted user values override defaults but never fixed constraints. + - Previews return the effective defaults and capabilities without writes; saved snapshots are not recomposed. + test_selectors: + - test/sdk/core/model_service/test_composition.py + - test/sdk/core/model_service/test_experience_overlay.py + - test/backend/services/test_model_profile_service.py + - frontend/tests/component/modelOutletAddForm.test.tsx + notes: Strengthen the existing full-file composition component Case, no duplicate D1. Dev API/browser evidence is separate from formal D4 execution. No new inference defaults, VLM changes or tool execution changes. Final container verification remains required before remote updates. diff --git a/test-e2e/changes/requirements/MODEL-ONBOARDING-LAYOUT-20261016.yaml b/test-e2e/changes/requirements/MODEL-ONBOARDING-LAYOUT-20261016.yaml new file mode 100644 index 0000000000..59ffbad7d5 --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-ONBOARDING-LAYOUT-20261016.yaml @@ -0,0 +1,40 @@ +schema_version: '1.0' +change: + change_id: MODEL-ONBOARDING-LAYOUT-20261016 + change_type: requirement + requirement_id: MODEL-ONBOARDING-LAYOUT-20261016 + title: Apply the reviewed two-column model onboarding layout + description: Use a compact header and two columns for LLM onboarding, with shared sampling/thinking/context cards and a model-only reset; hide the parameter panel and narrow the dialog for non-LLM types. + observed_behavior: Single onboarding spreads essential fields vertically and hides profile selection behind a manual toggle; batch rows use a different legacy configuration editor. + expected_behavior: Provider/type, model ID/profile/display name, endpoint/key and advanced connection settings occupy the left; matched model parameters occupy the right, use draft defaults and retain save-before-validation. Batch row configuration reuses the same form. + escaped_stage: D4 + coverage_gap: + type: missing_assertion + description: Existing PW-MODEL-01 proves saved outlet and Agent behavior but does not assert basic-field layout, Other expansion or batch draft cancellation/isolation. Strengthen frontend regression assertions and record supplementary built-in-browser evidence without claiming the unchanged fixed runner proves those interactions. + feature_contract_changed: false + traditional_ut: + decision: strengthen + reason: Reuse onboarding UT and add targeted suggestion and batch-state regressions; no Python production behavior changes. + behaviors: + - The empty parameter panel becomes populated after identification; model-only reset preserves connection and request settings. + - Disabled thinking details remain visible in the panel; existing inline controls retain their behavior. + - Manual model profiles do not rewrite request model IDs or get replaced by automatic matches. + - Other expands advanced settings; malformed parameters still block saving. + - Batch configuration is local until applied, cancellation preserves prior values and rows do not share overrides. + - Connection checking and creation use the same configured draft; non-LLM paths are preserved. + - Equivalent parent rerenders retain preview readiness; changed or stale drafts remain blocked. + test_selectors: + - frontend/tests/component/modelOutletAddForm.test.tsx + - frontend/tests/component/modelOutletBatch.test.tsx + - frontend/tests/component/modelProfileSuggestion.test.tsx + affected_features: + existing: [V5-PW-MODEL-01] + added: [] + modified: [] + retired: [] + affected_cases: + existing: [PW-MODEL-01] + added: [] + modified: [PW-MODEL-01] + retired: [] + notes: Approved user sketch recorded in model-access-native-agent/SPEC.md and TECHNICAL-DESIGN.md. Keep actual browser/layout evidence separate from formal execution. No new D1 duplicates component UT; VLM form and transport remain outside this change. diff --git a/test-e2e/changes/requirements/MODEL-OUTLET-CAPABILITY-STORAGE-20261013.yaml b/test-e2e/changes/requirements/MODEL-OUTLET-CAPABILITY-STORAGE-20261013.yaml new file mode 100644 index 0000000000..a800340e88 --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-OUTLET-CAPABILITY-STORAGE-20261013.yaml @@ -0,0 +1,27 @@ +schema_version: '1.0' +change: + change_id: MODEL-OUTLET-CAPABILITY-STORAGE-20261013 + change_type: requirement + requirement_id: MODEL-OUTLET-CAPABILITY-STORAGE-20261013 + title: Preserve capability column ownership on outlet load + description: Strict capability partition and restoration from a single-row field set without catalog access. + affected_features: + existing: [SDK-MODEL-OUTLET-STORAGE] + added: [] + modified: [SDK-MODEL-OUTLET-STORAGE] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Preserve strict BOOLEAN/NULL status conversion, unknown/empty domains and detached round-trip values. + - Reject duplicated, incomplete, inconsistent, non-JSON or oversized capability storage without leaking values. + - Enforce positive INT4 context limits and existing typed reasoning/modality/response contracts after restoration. + reason: Existing contracts validate capabilities in memory, not their persisted ownership. Pure partition/restore belongs in UT; no extra D1 adds necessary component evidence. + test_selectors: [test/sdk/core/model_service/test_outlet_capabilities.py] + no_case_change_reason: This pure codec does not connect database or runtime paths. Preserve existing Cases; complete-row/database and migration verification remains required separately. + notes: Designed in TECHNICAL-DESIGN before implementation. This does not prove complete ResolvedModelProfile storage or real database execution. diff --git a/test-e2e/changes/requirements/MODEL-OUTLET-PARAMETER-STORAGE-20261013.yaml b/test-e2e/changes/requirements/MODEL-OUTLET-PARAMETER-STORAGE-20261013.yaml new file mode 100644 index 0000000000..8fbb3c3112 --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-OUTLET-PARAMETER-STORAGE-20261013.yaml @@ -0,0 +1,27 @@ +schema_version: '1.0' +change: + change_id: MODEL-OUTLET-PARAMETER-STORAGE-20261013 + change_type: requirement + requirement_id: MODEL-OUTLET-PARAMETER-STORAGE-20261013 + title: Preserve single-location outlet parameter values + description: Add strict saved-value validation and dedicated-column parameter split/restore without default application or TOML reads. + affected_features: + existing: [] + added: [SDK-MODEL-OUTLET-STORAGE] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Preserve omission, false, zero and explicit null without defaults or input mutation. + - Reject invalid saved schemas, dependencies, required or fixed values, duplicate storage and null markers. + - Enforce dedicated boolean, string length, finite number and INT4 column limits without exposing values in errors. + reason: Existing parameter tests cover configuration creation, not storage presence or strict snapshot loading. Pure codec proof belongs in UT; no duplicate D1 is created. + test_selectors: [test/sdk/core/model_service/test_outlet_parameters.py] + no_case_change_reason: No database, transport or user journey is connected. Preserve all existing formal Cases; design real database/deployment proof before implementing those boundaries. + notes: Only a storage prerequisite, not a complete ResolvedModelProfile or migration delivery. Documented in TECHNICAL-DESIGN before product implementation. diff --git a/test-e2e/changes/requirements/MODEL-OUTLET-PERSISTENCE-20261013.yaml b/test-e2e/changes/requirements/MODEL-OUTLET-PERSISTENCE-20261013.yaml new file mode 100644 index 0000000000..695da561ae --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-OUTLET-PERSISTENCE-20261013.yaml @@ -0,0 +1,30 @@ +schema_version: '1.0' +change: + change_id: MODEL-OUTLET-PERSISTENCE-20261013 + change_type: requirement + requirement_id: MODEL-OUTLET-PERSISTENCE-20261013 + title: Persist resolved model outlets transactionally + description: Shared-session creation/read/update with stable identity, tenant and revision checks. + affected_features: + existing: [] + added: [BE-MODEL-OUTLET-PERSISTENCE] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [MOP-D3-001] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Validate new-record identity, credentials and shared-session lifecycle. + - Compile SQL with tenant, active state and expected revision predicates, preserving declaration immutability and parameter permissions. + - Sanitize SQL failures and reject stale or invalid updates without value disclosure. + - Recover integer sampling semantics from existing FLOAT columns and reject lossy integer storage. + reason: Existing CRUD tests do not exercise resolved snapshots. UT proves query/validation logic; D3 must prove actual transaction/CAS/database behavior. + test_selectors: + - test/backend/database/test_model_outlet_db.py + - test/sdk/core/model_service/test_outlet_storage.py + - test/sdk/core/model_service/test_outlet_parameters.py + notes: D3 designed before database implementation. Unit tests now exist; actual database script/execution remain pending. A FLOAT schema regression was reproduced before repair. No database transaction pass is claimed. diff --git a/test-e2e/changes/requirements/MODEL-OUTLET-RESOLUTION-20261013.yaml b/test-e2e/changes/requirements/MODEL-OUTLET-RESOLUTION-20261013.yaml new file mode 100644 index 0000000000..ed4591eb70 --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-OUTLET-RESOLUTION-20261013.yaml @@ -0,0 +1,32 @@ +schema_version: '1.0' +change: + change_id: MODEL-OUTLET-RESOLUTION-20261013 + change_type: requirement + requirement_id: MODEL-OUTLET-RESOLUTION-20261013 + title: Construct complete resolved outlet snapshots + description: Connect composed policy with save-time validated outlet identity, connection and provenance. + affected_features: + existing: [] + added: + - SDK-MODEL-OUTLET-RESOLUTION + modified: [] + retired: [] + affected_cases: + existing: [] + added: + - MPR-D1-001 + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Complete new snapshots preserve exact request identity, composed values, traditional mapping and atomic source provenance. + - Construction checks connection, implementation versions and protected extra fields without reapplying defaults or retaining + raw credentials. + - Saved snapshots load without source access; mismatched catalog revisions and inconsistent selections fail before persistence. + reason: New constructor needs unit normal/boundary/error assertions; D1 proves actual collaboration missing from independent + composition and storage UT. + test_selectors: + - test/sdk/core/model_service/test_outlet_resolution.py + notes: 'D1 admission: MPC ends at composed policy, MOP starts with a synthetic DTO; neither proves actual source policy + can become a persistable snapshot. Existing Cases remain unchanged.' diff --git a/test-e2e/changes/requirements/MODEL-OUTLET-ROW-STORAGE-20261013.yaml b/test-e2e/changes/requirements/MODEL-OUTLET-ROW-STORAGE-20261013.yaml new file mode 100644 index 0000000000..702ec330a9 --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-OUTLET-ROW-STORAGE-20261013.yaml @@ -0,0 +1,27 @@ +schema_version: '1.0' +change: + change_id: MODEL-OUTLET-ROW-STORAGE-20261013 + change_type: requirement + requirement_id: MODEL-OUTLET-ROW-STORAGE-20261013 + title: Encode and restore complete resolved outlet rows + description: Compose existing codecs under a strict ResolvedModelProfile and same-row restoration with trusted version availability checks. + affected_features: + existing: [SDK-MODEL-OUTLET-STORAGE] + added: [] + modified: [SDK-MODEL-OUTLET-STORAGE] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Strict full profile and row round trips preserve identity, saved values, same-row domains and detached snapshots without defaults or source reads. + - Credentials are references to this outlet; raw API keys and copied protocol/identity/value fields are excluded from generated storage. + - Invalid or incomplete rows, wrong versions, unavailable implementations, conflicting extra parameters, oversized profiles and corrupted domains fail explicitly. + reason: Existing group codecs cannot prove complete object ownership and restoration. These SDK codec tests remain UT; database integration and deployment proof add the necessary higher boundary instead of duplicating inputs in a D1 wrapper. + test_selectors: [test/sdk/core/model_service/test_outlet_storage.py] + no_case_change_reason: No database transaction or runtime provider request is connected in this slice. Those boundaries still require separate formal implementation and execution; no existing Case is removed. + notes: This does not establish actual transport registration or provider qualification. The tests supply a controlled availability snapshot; production must use its real registry. diff --git a/test-e2e/changes/requirements/MODEL-OUTLET-RULE-STORAGE-20261013.yaml b/test-e2e/changes/requirements/MODEL-OUTLET-RULE-STORAGE-20261013.yaml new file mode 100644 index 0000000000..acfef557be --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-OUTLET-RULE-STORAGE-20261013.yaml @@ -0,0 +1,27 @@ +schema_version: '1.0' +change: + change_id: MODEL-OUTLET-RULE-STORAGE-20261013 + change_type: requirement + requirement_id: MODEL-OUTLET-RULE-STORAGE-20261013 + title: Restore saved parameter rules without duplicated domains + description: Replace exact duplicate schema domains and saved fixed values with strict same-row references. + affected_features: + existing: [SDK-MODEL-OUTLET-STORAGE] + added: [] + modified: [SDK-MODEL-OUTLET-STORAGE] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Exact enum and numeric domain references restore the original schema while extra restrictions remain unchanged. + - Fixed false, zero and null use saved key presence; inactive rules retain their independent constraints without defaults applied. + - Missing, malformed, duplicate or inconsistent references and oversized/non-JSON data fail without changing declarations or exposing values. + reason: Existing rule tests validate effective policies, not storage reference syntax or lossless reconstruction. Pure codec proof stays in UT; no duplicate D1 is needed. + test_selectors: [test/sdk/core/model_service/test_outlet_rules.py] + no_case_change_reason: Database and runtime are not connected by this codec. Full-row and database transaction verification remain required separately; historical formal Cases are preserved. + notes: Reference syntax was documented before implementation. The change is not a complete outlet storage or migration acceptance claim. diff --git a/test-e2e/changes/requirements/MODEL-OUTLET-SCHEMA-20261013.yaml b/test-e2e/changes/requirements/MODEL-OUTLET-SCHEMA-20261013.yaml new file mode 100644 index 0000000000..76770a9dec --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-OUTLET-SCHEMA-20261013.yaml @@ -0,0 +1,28 @@ +schema_version: '1.0' +change: + change_id: MODEL-OUTLET-SCHEMA-20261013 + change_type: requirement + requirement_id: MODEL-OUTLET-SCHEMA-20261013 + title: Extend model outlet storage schema + description: Add capability/configuration columns and strict top_p capability support without legacy data conversion. + affected_features: + existing: [] + added: [BE-MODEL-OUTLET-SCHEMA] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [MOS-D5-001] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - ORM columns expose the reviewed types, unknown defaults and emitted database comments. + - top_p capability has strict supported/unsupported/unknown semantics and a versioned explicit override path. + reason: Existing unit tests do not cover outlet schema metadata or top_p; actual SQL execution and failure recovery require distinct D5 proof. + test_selectors: + - test/backend/database/test_model_outlet_schema.py + - test/sdk/core/model_service/test_profile_capabilities.py + - test/sdk/core/model_service/test_composition.py + notes: Designed before schema implementation. Unit selectors now exist; formal script and actual PostgreSQL verification remain pending. No SQL execution pass is claimed. diff --git a/test-e2e/changes/requirements/MODEL-PARAMETER-LAYERS-20261015.yaml b/test-e2e/changes/requirements/MODEL-PARAMETER-LAYERS-20261015.yaml new file mode 100644 index 0000000000..fa3d0d1b15 --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-PARAMETER-LAYERS-20261015.yaml @@ -0,0 +1,51 @@ +schema_version: '1.0' +change: + change_id: MODEL-PARAMETER-LAYERS-20261015 + change_type: requirement + requirement_id: MODEL-PARAMETER-LAYERS-20261015 + title: Layer explicit conversation and Agent model settings above outlet defaults + description: Reuse Agent model_params_override and conversation preferences. Apply conversation values above the corresponding Agent model values above frozen outlet defaults; scope each editor to permitted settings and retain VLM behavior. + affected_features: + existing: [BE-MODEL-OUTLET-PERSISTENCE, V5-PW-MODEL-01] + added: [] + modified: [] + retired: [] + affected_cases: + existing: [MOP-D3-001] + added: [] + modified: [PW-MODEL-01] + retired: [] + traditional_ut: + decision: strengthen + reason: Existing preference tests prove only the outlet/session merge. Add Agent-layer parameter and editor assertions; keep isolated merge validation in UT rather than duplicate D1. Existing database CAS and model onboarding journeys remain regression proof only. + behaviors: + - Explicit session values override per-model Agent values and outlet defaults without mutating either lower layer. + - False and zero are retained; clearing an override restores inheritance without copying defaults. + - Agent top_k reaches the resolved request and invalid fields/context values are rejected. + - Agent UI exposes declared common parameters and context; session UI displays inherited Agent values and saves explicit values only. + - Version-qualified short IDs suggest matching profiles and show approximate status; keyword search covers names and IDs. + - Explicit registered thinking switch formats survive storage and render the correct request body; restoration uses frozen originals. + - Compact capacity settings expose only window and maximum output; hidden inherited limits remain consistent. + - Typed request-parameter rows preserve nested values, block empty/duplicate keys and retain editable invalid drafts. + - Failed profile loading cannot apply a draft; legacy custom values and non-LLM paths are preserved. + - Uniform 28px text/password/select/numeric inputs; thinking toggle hides degree mode/value when off and prefills declared settings. + - Registered effort and budget degree presets freeze their request mapping and preserve existing provider constraints; values survive codec and switch/reset correctly. + - Unsent thread preferences remain isolated by local thread and outlet; persist before the first run using the same backend conversation, and block execution on save failure. + - Reuse upstream provider-scale sampling sliders, commit on blur/change-complete, reset by explicit deletion, and keep profile values out of the legacy generation context. + test_selectors: + - frontend/tests/component/modelSelectorPreferences.test.tsx + - frontend/tests/component/modelSelectorProfileContext.test.tsx + - frontend/tests/workbench/ConversationModelDrafts.test.ts + - frontend/tests/workbench/ModelParameterSettings.test.tsx + - frontend/tests/component/modelThinkingSettings.test.tsx + - test/backend/services/test_agent_model_parameters.py + - test/backend/services/test_agent_reasoning_service.py + - test/backend/services/test_conversation_model_preferences_service.py + - frontend/tests/component/modelParameterLayers.test.tsx + - frontend/tests/component/modelOutletEditing.test.tsx + - frontend/tests/component/requestParametersEditor.test.tsx + - frontend/tests/component/modelProfileSuggestion.test.tsx + - test/backend/services/test_model_thinking_configuration.py + - test/backend/database/test_model_outlet_db.py + no_case_change_reason: Reuse database persistence/CAS; adapt the existing onboarding UI selector and capacity expectation to the two-field form. Additional three-layer UI/API/runtime acceptance is recorded against AC-PARAM-LAYERS in the SPEC with separate evidence; unchanged formal scripts do not prove new hierarchy assertions. Browser and real runtime acceptance remain pending until executed. + notes: User authorized these new Agent overrides and precedence. No new schema or VLM changes. Final container verification remains mandatory before remote updates. diff --git a/test-e2e/changes/requirements/MODEL-PROFILE-CATALOG-20261013.yaml b/test-e2e/changes/requirements/MODEL-PROFILE-CATALOG-20261013.yaml new file mode 100644 index 0000000000..26c17cd90e --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-PROFILE-CATALOG-20261013.yaml @@ -0,0 +1,43 @@ +schema_version: '1.0' +change: + change_id: MODEL-PROFILE-CATALOG-20261013 + change_type: requirement + requirement_id: MODEL-PROFILE-CATALOG-20261013 + title: Package the pinned China-region model profile source inventory + description: Catalog source slice; no runtime consumer or support certification + is introduced. + affected_features: + existing: [] + added: + - SDK-MODEL-PROFILE-CATALOG + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - The reviewed inventory has 21 model, 21 provider-model and 3 provider files, + all with verified manifest hashes. + - Every declared base_model dependency is in the selection; TOML parses without + requiring upstream TypeScript. + - SDK wheels retain the manifest, source files, README and complete upstream MIT + license. + - Controlled TOML reader rejects path escapes, invalid manifest versions, duplicates, + missing sources, digest mismatch and malformed TOML without returning a partial + inventory. + - Read source documents preserve exact source identity and deeply immutable nested + data; loading is not semantic normalization. + reason: Isolated source/package integrity checks; later reader, normalization + and composition steps need their own component verification. + test_selectors: + - test/sdk/core/model_service/test_catalog_inventory.py + - test/sdk/core/model_service/test_source_reader.py + no_case_change_reason: No API or runtime behavior is connected. Existing Cases remain + unchanged; installed-package inspection supplies artifact evidence, not Agent + acceptance. + notes: SPEC AC-01/16 subset. Source inventory is distinct from verified support; + live-model acceptance remains pending. diff --git a/test-e2e/changes/requirements/MODEL-PROFILE-COMPOSITION-20261013.yaml b/test-e2e/changes/requirements/MODEL-PROFILE-COMPOSITION-20261013.yaml new file mode 100644 index 0000000000..4597545e94 --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-PROFILE-COMPOSITION-20261013.yaml @@ -0,0 +1,45 @@ +schema_version: '1.0' +change: + change_id: MODEL-PROFILE-COMPOSITION-20261013 + change_type: requirement + requirement_id: MODEL-PROFILE-COMPOSITION-20261013 + title: Compose model profiles with explicit Nexent supplements + description: Keep copied source data separate from Nexent roles, parameter permissions + and provider bindings; compose exact drafts with conflict diagnostics. + affected_features: + existing: [] + added: + - SDK-MODEL-PROFILE-COMPOSITION + modified: [] + retired: [] + affected_cases: + existing: + - MPN-D1-001 + added: + - MPC-D1-001 + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Strict profile contracts and registered traditional instruction mapping reject + unknown fields/versions/unsupported roles. + - Supplement parsing, explicit conflict handling and revision inputs are deterministic + and preserve caller-owned data. + - Body extras use exact JSON Pointer semantics, reject owned/overlapping paths + and invalid arrays, preserve inputs, and enforce UTF-8 size/depth/leaf limits. + - Supplement declarations validate controlled paths, hashes, exact source ownership, + versions and atom links without exposing file contents. Cross-atom condition + validation remains a composition obligation. + reason: Existing contracts and parameter tests do not validate complete profile + definitions, supplements or selection composition. Keep isolated assertions + in UT and verify actual connected component behavior separately. + test_selectors: + - test/sdk/core/model_service/test_instruction_mapping.py + - test/sdk/core/model_service/test_request_parameters.py + - test/sdk/core/model_service/test_supplements.py + - test/sdk/core/model_service/test_profile_capabilities.py + - test/sdk/core/model_service/test_composition.py + - test/sdk/core/model_service/test_parameter_bindings.py + notes: Design before component wiring. D2/D3/D4 and deployed outlet acceptance remain + later service/runtime slices. diff --git a/test-e2e/changes/requirements/MODEL-PROFILE-CONTRACTS-20261013.yaml b/test-e2e/changes/requirements/MODEL-PROFILE-CONTRACTS-20261013.yaml new file mode 100644 index 0000000000..e260eb7bf3 --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-PROFILE-CONTRACTS-20261013.yaml @@ -0,0 +1,28 @@ +schema_version: '1.0' +change: + change_id: MODEL-PROFILE-CONTRACTS-20261013 + change_type: requirement + requirement_id: MODEL-PROFILE-CONTRACTS-20261013 + title: Add strict model profile contract primitives + description: First local implementation slice of the approved model-access / NativeAgent SPEC; no existing runtime is connected or modified. + affected_features: + existing: [] + added: [SDK-MODEL-PROFILE-CONTRACTS] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Unknown capacity remains null and known limits reject booleans, numeric strings, zero, negatives and service limits above the combined window. + - Toggle, effort and budget controls preserve explicitly declared values and reject duplicate modes/keys, invalid types, ranges, unknown fields and unsupported-with-controls. + - Source references retain exact identity/digests, reject unsafe paths and round-trip through strict JSON validation without loading external sources. + reason: These are isolated domain validation contracts; existing model UI/runtime Cases do not exercise them, and a duplicate formal D1 would add no component evidence. Catalog parsing/composition and live runtime verification belong to subsequent slices. + test_selectors: + - test/sdk/core/model_service/test_profile_contracts.py + no_case_change_reason: No API, provider, database, tool execution or user journey is connected in this slice. Preserve all historical Cases; formal integration coverage will be added with the owning implementation. + notes: Implements only profile primitives under SPEC AC-01/02. Overall feature acceptance remains pending; neither formal runtime acceptance nor model compatibility is claimed. diff --git a/test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml b/test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml new file mode 100644 index 0000000000..22cb8c587c --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-PROFILE-NORMALIZATION-20261013.yaml @@ -0,0 +1,56 @@ +schema_version: '1.0' +change: + change_id: MODEL-PROFILE-NORMALIZATION-20261013 + change_type: requirement + requirement_id: MODEL-PROFILE-NORMALIZATION-20261013 + title: Normalize pinned model profile sources without invented capability defaults + description: Incremental source inheritance and normalization layer; first contract + step preserves absent budget bounds. + affected_features: + existing: + - SDK-MODEL-PROFILE-CONTRACTS + added: + - SDK-MODEL-PROFILE-NORMALIZATION + modified: + - SDK-MODEL-PROFILE-CONTRACTS + retired: [] + affected_cases: + existing: [] + added: + - MPN-D1-001 + modified: [] + retired: [] + traditional_ut: + decision: strengthen + behaviors: + - Absent reasoning budget minimum or maximum remains unknown; explicit zero and + declared special values remain distinct. + - Only known bounds participate in range validation; strict type errors and JSON + round trips preserve the declared domain. + - Inheritance keeps scalar/array replacements and recursive object merging distinct, + applies omit semantics, excludes identity/license/weights/links/benchmarks from + provider inheritance, and does not mutate source documents. + - Unknown/missing base references, duplicate source identities and invalid omission + declarations fail explicitly; surviving fields retain exact source file and + path. + - Selected authored source schemas reject unknown nested fields, incorrect scalar + types, invalid dates, modalities, reasoning variants and cost tiers with exact + sanitized source paths. Raw base-model overrides allow partial objects; effective + provider-model validation requires the inherited mandatory fields and reasoning + cross-field constraints. + - Explicit effort null sentinels remain declared values; catalog tree materialization + preserves array/object/null distinctions and immutable normalized snapshots. + - Versioned mapping rules reject duplicate source identities, unknown transforms + and incompatible versions; source capabilities preserve unknown, false and zero + separately from request bindings. Each handled leaf retains mapping and source + provenance; reference metadata never becomes an API setting. + reason: Existing profile contract UT owns isolated budget validation; no component + or runtime normalization is connected in this first step. + test_selectors: + - test/sdk/core/model_service/test_profile_contracts.py + - test/sdk/core/model_service/test_source_inheritance.py + - test/sdk/core/model_service/test_source_validation.py + - test/sdk/core/model_service/test_source_mapping.py + - test/sdk/core/model_service/test_catalog_normalization.py + notes: AC-01/02 subset remains pending as a whole. This change does not infer provider + API behavior or modify a saved outlet. diff --git a/test-e2e/changes/requirements/MODEL-PROFILE-PARAMETERS-20261013.yaml b/test-e2e/changes/requirements/MODEL-PROFILE-PARAMETERS-20261013.yaml new file mode 100644 index 0000000000..b1e7608666 --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-PROFILE-PARAMETERS-20261013.yaml @@ -0,0 +1,31 @@ +schema_version: '1.0' +change: + change_id: MODEL-PROFILE-PARAMETERS-20261013 + change_type: requirement + requirement_id: MODEL-PROFILE-PARAMETERS-20261013 + title: Introduce finite profile parameter validation + description: Implement finite schema compilation, strict value validation and explicit parameter permissions/defaults/fixed values/conditions as an isolated domain policy. + affected_features: + existing: [] + added: [SDK-MODEL-PROFILE-PARAMETERS] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Declared schema keywords, nested properties/items and bounds are checked before use; unknown keywords, invalid shapes and nonfinite values fail explicitly. + - Value validation preserves null, boolean and numeric distinctions and checks enum/const, ranges, lengths, required properties and additional fields without coercion. + - Compiled schemas are immutable snapshots and errors never echo values. + - Parameter permissions, scopes, defaults, fixed values and required flags are enforced; explicit inactive parameters fail and dependency cycles are rejected. + - Conditional effective views never mutate saved values; declaration order does not alter dependency handling. + reason: Existing profile contracts validate source capabilities; they do not implement the finite configurable parameter schema. This increment is isolated domain validation, not a connected component. + test_selectors: + - test/sdk/core/model_service/test_parameter_schema.py + - test/sdk/core/model_service/test_parameter_rules.py + no_case_change_reason: Pure schema/value rules stay in UT. These pure schema/policy rules stay isolated; component proof will be designed before connecting TOML supplements and profile composition; existing formal Cases remain unchanged. + notes: This increment does not send API requests or alter saved model records. diff --git a/test-e2e/changes/requirements/MODEL-TRANSPORT-20261013.yaml b/test-e2e/changes/requirements/MODEL-TRANSPORT-20261013.yaml new file mode 100644 index 0000000000..76c7e832eb --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-TRANSPORT-20261013.yaml @@ -0,0 +1,108 @@ +schema_version: '1.0' +change: + change_id: MODEL-TRANSPORT-20261013 + change_type: requirement + requirement_id: MODEL-TRANSPORT-20261013 + title: Implement unified model transport and adapter + description: Common SSE and HTTP handling with first OpenAI-compatible response + adapter. + affected_features: + existing: + - F-171 + added: + - SDK-MODEL-TRANSPORT + modified: [] + retired: [] + affected_cases: + existing: + - UT-SDK-AUTO-74430BEE8438F486 + - UT-SDK-AUTO-9B9B36B05DA14338 + added: + - MST-D3-001 + modified: + - UT-SDK-AUTO-9A76928E3A7B1ADF + retired: [] + traditional_ut: + decision: add + behaviors: + - SSE fragmentation, fields, bounded buffers and incomplete events. + - Prepared request, adapter reconstruction and safe transport failure branches. + - Logical sync/async calls, bounded pre-output retries, credential snapshots, + run budgets and shared admission cleanup. + - Versioned protocol and authentication registration, tenant/outlet credential + ownership, and native-tool capability checks for historical messages. + - The legacy model and unified service import the same limiter implementation + and global instance after its internal relocation. + - Smolagents-compatible message/stream output, Code history role projection, + separate observer content and reasoning, and actual physical attempt numbers. + - Deferred response acceptance by the existing CoreAgent resolver, suppressed + repair output, and rollback of failed or prematurely closed visible attempts. + - Explicit legacy stop/structured-output aliases use declared parameter rules + and bindings without mutating persisted outlet defaults or inventing capabilities. + - Reuse existing W1/W2 fingerprint and identity checks; bind trusted output + caps through declarations and reject stale or cross-model budgets before send. + - Preserve the run-agent test package stub path when resolving the relocated + shared limiter; no product change or weakened runtime assertions. + - Legacy empty-response control is consumed before parameter validation; empty and + transport failures share one physical retry budget without replaying visible output. + - Common monitoring trace lifetime spans sync/async calls, retries and cleanup; + reported usage stays distinct from missing counts and legacy inferred totals. + - Code response diagnostics and cache DTOs reset across attempts; cache arithmetic + requires declared usage semantics and provider counts, with no inferred capability. + - Context rebuilds replace messages only after releasing the failed response and + permit, use at most two recoveries within the original physical attempt budget, + and retain frozen credentials, run deadline, cancellation and outer attempt identity. + - Legacy connection-only probes use saved outlet parameters through ModelService + without inventing output caps; cancellation propagates instead of reporting unavailable. + - Actual NexentAgent construction validates the supplied outlet's tenant/model/URL, + preserves frozen profile defaults and credentials, and reuses the run cancellation owner. + - Injected outlet selection validates tenant and record identity, detaches snapshots, + performs no credential or HTTP access, and keeps concurrent selections independent. + - The authored Alibaba CN Qwen3.5-plus profile declares Code/planning string stop + sequences; actual construction and HTTP preserve them without changing saved defaults. + Undeclared JSON Schema output remains rejected for this exact combination. + - The actual Alibaba CN Qwen3.5-plus output rule binds the unchanged W2 answer + budget to max_tokens; no inferred total-thinking cap or persisted default is added. + - Saved-outlet auxiliary prompt generation consumes normalized text and configured + defaults, closes streams, propagates cancellation and never adds an outer retry. + - Long-text entry points reuse the unchanged truncation preparation with the saved + outlet sender; missing capacity fails explicitly. VLM is excluded by user scope. + - Sync cancellation shuts down owned sockets but defers client close to the request + owner after blocked reads unwind; preserve late-connect cleanup and idempotence. + reason: New transport behavior is absent from profile tests; D3 adds actual protocol + collaboration. + test_selectors: + - test/sdk/core/model_service/test_sse.py + - test/sdk/core/model_service/test_http_contracts.py + - test/sdk/core/model_service/test_http_sender.py + - test/sdk/core/model_service/test_openai_chat_adapter.py + - test/sdk/core/model_service/test_openai_stream.py + - test/sdk/core/model_service/test_errors_registry.py + - test/sdk/core/model_service/test_authentication_registry.py + - test/sdk/core/model_service/test_model_service.py + - test/sdk/core/model_service/test_model_service_observation.py + - test/sdk/core/model_service/test_legacy_response_observation.py + - test/sdk/monitor/test_reported_token_usage.py + - test/sdk/core/model_service/test_legacy_chat_model.py + - test/sdk/core/model_service/test_legacy_invocation_options.py + - test/sdk/core/model_service/test_legacy_context_budget.py + - test/sdk/core/model_service/test_context_recovery.py + - test/sdk/core/model_service/test_legacy_context_recovery.py + - test/sdk/core/model_service/test_legacy_connectivity.py + - test/sdk/core/model_service/test_resource_conversion.py + - test/sdk/core/model_service/test_outlet_binding.py + - test/sdk/core/model_service/test_gateway_long_text_outlet.py + - test/sdk/core/models/test_openai_long_context_model.py + - test/sdk/core/model_service/test_prompt_generation_outlet.py + - test/backend/utils/test_llm_utils.py + - test/sdk/core/model_service/test_backend_snapshot_hydration.py + - test/backend/services/test_model_gateway_service.py + - test/backend/agents/test_create_agent_info.py + - test/sdk/core/model_service/test_gateway_resolved_model.py + - test/sdk/core/gateway/modality/test_llm_adapter.py + - test/sdk/core/model_service/test_agent_model_construction.py + - test/sdk/core/agents/test_nexent_agent.py + - test/sdk/core/models/test_model_concurrency.py + - test/sdk/core/models/test_model_concurrency_async.py + - test/sdk/core/agents/test_run_agent.py + notes: D3 planned before implementation; no duplicate D1 or real-model support claim. diff --git a/test-e2e/changes/requirements/SESSION-MODEL-UNIFY-20261020.yaml b/test-e2e/changes/requirements/SESSION-MODEL-UNIFY-20261020.yaml new file mode 100644 index 0000000000..62a75b06b7 --- /dev/null +++ b/test-e2e/changes/requirements/SESSION-MODEL-UNIFY-20261020.yaml @@ -0,0 +1,33 @@ +schema_version: '1.0' +change: + change_id: SESSION-MODEL-UNIFY-20261020 + change_type: requirement + requirement_id: SESSION-MODEL-UNIFY-20261020 + title: Unify Workbench and ordinary Agent conversation model parameters + description: Replace temporary generation controls with profile preferences, preserve shared UI and migrate saved Workbench values. + affected_features: + existing: [CHAT-MODEL-CONTROLS, BE-MODEL-OUTLET-PERSISTENCE] + added: [] + modified: [CHAT-MODEL-CONTROLS] + retired: [] + affected_cases: + existing: [MOP-D3-001] + added: [CHAT-MODEL-CONTROLS-D5-001] + modified: [CHAT-MODEL-CONTROLS-D2-001, CHAT-MODEL-CONTROLS-D2-002, CHAT-MODEL-CONTROLS-D3-001, CHAT-MODEL-CONTROLS-D3-002, CHAT-MODEL-CONTROLS-D4-001, CHAT-MODEL-CONTROLS-D4-002, CHAT-MODEL-CONTROLS-D4-003] + retired: [] + feature_contract_changed: true + traditional_ut: + decision: strengthen + reason: Selection, preference validation, CAS and root isolation already have UT; replace removed parallel DTO assertions and strengthen first-turn drafts, migration and shared paths. + behaviors: + - Empty stop is retried once within the physical request budget and never cascades into protocol repair + - Debug controls use canonical drafts without changing Agent or outlet definitions + - Both conversation modes use the same editor and canonical preference format + - First-turn intent persists with the correct conversation mode and survives rejection + - Root-only merging preserves child Agents and profile defaults + - Invalid values, permissions and CAS conflicts prevent downstream writes or runs + test_selectors: + - test/backend/services/test_run_model_controls.py + - test/backend/services/test_conversation_model_preferences_service.py + - test/backend/agents/test_workbench_generation_overlay.py + notes: User approved implementation after confirming both entrypoints are Agent conversations. Existing formal cases are retained; no new D1 duplicates UT. Runtime, browser and migration proof are distinct from unit execution. diff --git a/test-e2e/changes/test-fixes/MODEL-OUTLET-UI-20261015.yaml b/test-e2e/changes/test-fixes/MODEL-OUTLET-UI-20261015.yaml new file mode 100644 index 0000000000..26ee8e51c5 --- /dev/null +++ b/test-e2e/changes/test-fixes/MODEL-OUTLET-UI-20261015.yaml @@ -0,0 +1,25 @@ +schema_version: '1.0' +change: + change_id: MODEL-OUTLET-UI-20261015 + change_type: test-fix + title: Follow resolved LLM onboarding and editing in the existing browser journey + description: >- + Reuse PW-MODEL-01 rather than adding another case. The LLM form now saves an + outlet and checks its connection in one action; canonical edits use PATCH. + Compare actual saved capacity and request parameters before and after refresh + and blank-key editing. Preserve real provider and Agent assertions, use exact + configured identity, retain immutable provider identity when aligning an old + fixture, and clean a newly created outlet even when later validation fails. + Non-LLM onboarding and checks retain their existing paths. Product behavior + and dependencies are unchanged by this test maintenance. Browser and HTTP + observations are recorded separately from execution of the fixed runner. + affected_features: + existing: [V5-PW-MODEL-01] + added: [] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [PW-MODEL-01] + retired: [] diff --git a/test-e2e/features/BE-MODEL-OUTLET-PERSISTENCE/cases.md b/test-e2e/features/BE-MODEL-OUTLET-PERSISTENCE/cases.md new file mode 100644 index 0000000000..5cd0ff0a1f --- /dev/null +++ b/test-e2e/features/BE-MODEL-OUTLET-PERSISTENCE/cases.md @@ -0,0 +1,5 @@ +# BE-MODEL-OUTLET-PERSISTENCE 关联用例 + +此页由结构化用例生成,不手工编辑。 + +- D3 [MOP-D3-001](../../cases/MOP-D3-001/case.yaml) · active diff --git a/test-e2e/features/BE-MODEL-OUTLET-PERSISTENCE/feature.yaml b/test-e2e/features/BE-MODEL-OUTLET-PERSISTENCE/feature.yaml new file mode 100644 index 0000000000..d75553345a --- /dev/null +++ b/test-e2e/features/BE-MODEL-OUTLET-PERSISTENCE/feature.yaml @@ -0,0 +1,22 @@ +schema_version: '1.0' +module: + id: MODEL-SERVICE + name: Model service +feature: + feature_id: BE-MODEL-OUTLET-PERSISTENCE + name: Transactional resolved outlet persistence + status: planned + description: Persist and restore complete model configuration with tenant isolation and optimistic updates. + business_rules: + - rule_id: BE-MODEL-OUTLET-PERSISTENCE-RULE-01 + description: Creation and restoration use one row and a caller-owned transaction without source composition or duplicate credentials. + - rule_id: BE-MODEL-OUTLET-PERSISTENCE-RULE-02 + description: Reads and updates enforce tenant and active state; updates check expected revision and reject immutable identity/rule changes. + - rule_id: BE-MODEL-OUTLET-PERSISTENCE-RULE-03 + description: SQL failures and stale updates cannot partially write configuration; errors do not expose credentials. + - rule_id: BE-MODEL-OUTLET-PERSISTENCE-RULE-04 + description: Conversation preferences store only explicit values per outlet in the owned conversation row; independent revisions and explicit deletion never modify outlet defaults or model-visible metadata. + required_test_stages: [D3] + source: + requirement_ids: [MODEL-OUTLET-PERSISTENCE-20261013] + notes: Backend storage boundary; UI, request transport and legacy conversion remain separate work. diff --git a/test-e2e/features/BE-MODEL-OUTLET-SCHEMA/cases.md b/test-e2e/features/BE-MODEL-OUTLET-SCHEMA/cases.md new file mode 100644 index 0000000000..677e8060df --- /dev/null +++ b/test-e2e/features/BE-MODEL-OUTLET-SCHEMA/cases.md @@ -0,0 +1,5 @@ +# BE-MODEL-OUTLET-SCHEMA 关联用例 + +此页由结构化用例生成,不手工编辑。 + +- D5 [MOS-D5-001](../../cases/MOS-D5-001/case.yaml) · active diff --git a/test-e2e/features/BE-MODEL-OUTLET-SCHEMA/feature.yaml b/test-e2e/features/BE-MODEL-OUTLET-SCHEMA/feature.yaml new file mode 100644 index 0000000000..7362855be0 --- /dev/null +++ b/test-e2e/features/BE-MODEL-OUTLET-SCHEMA/feature.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' +module: + id: MODEL-SERVICE + name: Model service +feature: + feature_id: BE-MODEL-OUTLET-SCHEMA + name: Model outlet schema extension + status: planned + description: Add single-row capability and parameter storage without changing existing model configuration values. + business_rules: + - rule_id: BE-MODEL-OUTLET-SCHEMA-RULE-01 + description: Capability columns preserve unknown as NULL; schema upgrade does not infer support or copy existing values. + - rule_id: BE-MODEL-OUTLET-SCHEMA-RULE-02 + description: Types, comments and revision default match the reviewed storage contract; no atomic profile tables or redundant indexes are added. + - rule_id: BE-MODEL-OUTLET-SCHEMA-RULE-03 + description: Repeated schema execution preserves existing data and metadata; failed transactional execution rolls back and permits a normal retry. + required_test_stages: [D5] + source: + requirement_ids: [MODEL-OUTLET-SCHEMA-20261013] + notes: Schema only; saved-profile codec, runtime consumption and legacy data migration remain separate requirements. diff --git a/test-e2e/features/CHAT-MODEL-CONTROLS/cases.md b/test-e2e/features/CHAT-MODEL-CONTROLS/cases.md index 3dfd150e13..b98a65dfd8 100644 --- a/test-e2e/features/CHAT-MODEL-CONTROLS/cases.md +++ b/test-e2e/features/CHAT-MODEL-CONTROLS/cases.md @@ -9,3 +9,4 @@ - D4 [CHAT-MODEL-CONTROLS-D4-001](../../cases/CHAT-MODEL-CONTROLS-D4-001/case.yaml) · active - D4 [CHAT-MODEL-CONTROLS-D4-002](../../cases/CHAT-MODEL-CONTROLS-D4-002/case.yaml) · active - D4 [CHAT-MODEL-CONTROLS-D4-003](../../cases/CHAT-MODEL-CONTROLS-D4-003/case.yaml) · active +- D5 [CHAT-MODEL-CONTROLS-D5-001](../../cases/CHAT-MODEL-CONTROLS-D5-001/case.yaml) · active diff --git a/test-e2e/features/CHAT-MODEL-CONTROLS/feature.yaml b/test-e2e/features/CHAT-MODEL-CONTROLS/feature.yaml index 96ffa4a616..41676bedd1 100644 --- a/test-e2e/features/CHAT-MODEL-CONTROLS/feature.yaml +++ b/test-e2e/features/CHAT-MODEL-CONTROLS/feature.yaml @@ -15,8 +15,7 @@ feature: description: One validated session selection supplies initial and subsequent composers, immutable turn requests and creation snapshots. - rule_id: CHAT-MODEL-CONTROLS-RULE-003 - description: Presence-aware overrides distinguish omitted, disabled, automatic, supported effort and budget without changing - legacy model parameters. + description: Both Agent conversation modes use one per-outlet preference format and outlet to Agent to conversation merging; historical Workbench values migrate once, without retaining a second editable parameter store. - rule_id: CHAT-MODEL-CONTROLS-RULE-004 description: The selected root model supplies provider binding and capacity while preserving independent models and target Agent configuration. @@ -33,8 +32,10 @@ feature: - D2 - D3 - D4 + - D5 source: requirement_ids: + - SESSION-MODEL-UNIFY-20261020 - CHAT-MODEL-CONTROLS-20261014 - CHAT-WORKBENCH-REFINEMENTS-20261015 - CHAT-WORKBENCH-HEADER-DEFAULTS-20261015 diff --git a/test-e2e/features/SDK-MODEL-OUTLET-RESOLUTION/cases.md b/test-e2e/features/SDK-MODEL-OUTLET-RESOLUTION/cases.md new file mode 100644 index 0000000000..2f2ee9e3f7 --- /dev/null +++ b/test-e2e/features/SDK-MODEL-OUTLET-RESOLUTION/cases.md @@ -0,0 +1,5 @@ +# SDK-MODEL-OUTLET-RESOLUTION 关联用例 + +此页由结构化用例生成,不手工编辑。 + +- D1 [MPR-D1-001](../../cases/MPR-D1-001/case.yaml) · active diff --git a/test-e2e/features/SDK-MODEL-OUTLET-RESOLUTION/feature.yaml b/test-e2e/features/SDK-MODEL-OUTLET-RESOLUTION/feature.yaml new file mode 100644 index 0000000000..63b5e72f75 --- /dev/null +++ b/test-e2e/features/SDK-MODEL-OUTLET-RESOLUTION/feature.yaml @@ -0,0 +1,25 @@ +schema_version: '1.0' +module: + id: SDK-MODEL-SERVICE + name: SDK model service +feature: + feature_id: SDK-MODEL-OUTLET-RESOLUTION + name: Resolved outlet construction + status: planned + description: Connect real composition to the complete saved outlet contract. + business_rules: + - rule_id: SDK-MODEL-OUTLET-RESOLUTION-RULE-01 + description: Complete new snapshots preserve exact request identity, composed values, traditional mapping and atomic source + provenance. + - rule_id: SDK-MODEL-OUTLET-RESOLUTION-RULE-02 + description: Construction checks connection, implementation versions and protected extra fields without reapplying defaults + or retaining raw credentials. + - rule_id: SDK-MODEL-OUTLET-RESOLUTION-RULE-03 + description: Saved snapshots load without source access; mismatched catalog revisions and inconsistent selections fail + before persistence. + required_test_stages: + - D1 + source: + requirement_ids: + - MODEL-OUTLET-RESOLUTION-20261013 + notes: Save-time component boundary of SPEC AC-01/03; real inference is not claimed. diff --git a/test-e2e/features/SDK-MODEL-OUTLET-STORAGE/cases.md b/test-e2e/features/SDK-MODEL-OUTLET-STORAGE/cases.md new file mode 100644 index 0000000000..50340484f7 --- /dev/null +++ b/test-e2e/features/SDK-MODEL-OUTLET-STORAGE/cases.md @@ -0,0 +1,5 @@ +# SDK-MODEL-OUTLET-STORAGE 关联用例 + +此页由结构化用例生成,不手工编辑。 + +当前测试基线未保留关联用例。 diff --git a/test-e2e/features/SDK-MODEL-OUTLET-STORAGE/feature.yaml b/test-e2e/features/SDK-MODEL-OUTLET-STORAGE/feature.yaml new file mode 100644 index 0000000000..396997e055 --- /dev/null +++ b/test-e2e/features/SDK-MODEL-OUTLET-STORAGE/feature.yaml @@ -0,0 +1,26 @@ +schema_version: '1.0' +module: + id: SDK-MODEL-SERVICE + name: SDK model service +feature: + feature_id: SDK-MODEL-OUTLET-STORAGE + name: Resolved outlet value storage + status: planned + description: Preserve saved parameter presence and capability ownership across dedicated columns and remaining JSON. + business_rules: + - rule_id: SDK-MODEL-OUTLET-STORAGE-RULE-01 + description: Loading never applies defaults or fixed values and never removes conflicting saved values; invalid snapshots fail. + - rule_id: SDK-MODEL-OUTLET-STORAGE-RULE-02 + description: Common parameters have one dedicated column; only other values enter the parameter bag. False, zero and explicit null remain distinct from omission. + - rule_id: SDK-MODEL-OUTLET-STORAGE-RULE-03 + description: Dedicated SQL null means omission unless its allowed key is explicitly marked. Duplicate, unknown or conflicting markers and duplicate storage fail. + - rule_id: SDK-MODEL-OUTLET-STORAGE-RULE-04 + description: Capability status, context, controls and modalities live only in their dedicated columns; remaining details are strict and complete, with no inference or duplicate storage. + - rule_id: SDK-MODEL-OUTLET-STORAGE-RULE-05 + description: Exact duplicated control domains and active fixed values use strict same-row references; restoration preserves additional restrictions and never applies defaults or repairs missing values. + - rule_id: SDK-MODEL-OUTLET-STORAGE-RULE-06 + description: Complete outlet identity, connection, instruction and implementation versions restore from the same row; raw credentials, source loading and fallback are excluded. Incomplete rows and unavailable versions fail. + required_test_stages: [] + source: + requirement_ids: [MODEL-OUTLET-PARAMETER-STORAGE-20261013, MODEL-OUTLET-CAPABILITY-STORAGE-20261013, MODEL-OUTLET-RULE-STORAGE-20261013, MODEL-OUTLET-ROW-STORAGE-20261013] + notes: SDK row codec of SPEC AC-01/03. Database writer, legacy data conversion and runtime consumption are not connected yet. Those boundaries require additional integration and deployment evidence. diff --git a/test-e2e/features/SDK-MODEL-PROFILE-CATALOG/cases.md b/test-e2e/features/SDK-MODEL-PROFILE-CATALOG/cases.md new file mode 100644 index 0000000000..a1a090f4ea --- /dev/null +++ b/test-e2e/features/SDK-MODEL-PROFILE-CATALOG/cases.md @@ -0,0 +1,5 @@ +# SDK-MODEL-PROFILE-CATALOG 关联用例 + +此页由结构化用例生成,不手工编辑。 + +当前测试基线未保留关联用例。 diff --git a/test-e2e/features/SDK-MODEL-PROFILE-CATALOG/feature.yaml b/test-e2e/features/SDK-MODEL-PROFILE-CATALOG/feature.yaml new file mode 100644 index 0000000000..166c8898c9 --- /dev/null +++ b/test-e2e/features/SDK-MODEL-PROFILE-CATALOG/feature.yaml @@ -0,0 +1,22 @@ +schema_version: '1.0' +module: + id: SDK-MODEL-SERVICE + name: SDK model service +feature: + feature_id: SDK-MODEL-PROFILE-CATALOG + name: Local model profile source catalog + status: planned + description: Pinned source inventory and package resources for the upcoming local + resolver. + business_rules: + - rule_id: SDK-MODEL-PROFILE-CATALOG-RULE-01 + description: Selected authored TOML retains exact upstream bytes and complete + base_model dependencies. Nexent-authored provider files carry separate origin, version and official documentation evidence. + - rule_id: SDK-MODEL-PROFILE-CATALOG-RULE-02 + description: Inventory inclusion is not verified model support; runtime uses saved + resolved outlets. + required_test_stages: [] + source: + requirement_ids: + - MODEL-PROFILE-CATALOG-20261013 + notes: Source and package integrity only; no runtime integration. diff --git a/test-e2e/features/SDK-MODEL-PROFILE-COMPOSITION/cases.md b/test-e2e/features/SDK-MODEL-PROFILE-COMPOSITION/cases.md new file mode 100644 index 0000000000..d4381932a7 --- /dev/null +++ b/test-e2e/features/SDK-MODEL-PROFILE-COMPOSITION/cases.md @@ -0,0 +1,5 @@ +# SDK-MODEL-PROFILE-COMPOSITION 关联用例 + +此页由结构化用例生成,不手工编辑。 + +- D1 [MPC-D1-001](../../cases/MPC-D1-001/case.yaml) · active diff --git a/test-e2e/features/SDK-MODEL-PROFILE-COMPOSITION/feature.yaml b/test-e2e/features/SDK-MODEL-PROFILE-COMPOSITION/feature.yaml new file mode 100644 index 0000000000..586c7b7aff --- /dev/null +++ b/test-e2e/features/SDK-MODEL-PROFILE-COMPOSITION/feature.yaml @@ -0,0 +1,28 @@ +schema_version: '1.0' +module: + id: SDK-MODEL-SERVICE + name: SDK model service +feature: + feature_id: SDK-MODEL-PROFILE-COMPOSITION + name: Nexent profile supplements and composition + status: planned + description: Combine pinned sources with explicit versioned Nexent policy into strict + model/provider definitions and provider-model drafts without transport or persistence. + business_rules: + - rule_id: SDK-MODEL-PROFILE-COMPOSITION-RULE-01 + description: Supplemented capability, instruction and parameter declarations retain + exact source and override evidence; no inferred provider behavior. + - rule_id: SDK-MODEL-PROFILE-COMPOSITION-RULE-02 + description: Exact selections first combine upstream facts, then apply model, provider + and exact-combination supplements in that priority order before user values. + Contradictory same-layer declarations and unregistered references fail explicitly. + - rule_id: SDK-MODEL-PROFILE-COMPOSITION-RULE-03 + description: Source and supplement revisions identify immutable complete catalogs; + invalid loads never publish partial definitions. + required_test_stages: + - D1 + source: + requirement_ids: + - MODEL-PROFILE-COMPOSITION-20261013 + notes: SPEC AC-01/02/07; this component does not prove provider availability, + saved outlets or Agent execution. diff --git a/test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/cases.md b/test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/cases.md new file mode 100644 index 0000000000..903c7086ec --- /dev/null +++ b/test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/cases.md @@ -0,0 +1,5 @@ +# SDK-MODEL-PROFILE-CONTRACTS 关联用例 + +此页由结构化用例生成,不手工编辑。 + +当前测试基线未保留关联用例。 diff --git a/test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/feature.yaml b/test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/feature.yaml new file mode 100644 index 0000000000..e2a8f0ee02 --- /dev/null +++ b/test-e2e/features/SDK-MODEL-PROFILE-CONTRACTS/feature.yaml @@ -0,0 +1,31 @@ +schema_version: '1.0' +module: + id: SDK-MODEL-SERVICE + name: SDK model service +feature: + feature_id: SDK-MODEL-PROFILE-CONTRACTS + name: Model profile contract primitives + status: planned + description: Strict provider-neutral capacity, reasoning and source-reference contracts + used by the upcoming catalog and outlet layers. + business_rules: + - rule_id: SDK-MODEL-PROFILE-CONTRACTS-RULE-01 + description: Unknown capacity and capability stay distinct from explicit unsupported; + known token limits are positive strict integers. + - rule_id: SDK-MODEL-PROFILE-CONTRACTS-RULE-02 + description: Reasoning controls use declared toggle, effort or token-budget domains; + duplicates, invalid types, inconsistent support and unknown fields fail validation. + - rule_id: SDK-MODEL-PROFILE-CONTRACTS-RULE-03 + description: Atomic references carry exact source identity and digests with relative + source paths; they are traceability, not runtime recomposition instructions. + - rule_id: SDK-MODEL-PROFILE-CONTRACTS-RULE-04 + description: Undeclared reasoning budget bounds remain unknown; explicit zero, + numeric bounds and special values are preserved separately. + required_test_stages: [] + source: + requirement_ids: + - MODEL-PROFILE-CONTRACTS-20261013 + - MODEL-PROFILE-NORMALIZATION-20261013 + notes: SPEC AC-01 and AC-02 subset only; the current slice has no catalog, persistence, + provider or Agent runtime integration. Traditional UT proves these domain contracts. + Later slices supply independent component/API/runtime evidence. diff --git a/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/cases.md b/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/cases.md new file mode 100644 index 0000000000..2652e7776a --- /dev/null +++ b/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/cases.md @@ -0,0 +1,5 @@ +# SDK-MODEL-PROFILE-NORMALIZATION 关联用例 + +此页由结构化用例生成,不手工编辑。 + +- D1 [MPN-D1-001](../../cases/MPN-D1-001/case.yaml) · active diff --git a/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/feature.yaml b/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/feature.yaml new file mode 100644 index 0000000000..5dbeeb1916 --- /dev/null +++ b/test-e2e/features/SDK-MODEL-PROFILE-NORMALIZATION/feature.yaml @@ -0,0 +1,31 @@ +schema_version: '1.0' +module: + id: SDK-MODEL-SERVICE + name: SDK model service +feature: + feature_id: SDK-MODEL-PROFILE-NORMALIZATION + name: Model profile source inheritance and normalization + status: planned + description: Versioned conversion from pinned source definitions to provider-neutral + capabilities with explicit field origins. + business_rules: + - rule_id: SDK-MODEL-PROFILE-NORMALIZATION-RULE-01 + description: Pinned authored inheritance overlays objects recursively, replaces + arrays/scalars, honors omissions, excludes reference-only metadata, and leaves + original documents unchanged. + - rule_id: SDK-MODEL-PROFILE-NORMALIZATION-RULE-02 + description: Missing capabilities and bounds stay unknown; boolean abilities and + configurable values remain separate. + - rule_id: SDK-MODEL-PROFILE-NORMALIZATION-RULE-03 + description: Before submission, validate the consumed source fields and inherited constraints + with source diagnostics. Reference metadata has no detailed business validation; + runtime catalog loading converts the already checked sources without rerunning + the submission validator. + required_test_stages: + - D1 + source: + requirement_ids: + - MODEL-PROFILE-NORMALIZATION-20261013 + notes: Isolated UT does not complete component acceptance. MPN-D1-001 was + designed before connecting the catalog pipeline to prove complete publication, + provenance and revision behavior. No provider/API/database/Agent acceptance claimed. diff --git a/test-e2e/features/SDK-MODEL-PROFILE-PARAMETERS/cases.md b/test-e2e/features/SDK-MODEL-PROFILE-PARAMETERS/cases.md new file mode 100644 index 0000000000..f9c5984e54 --- /dev/null +++ b/test-e2e/features/SDK-MODEL-PROFILE-PARAMETERS/cases.md @@ -0,0 +1,5 @@ +# SDK-MODEL-PROFILE-PARAMETERS 关联用例 + +此页由结构化用例生成,不手工编辑。 + +当前测试基线未保留关联用例。 diff --git a/test-e2e/features/SDK-MODEL-PROFILE-PARAMETERS/feature.yaml b/test-e2e/features/SDK-MODEL-PROFILE-PARAMETERS/feature.yaml new file mode 100644 index 0000000000..9a1f42da04 --- /dev/null +++ b/test-e2e/features/SDK-MODEL-PROFILE-PARAMETERS/feature.yaml @@ -0,0 +1,21 @@ +schema_version: '1.0' +module: + id: SDK-MODEL-SERVICE + name: SDK model service +feature: + feature_id: SDK-MODEL-PROFILE-PARAMETERS + name: Model profile parameter rules and values + status: planned + description: Finite declarative parameter schemas, permissions and value application shared by outlet and conversation configuration. + business_rules: + - rule_id: SDK-MODEL-PROFILE-PARAMETERS-RULE-01 + description: Accept only the declared finite schema keywords and JSON values, with no remote references, scripts or coercion. + - rule_id: SDK-MODEL-PROFILE-PARAMETERS-RULE-02 + description: Missing, explicit null, false and zero remain distinct; defaults, fixed values, permissions and conditions are applied explicitly. + - rule_id: SDK-MODEL-PROFILE-PARAMETERS-RULE-03 + description: Configuration errors expose parameter paths and categories without echoing submitted or fixed values. + required_test_stages: [] + source: + requirement_ids: + - MODEL-PROFILE-PARAMETERS-20261013 + notes: Initial isolated schema validation belongs in traditional UT; composition and service contracts will receive component/API proof before wiring. No outlet, frontend or runtime acceptance claimed. diff --git a/test-e2e/features/SDK-MODEL-TRANSPORT/cases.md b/test-e2e/features/SDK-MODEL-TRANSPORT/cases.md new file mode 100644 index 0000000000..22d047b28f --- /dev/null +++ b/test-e2e/features/SDK-MODEL-TRANSPORT/cases.md @@ -0,0 +1,5 @@ +# SDK-MODEL-TRANSPORT 关联用例 + +此页由结构化用例生成,不手工编辑。 + +- D3 [MST-D3-001](../../cases/MST-D3-001/case.yaml) · active diff --git a/test-e2e/features/SDK-MODEL-TRANSPORT/feature.yaml b/test-e2e/features/SDK-MODEL-TRANSPORT/feature.yaml new file mode 100644 index 0000000000..5b685cc8db --- /dev/null +++ b/test-e2e/features/SDK-MODEL-TRANSPORT/feature.yaml @@ -0,0 +1,27 @@ +schema_version: '1.0' +module: + id: SDK-MODEL-SERVICE + name: SDK model service +feature: + feature_id: SDK-MODEL-TRANSPORT + name: Unified model HTTP transport and response adaptation + status: planned + description: Prepare and send model requests through common HTTP transport and decode + provider responses. + business_rules: + - rule_id: SDK-MODEL-TRANSPORT-RULE-01 + description: Exact prepared URL, headers and body reach the HTTP peer without + duplicate protocol roots or extra retries. + - rule_id: SDK-MODEL-TRANSPORT-RULE-02 + description: Sync and async streams preserve event order and separate reasoning, + content and complete tool calls. + - rule_id: SDK-MODEL-TRANSPORT-RULE-03 + description: Errors and incomplete streams are explicit, sanitized and release + owned resources without replay. + required_test_stages: + - D3 + source: + requirement_ids: + - MODEL-TRANSPORT-20261013 + notes: SPEC model-service boundary; controlled HTTP protocol evidence does not + qualify hosted models. diff --git a/test-e2e/infra/automation/d4/fixtures/model-controls.ts b/test-e2e/infra/automation/d4/fixtures/model-controls.ts index fd3c4d0ed9..72063c1907 100644 --- a/test-e2e/infra/automation/d4/fixtures/model-controls.ts +++ b/test-e2e/infra/automation/d4/fixtures/model-controls.ts @@ -7,11 +7,11 @@ import { makeDraft, fulfillEditorReadFixture } from "./agent-editor-reads.mjs"; export const creatorEntries = ["workbench-agent", "workbench-skill", "nl2agent", "nl2skill"] as const; export type Entry = typeof creatorEntries[number] | "workbench" | "chat"; export const levels = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]; -export const labels = ["无", "最低", "低", "中", "高", "极高", "最高"]; +export const labels = ["无", "极低", "低", "中", "高", "极高", "最高"]; export async function prepareControls(page: Page, histories: Array> = []) { const base = await prepareWorkbench(page, 1, { reasoning: true, histories }); - const state = { ...base, requests: [] as Array<{ path: string; body: any }>, + const state = { ...base, requests: [] as Array<{ path: string; body: any; effective: any; explicit: any }>, unavailable: false, delayed: false, release: () => {}, configDelay: 0, configGate: undefined as Promise | undefined, draft: { ...makeDraft(), model_ids: [1, 3], model_names: ["Default A", "Effort B"] }, @@ -20,12 +20,56 @@ export async function prepareControls(page: Page, histories: Array }>(); + const resources = new Map(); + let nextConversation = 501; + const snapshot = (id: number) => { + if (!snapshots.has(id)) snapshots.set(id, { version: 1, selected_model_id: id === 91 ? 999 : id === 92 ? 3 : null, + model_parameter_preferences: id === 92 ? { "3": { enable_thinking: true, reasoning_effort: "xhigh" } } : {} }); + return snapshots.get(id)!; + }; + const defaults = (id: number) => ({ temperature: id === 1 ? 0.6 : 1, top_p: id === 1 ? 0.85 : 1, + enable_thinking: false, ...(id === 4 ? { reasoning_budget_tokens: 2048 } : id !== 2 ? { reasoning_effort: "low" } : {}) }); + const rules = (id: number) => { + const rule = (key: string, schema: any, editable = true) => ({ key, schema, visible: true, editable, required: false, + allowed_scopes: ["conversation"], ui_group: "fixture", ...(key.startsWith("reasoning_") ? { condition: { key: "enable_thinking", op: "eq", value: true } } : {}) }); + return [rule("temperature", { type: "number", minimum: 0, maximum: 2 }), rule("top_p", { type: "number", minimum: 0, maximum: 1 }), + rule("enable_thinking", { type: "boolean" }, id !== 2), + ...(id === 4 ? [rule("reasoning_budget_tokens", { type: "integer", minimum: 1024, maximum: 8192 })] : + id !== 2 ? [rule("reasoning_effort", { type: "string", enum: levels })] : [])]; + }; await page.route("**/api/**", async route => { const path = new URL(route.request().url()).pathname; const json = (body: any, status = 200) => route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); + const selectionMatch = path.match(/^\/api\/conversation\/(\d+)\/model_preferences$/); + const preferenceMatch = path.match(/^\/api\/conversation\/(\d+)\/model_preferences\/(\d+)$/); + const outletMatch = path.match(/^\/api\/model\/outlets\/(\d+)$/); + if (outletMatch) { + const id = Number(outletMatch[1]); + return json({ data: { config_revision: 1, profile: { parameter_values: defaults(id) }, field_specs: rules(id) } }); + } + if (selectionMatch || preferenceMatch) { + const match = (selectionMatch || preferenceMatch)!; + const saved = snapshot(Number(match[1])); + if (route.request().method() === "PATCH") { + const patch = route.request().postDataJSON(); + if (patch.expected_version !== saved.version) return json({ detail: { code: "MODEL_PREFERENCE_VERSION_CONFLICT" } }, 409); + if (selectionMatch) saved.selected_model_id = patch.selected_model_id; + else { + const values = { ...saved.model_parameter_preferences[match[2]], ...patch.parameter_values }; + (patch.delete_parameters || []).forEach((key: string) => delete values[key]); + saved.model_parameter_preferences[match[2]] = values; + } + saved.version++; + } + if (selectionMatch) return json({ code: 0, data: saved }); + const id = Number(match[2]); + return json({ code: 0, data: { version: saved.version, config_revision: 1, parameter_values: saved.model_parameter_preferences[String(id)] || {}, + defaults: defaults(id), field_specs: rules(id), issues: [] } }); + } if (path === "/api/file/storage") return json({ success_count: 1, failed_count: 0, results: [{ success: true, object_name: "attachments/model-controls-fixture.txt", file_name: "model-controls-fixture.txt", url: "/api/file/storage/model-controls-fixture.txt", file_size: 16, @@ -36,8 +80,22 @@ export async function prepareControls(page: Page, histories: Array(resolve => { state.release = resolve; }); const events = path.includes("nl2skill") ? [ { type: "skill_body", content: "---\nname: model-control-fixture\ndescription: Fixture skill\n---\n# Fixture" }, @@ -46,7 +104,7 @@ export async function prepareControls(page: Page, histories: Array `data: ${JSON.stringify(e)}\n\n`).join("") }).catch(() => {}); + await route.fulfill({ status: 200, headers: conversationId ? { conversation_id: String(conversationId) } : {}, contentType: "text/event-stream", body: [...events, { type: "done", content: "" }].map(e => `data: ${JSON.stringify(e)}\n\n`).join("") }).catch(() => {}); return; } if (path === "/api/model/list") return json({ data: [model(1, "Default A", effort), model(3, "Effort B", effort), @@ -60,9 +118,15 @@ export async function prepareControls(page: Page, histories: Array h.conversation_id === id), message: [], - workbench_config: { schema_version: 3, mode: id === 92 ? "skill_create" : "generic_chat", model_id: id === 92 ? 3 : 999, - generation_config: { deep_thinking: true, thinking_effort: "xhigh" }, agent_mounts: [], skill_mounts: [] } }] }); + workbench_config: { schema_version: 4, mode: id === 92 ? "skill_create" : "generic_chat", agent_mounts: [], skill_mounts: [] } }] }); + } + const historyMatch = path.match(/^\/api\/conversation\/(\d+)$/); + if (historyMatch && snapshots.has(Number(historyMatch[1]))) { + const id = Number(historyMatch[1]); + return json({ code: 0, data: [{ conversation_id: id, conversation_title: "Fixture conversation", agent_id: 42, + message: [], workbench_config: resources.get(id), workbench_config_version: 1 }] }); } + if (path === "/api/conversation/generate_title") return json({ code: 0, data: "Fixture conversation" }); if (path === "/api/agent/search_info") return json(state.draft); if (path === "/api/agent/update") return json({ status: "success", agent_id: 42 }); if (path === "/api/agent/published_list") return json([{ ...state.draft, id: "42", current_version_no: 1 }]); @@ -89,7 +153,9 @@ export async function enter(page: Page, entry: Entry) { await page.getByRole("button", { name: "添加Skill服务", exact: true }).click(); } else { await page.goto(`${root}/zh/newchat`); - await page.getByRole("button", { name: /健康小助手 更多操作/ }).click(); + const card = page.getByRole("button", { name: /健康小助手 更多操作/ }); + await expect.poll(async () => (await card.isVisible()) || (await input(page).isVisible())).toBe(true); + if (await card.isVisible()) await card.click(); } await expect(input(page)).toBeVisible(); await expect(trigger(page)).toBeVisible(); @@ -110,17 +176,23 @@ export async function chooseModel(page: Page, name: string) { export async function thinking(page: Page, enabled: boolean, effort?: string) { await openControls(page); const toggle = settings(page).getByRole("switch"); + await expect(toggle).toBeEnabled(); if ((await toggle.getAttribute("aria-checked")) !== String(enabled)) await toggle.click(); await expect(toggle).toHaveAttribute("aria-checked", String(enabled)); if (effort) { - await settings(page).getByRole("combobox").click(); - await page.locator("[data-model-effort-popup]").getByText(effort, { exact: true }).click(); + if (effort === "自动") { + const reset = settings(page).getByRole("button", { name: "继承 Agent / 出口配置", exact: true }); + if (await reset.count()) await reset.click(); + } else { + await settings(page).getByRole("combobox").click(); + await page.locator("[data-model-effort-popup]").getByText(effort, { exact: true }).click(); + } } await page.keyboard.press("Escape"); } export async function send(page: Page, state: Awaited>, text = "Create fixture capability") { const before = state.requests.length; - await page.keyboard.press("Escape"); + if (await settings(page).isVisible()) await page.keyboard.press("Escape"); await input(page).fill(text); await composer(page).getByRole("button", { name: "发送", exact: true }).click(); await expect.poll(() => state.requests.length).toBe(before + 1); diff --git a/test-e2e/infra/automation/d4/fixtures/workbench-controls.ts b/test-e2e/infra/automation/d4/fixtures/workbench-controls.ts index a3bed0924f..41e295ad9a 100644 --- a/test-e2e/infra/automation/d4/fixtures/workbench-controls.ts +++ b/test-e2e/infra/automation/d4/fixtures/workbench-controls.ts @@ -73,7 +73,7 @@ export async function prepareWorkbench(page: Page, resourceCount = 1, options: { } else if (/^\/api\/conversation\/\d+$/.test(pathname) && options.histories) { const history = options.histories.find(item => String(item.conversation_id) === pathname.split("/").at(-1)); - result = { code: 0, data: history ? [{ ...history, message: [], workbench_config: { schema_version: 3, mode: "generic_chat", generation_config: { deep_thinking: false }, agent_mounts: [], skill_mounts: [] } }] : [] }; + result = { code: 0, data: history ? [{ ...history, message: [], workbench_config: { schema_version: 4, mode: "generic_chat", agent_mounts: [], skill_mounts: [] } }] : [] }; } else if (pathname.includes("conversation") || pathname.includes("tool")) result = { code: 0, data: [], conversations: [], tools: [] }; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(result) }); diff --git a/test-e2e/infra/automation/d4/pages/model.page.ts b/test-e2e/infra/automation/d4/pages/model.page.ts index 68983c9cff..5f1915a705 100644 --- a/test-e2e/infra/automation/d4/pages/model.page.ts +++ b/test-e2e/infra/automation/d4/pages/model.page.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + import { expect, Locator, Page } from "playwright/test"; import type { ModelAsset } from "../runner/runtime-config"; import { appPath } from "../runner/runtime-config"; @@ -12,6 +14,9 @@ const TYPE_LABEL: Record = { export class ModelPage { constructor(readonly page: Page) {} + private resolvedLlmDraft = false; + private persistedDraftStatus: number | undefined; + createdOutletId: number | undefined; private modelCard(capability: "llm" | "embedding"): Locator { const fieldKey = capability === "llm" ? "llm.main" : "embedding.embedding"; @@ -35,6 +40,9 @@ export class ModelPage { } async openAddDialog(): Promise { + this.resolvedLlmDraft = false; + this.persistedDraftStatus = undefined; + this.createdOutletId = undefined; await this.page.getByRole("button", { name: "添加模型", exact: true }).click(); const modal = this.page.getByRole("dialog", { name: "添加模型" }); await expect(modal).toBeVisible(); @@ -42,6 +50,19 @@ export class ModelPage { } async fillConfiguredModel(modal: Locator, asset: ModelAsset, displayName: string): Promise { + if (asset.capability === "llm") { + this.resolvedLlmDraft = true; + await modal.getByRole("textbox", { name: "服务地址", exact: true }).fill(asset.baseUrl); + await modal.getByRole("textbox", { name: "实际请求模型名称", exact: true }).fill(asset.model); + await modal.getByRole("textbox", { name: "显示名称", exact: true }).fill(displayName); + await modal.getByLabel("API Key", { exact: true }).fill(asset.secret); + const capacity = (label: string) => modal.locator(".ant-form-item") + .filter({ has: this.page.getByText(label, { exact: true }) }).getByRole("spinbutton"); + await capacity("上下文窗口长度(Token)").fill("200000"); + await modal.getByRole("spinbutton", { name: "最大输出长度(Token)", exact: true }).fill("4096"); + await expect(modal.getByRole("button", { name: "添加模型", exact: true })).toBeEnabled(); + return; + } const customTab = modal.getByRole("tab", { name: "自定义接入", exact: true }); await expect(customTab).toBeVisible(); await customTab.click(); @@ -67,6 +88,29 @@ export class ModelPage { } async verifyDraft(modal: Locator): Promise { + if (this.resolvedLlmDraft) { + const createdPromise = this.page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/model/outlets" && response.request().method() === "POST"); + const validationPromise = this.page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/model/outlet_validations" && response.request().method() === "POST", + { timeout: 300000 }).catch(() => null); + await modal.getByRole("button", { name: "添加模型", exact: true }).click(); + const created = await createdPromise; + if (!created.ok()) throw new Error(`resolved model create returned ${created.status()}`); + const saved = (await created.json()).data; + expect(Number.isInteger(saved.model_id) && saved.model_id > 0).toBe(true); + this.createdOutletId = saved.model_id; + this.persistedDraftStatus = created.status(); + expect(saved.profile.capabilities.context.combined_window).toBe(200000); + const outputLimit = saved.profile.capabilities.context.output_limit; + expect(outputLimit).toBe(4096); + // This form declares outlet capability, not a per-request output default. + const validation = await validationPromise; + if (!validation?.ok()) throw new Error(`resolved connectivity returned ${validation?.status() ?? "no response"}`); + expect((await validation.json()).data.ok).toBe(true); + await expect(modal).toBeHidden({ timeout: 300000 }); + return validation.status(); + } const connectivityPromise = this.page.waitForResponse((r) => r.url().includes("/api/model/temporary_healthcheck") && r.request().method() === "POST", { timeout: 300000 }); await modal.getByRole("button", { name: "校验连通性", exact: true }).click(); const connectivity = await connectivityPromise; @@ -76,6 +120,11 @@ export class ModelPage { } async submitDraft(modal: Locator, displayName: string): Promise { + if (this.resolvedLlmDraft) { + if (this.persistedDraftStatus === undefined) throw new Error("resolved draft has not been saved and checked"); + await expect(modal).toBeHidden(); + return this.persistedDraftStatus; + } // The deployed Ant button exposes the accessible name as `添 加` because // of its two-character spacing style. const addButton = modal.getByRole("button", { name: "保存", exact: true }); @@ -194,6 +243,9 @@ export class ModelPage { if (!model) throw new Error(`configured real model ${displayName} is missing`); const currentName = model.name ?? model.model_name; + if (capability === "llm" && currentName !== asset.model) { + throw new Error("configured LLM outlet identity differs from the fixed model asset; create a separate outlet"); + } const currentUrl = model.apiUrl ?? model.api_url ?? model.base_url; const currentFactory = model.source ?? model.model_factory; const expectedFactory = asset.provider === "openai-compatible" ? "OpenAI-API-Compatible" : asset.provider; @@ -204,7 +256,7 @@ export class ModelPage { // Official embedding providers may retain their product factory while the // test asset describes the common OpenAI-compatible protocol. const factoryMatches = capability === "embedding" || currentFactory === expectedFactory; - if (currentName === asset.model && urlMatches && factoryMatches) return "reused"; + if (currentName === asset.model && urlMatches && (capability === "llm" || factoryMatches)) return "reused"; const body: Record = { model_name: asset.model, @@ -212,6 +264,7 @@ export class ModelPage { api_key: asset.secret, model_factory: expectedFactory, }; + if (capability === "llm") delete body.model_factory; if (capability === "embedding" && asset.dimension) body.max_tokens = asset.dimension; const updated = await this.page.request.post(`/api/model/update?display_name=${encodeURIComponent(displayName)}`, { data: body }); if (!updated.ok()) throw new Error(`configured real model alignment returned ${updated.status()}`); @@ -255,7 +308,7 @@ export class ModelPage { await expect(row).toBeVisible(); await expect(row.getByRole("button", { name: "编辑", exact: true })).toHaveCount(1); await row.getByRole("button", { name: "编辑", exact: true }).click(); - const editor = this.page.getByRole("dialog", { name: "编辑模型" }); + const editor = this.page.getByRole("dialog", { name: /^(编辑模型|编辑模型出口)$/ }); await expect(editor).toBeVisible(); return editor; } @@ -267,9 +320,12 @@ export class ModelPage { const editor = await this.openEditDialog(displayName, capability === "llm" ? "大语言模型" : "嵌入模型"); const secret = editor.locator("input[type='password']"); await expect(secret).toHaveValue(""); - await expect(secret).toHaveAttribute("placeholder", /留空|保持|existing/i); + if (capability === "llm") await expect(editor.getByText(/留空保留原有密钥/)).toBeVisible(); + else await expect(secret).toHaveAttribute("placeholder", /留空|保持|existing/i); const saved = this.page.waitForResponse( - (response) => response.request().method() === "POST" && response.url().includes("/api/model/update"), + (response) => capability === "llm" + ? response.request().method() === "PATCH" && /\/api\/model\/outlets\/\d+$/.test(new URL(response.url()).pathname) + : response.request().method() === "POST" && response.url().includes("/api/model/update"), ); await editor.getByRole("button", { name: "保存", exact: true }).click(); const response = await saved; @@ -284,6 +340,20 @@ export class ModelPage { return body; } + async savedLlmConfiguration(displayName: string): Promise> { + const listing = await this.page.request.get("/api/model/list"); + expect(listing.ok()).toBe(true); + const model = (await listing.json()).data.find((item: any) => + (item.display_name ?? item.displayName) === displayName && (item.model_type ?? item.type) === "llm"); + expect(model).toBeTruthy(); + const response = await this.page.request.get(`/api/model/outlets/${model.model_id ?? model.id}`); + expect(response.ok()).toBe(true); + const saved = (await response.json()).data; + expect(saved.has_credential).toBe(true); + return { context: saved.profile.capabilities.context, parameters: saved.profile.parameter_values, + model: saved.profile.identity.request_model_id, baseUrl: saved.profile.connection.base_url }; + } + async deleteModel(displayName: string): Promise { const response = await this.page.request.post(`/api/model/delete?display_name=${encodeURIComponent(displayName)}`); if (!response.ok() && response.status() !== 404) throw new Error(`model cleanup returned ${response.status()}`); diff --git a/test-e2e/infra/automation/model_controls/api.py b/test-e2e/infra/automation/model_controls/api.py index 1419083692..1ecfa1bbec 100644 --- a/test-e2e/infra/automation/model_controls/api.py +++ b/test-e2e/infra/automation/model_controls/api.py @@ -84,26 +84,27 @@ def lookup(model_id, tenant_id): lambda model_name, **kwargs: BUDGET if model_name == "budget" else {"status": "unsupported", "levels": []} if model_name == "plain" else CAPABILITY) + from model_controls.profiles import frozen_fixture_profile + profiles = {row["model_id"]: frozen_fixture_profile(row, BUDGET if label == "budget" else + {"status": "unsupported"} if label == "plain" else CAPABILITY) + for label, row in records.items() if label != "embedding"} + from database import model_outlet_db + monkeypatch.setattr(model_outlet_db, "get_resolved_model_record", lambda identifier, *args, **kwargs: profiles.get(identifier)) + async def stream(request, ordinary=False): if ordinary and request.entrypoint != "workbench" and request.model_id and request.model_id not in state.configured: raise ForbiddenError("The selected model is not configured for this Agent") if ordinary and request.workbench: request.model_id = request.workbench.model_id - request.generation_config = request.workbench.generation_config run_controls.preflight_turn_controls(request, "tenant") selected = lookup(request.model_id, "tenant") if request.model_id else records["effort"] - base = create_agent_info.create_model_config_from_record(selected) - values = request.generation_config.model_dump(exclude_unset=True) if request.generation_config else {} - for legacy, canonical in (("reasoning_effort", "thinking_effort"), ("reasoning_budget_tokens", "reasoning_budget_tokens")): - value = getattr(request, legacy, None) - if value is not None: - values[canonical] = value - root = run_controls.overlay_root_model(base, values, "root") - payload = {"model_id": selected["model_id"], "enabled": root.enable_thinking, - "effort": root.reasoning_effort, "budget": root.reasoning_budget_tokens, - "temperature": root.temperature, "top_p": root.top_p} + from services.conversation_model_preferences_service import merge_model_preferences + root = merge_model_preferences(profiles[selected["model_id"]], request.model_parameter_preferences.get(str(selected["model_id"]), {})) + values = root.parameter_values + payload = {"model_id": selected["model_id"], "enabled": values.get("enable_thinking"), + "effort": values.get("reasoning_effort"), "budget": values.get("reasoning_budget_tokens"), + "temperature": values.get("temperature"), "top_p": values.get("top_p")} state.calls.append(request.model_dump(exclude_unset=True)) - async def chunks(): yield f"data: {json.dumps(payload)}\n\n" return chunks() diff --git a/test-e2e/infra/automation/model_controls/database.py b/test-e2e/infra/automation/model_controls/database.py index 199892279a..887c1e8221 100644 --- a/test-e2e/infra/automation/model_controls/database.py +++ b/test-e2e/infra/automation/model_controls/database.py @@ -44,14 +44,18 @@ def search_path(connection): raise @contextmanager - def sessions(): + def sessions(existing=None): + if existing is not None: + yield existing + return with Session(engine) as session, session.begin(): yield session original_session = client.get_db_session # Conversation DAC resolves this table lazily; load it before session routing. import importlib - importlib.import_module("database.user_tenant_db") + for module_name in ("database.user_tenant_db", "database.model_outlet_db", "database.conversation_model_preferences_db"): + importlib.import_module(module_name) for name, module in list(sys.modules.items()): if name.startswith("database.") and getattr(module, "get_db_session", None) is original_session: monkeypatch.setattr(module, "get_db_session", sessions) @@ -79,6 +83,13 @@ def sessions(): ) session.add(record) session.flush() + from model_controls.profiles import frozen_fixture_profile + from nexent.core.model_service.profiles.outlet_storage import encode_resolved_model_profile + from nexent.core.model_service.registry import model_service_references + row = {column.name: getattr(record, column.name) for column in record.__table__.columns} + profile = frozen_fixture_profile(row, BUDGET if "budget" in label else CAPABILITY) + for key, value in encode_resolved_model_profile(profile, registered_references=model_service_references()).items(): + setattr(record, key, value) models[label] = record.model_id session.add(db_models.TenantConfig( tenant_id=tenant, user_id=owner, config_key=MODEL_CONFIG_MAPPING["llm"], @@ -90,8 +101,8 @@ def sessions(): tenant_id=tenant, enabled=True, provide_run_summary=False, is_main_agent=True, group_ids="[]", ingroup_permission="PRIVATE", created_by=owner, updated_by=owner, duty_prompt="Complete the fixture task.", allow_chat_metadata=False, - constraint_prompt="", few_shots_prompt="", example_questions=[], greeting_message="", - model_params_override={str(value): {"extra_params": {}, **({"temperature": 0.56} if key == "selected-B" else {})} + example_questions=[], greeting_message="", + model_params_override={str(value): {"parameter_values": {**({"temperature": 0.56} if key == "selected-B" else {})}} for key, value in list(models.items())[:3]}, ) session.add(actor) @@ -103,7 +114,7 @@ def sessions(): tenant_id=tenant, model_ids=[models["business-C"]], max_steps=1, enabled=True, is_main_agent=False, provide_run_summary=False, group_ids="[]", ingroup_permission="PRIVATE", created_by=owner, updated_by=owner, duty_prompt="Return the fixture result.", - constraint_prompt="", few_shots_prompt="", example_questions=[], greeting_message="", + example_questions=[], greeting_message="", ) session.add(child) session.flush() @@ -119,7 +130,7 @@ def sessions(): model_ids=[models["default-A"]], max_steps=3, enabled=True, tenant_id=tenant, is_main_agent=True, provide_run_summary=False, group_ids="[]", ingroup_permission="READ_ONLY", created_by=owner, updated_by=owner, duty_prompt="Complete fixture tasks.", - constraint_prompt="", few_shots_prompt="", example_questions=[], greeting_message="", + example_questions=[], greeting_message="", )) session.flush() for typ, sub in [("agent", "create"), ("skill", "create"), ("agent", "read"), ("agent", "use")]: diff --git a/test-e2e/infra/automation/model_controls/profiles.py b/test-e2e/infra/automation/model_controls/profiles.py new file mode 100644 index 0000000000..3a2995af86 --- /dev/null +++ b/test-e2e/infra/automation/model_controls/profiles.py @@ -0,0 +1,58 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +"""Frozen, synthetic profile fixtures for the shared conversation-control contract.""" + +from services.model_outlet_migration_service import build_legacy_outlet_snapshot +from nexent.core.model_service.profiles.resolved_profile import ResolvedModelProfile + + +def frozen_fixture_profile(record, capability): + profile = build_legacy_outlet_snapshot(record, legacy_capability=capability, migrated_at="fixture") + data = profile.model_dump(mode="json") + budget = capability.get("control") == "budget_tokens" + supported = capability.get("status") == "supported" + fields = [ + ("temperature", {"type": ["number", "null"], "minimum": 0, "maximum": 2}, "/temperature"), + ("top_p", {"type": ["number", "null"], "minimum": 0, "maximum": 1}, "/top_p"), + ("enable_thinking", {"type": "boolean", **({"const": False} if not supported else {})}, "/enable_thinking"), + ] + if supported: + fields.append(("reasoning_budget_tokens", {"type": "integer", "minimum": 1024, "maximum": 8192}, "/thinking_budget") if budget else + ("reasoning_effort", {"type": "string", "enum": capability["levels"]}, "/reasoning_effort")) + replaced = {key for key, _, _ in fields} | {"reasoning_effort", "reasoning_budget_tokens"} + data["parameter_rules"] = [rule for rule in data["parameter_rules"] if rule["key"] not in replaced] + for key, schema, path in fields: + rule = {"key": key, "schema": schema, "visible": True, "editable": True, "required": False, + "allowed_scopes": ["outlet", "conversation"], "ui_group": "fixture", + "owner": "combination", "source": {"rule_id": "conversation-profile-fixture"}, + "api_binding": {"path": path, "transform_ref": "identity_v1", "transform_version": 1}} + if key.startswith("reasoning_"): + rule["condition"] = {"key": "enable_thinking", "op": "eq", "value": True} + data["parameter_rules"].append(rule) + data["parameter_values"].pop("reasoning_effort" if budget else "reasoning_budget_tokens", None) + if not supported: + data["parameter_values"].update(enable_thinking=False) + data["parameter_values"].pop("reasoning_effort", None) + data["parameter_values"].pop("reasoning_budget_tokens", None) + data["capabilities"]["reasoning"] = {"status": "supported" if supported else "unsupported", "controls": + ([{"mode": "toggle", "parameter_key": "enable_thinking", "values": [False, True]}, + {"mode": "budget_tokens", "parameter_key": "reasoning_budget_tokens", "min": 1024, "max": 8192, "special_values": []} + if budget else {"mode": "effort", "parameter_key": "reasoning_effort", "values": capability["levels"]}] + if supported else None), "response_paths": []} + return ResolvedModelProfile.model_validate(data) + + +def preference_values(generation): + """Fixture intent only; product requests always carry canonical parameter names.""" + values = {key: value for key, value in (generation or {}).items() if value is not None} + if "deep_thinking" in values: + values["enable_thinking"] = values.pop("deep_thinking") + effort = values.pop("thinking_effort", None) + if effort not in (None, "auto"): + values["reasoning_effort"] = effort + if values.get("reasoning_budget_tokens") == 0: + values.pop("reasoning_budget_tokens") + if values.get("enable_thinking") is False: + values.pop("reasoning_effort", None) + values.pop("reasoning_budget_tokens", None) + return values diff --git a/test-e2e/infra/automation/model_controls/runtime.py b/test-e2e/infra/automation/model_controls/runtime.py index 32b1b5deb8..095fd996d4 100644 --- a/test-e2e/infra/automation/model_controls/runtime.py +++ b/test-e2e/infra/automation/model_controls/runtime.py @@ -15,7 +15,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient -from apps import agent_app, skill_app +from apps import agent_app, skill_app, conversation_management_app from model_controls.database import database_fixture from model_controls.provider import Provider from services import nl2agent_service as nl2agent_service @@ -61,18 +61,30 @@ def post(self, path, label="selected-B", generation=None, workbench=False, **val if "agent" in path: body["agent_id"] = self.agent_id if generation is not None: - body["generation_config"] = generation + from model_controls.profiles import preference_values + body["model_parameter_preferences"] = {str(model_id): preference_values(generation)} if workbench: - config = {"schema_version": 3, "mode": "agent_create" if "nl2agent" in path else - "skill_create" if "nl2skill" in path else "generic_chat", "model_id": model_id, - "generation_config": generation or {"deep_thinking": True, "thinking_effort": "auto"}, + config = {"schema_version": 4, "mode": "agent_create" if "nl2agent" in path else + "skill_create" if "nl2skill" in path else "generic_chat", "agent_mounts": [], "skill_mounts": []} if "nl2" in path: body["workbench_config"] = config body["persist_history"] = True else: body.update(entrypoint="workbench", workbench=config) - return self.client.post(path, json=body, headers={"Authorization": "Bearer fixture-creator"}) + headers = {"Authorization": "Bearer fixture-creator"} + if body.get("conversation_id") and body.get("model_parameter_preferences"): + preferences = body.pop("model_parameter_preferences")[str(model_id)] + endpoint = f"/api/conversation/{body['conversation_id']}/model_preferences" + saved = self.client.get(endpoint, headers=headers) + assert saved.status_code == 200, saved.text + selected = self.client.patch(endpoint, headers=headers, json={ + "selected_model_id": model_id, "expected_version": saved.json()["data"]["version"]}) + assert selected.status_code == 200, selected.text + patched = self.client.patch(endpoint + f"/{model_id}", headers=headers, json={ + "parameter_values": preferences, "expected_version": selected.json()["data"]["version"]}) + assert patched.status_code == 200, patched.text + return self.client.post(path, json=body, headers=headers) def draft(self): from sqlalchemy import select @@ -82,6 +94,19 @@ def draft(self): return {"description": row.description, "model_ids": list(row.model_ids), "model_params_override": row.model_params_override, "business_logic_model_id": row.business_logic_model_id} + def set_outlet_defaults(self, label, values): + from database.db_models import ModelRecord + from management.services.model.adapter import restore_model_snapshot + from nexent.core.model_service.profiles.outlet_storage import encode_resolved_model_profile + from nexent.core.model_service.registry import model_service_references + with self.storage["sessions"]() as session: + record = session.get(ModelRecord, self.models[label]) + row = {column.name: getattr(record, column.name) for column in record.__table__.columns} + profile = restore_model_snapshot(row, row["tenant_id"]) + profile = profile.model_copy(update={"parameter_values": values(profile.parameter_values)}) + for key, value in encode_resolved_model_profile(profile, registered_references=model_service_references()).items(): + setattr(record, key, value) + def model_records(self): from sqlalchemy import select from database.db_models import ModelRecord @@ -116,7 +141,9 @@ def runtime_fixture(monkeypatch): from permissions import rbac old_permissions, old_initialized = rbac._ROLE_PERMISSIONS.copy(), rbac._INITIALIZED rbac.init_rbac() - monkeypatch.setattr(nl2agent_service, "get_tenant_local_mcp_server", lambda _: mcp_url) + for module in (nl2agent_service, nl2skill_service, create_agent_info): + if hasattr(module, "get_tenant_local_mcp_server"): + monkeypatch.setattr(module, "get_tenant_local_mcp_server", lambda _: mcp_url) monkeypatch.setattr(agent_app, "ENABLE_AGENT_WORKBENCH", True) monkeypatch.setattr(skill_app, "ENABLE_AGENT_WORKBENCH", True) from management.services.agent import execution as ordinary @@ -152,6 +179,7 @@ def respond(body): app = FastAPI() app.include_router(agent_app.agent_runtime_router, prefix="/api") app.include_router(skill_app.skill_creator_router, prefix="/api") + app.include_router(conversation_management_app.router, prefix="/api") with TestClient(app) as http: try: yield RuntimeFixture(storage, provider, http, runs)