-
+
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
@@ -505,7 +574,7 @@ export default function ChatPage() {
)}
@@ -543,7 +612,7 @@ export default function ChatPage() {
@@ -612,15 +681,17 @@ export default function ChatPage() {
status={
thread.error
? "error"
- : thread.isLoading
+ : displayThread.isLoading
? "streaming"
: "ready"
}
context={settings.context}
+ extraModels={SINGLE_ROUND_RAG_MODELS}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading ||
- agentHandoffPending
+ agentHandoffPending ||
+ singleRoundRag.isLoading
}
onContextChange={(context) =>
setSettings("context", context)
@@ -664,7 +735,7 @@ export default function ChatPage() {
*/}
{!isLandingState && (
)}
diff --git a/frontend-web/src/pages/ModelManagementPage.tsx b/frontend-web/src/pages/ModelManagementPage.tsx
new file mode 100644
index 00000000..567a867b
--- /dev/null
+++ b/frontend-web/src/pages/ModelManagementPage.tsx
@@ -0,0 +1,246 @@
+import {
+ BotIcon,
+ ChevronDownIcon,
+ CircleAlertIcon,
+ CpuIcon,
+ EyeIcon,
+ KeyRoundIcon,
+ LoaderCircleIcon,
+ PlusIcon,
+ RefreshCwIcon,
+ Settings2Icon,
+ SparklesIcon,
+ WrenchIcon,
+} from "lucide-react";
+import { useMemo, useState, type ReactNode } from "react";
+import {
+ Button as TButton,
+ Dialog,
+ Input,
+ InputNumber,
+ MessagePlugin,
+ Select,
+ Switch,
+ Tag,
+ Textarea,
+} from "tdesign-react";
+
+import {
+ type ManagedModel,
+ type ModelSavePayload,
+ useManagedModels,
+ useSaveManagedModel,
+ useTestManagedModel,
+} from "@/core/model-management";
+import {
+ WorkspaceBody,
+ WorkspaceContainer,
+ WorkspaceHeader,
+} from "@/components/workspace/workspace-container";
+
+type ModelDraft = ModelSavePayload & {
+ api_key: string;
+ keep_existing_key: boolean;
+ extra_settings_text: string;
+};
+
+const PROVIDER_OPTIONS = [
+ { label: "OpenAI 兼容接口", value: "langchain_openai:ChatOpenAI" },
+ { label: "Anthropic", value: "langchain_anthropic:ChatAnthropic" },
+ { label: "Google Gemini", value: "langchain_google_genai:ChatGoogleGenerativeAI" },
+ { label: "Groq", value: "langchain_groq:ChatGroq" },
+ { label: "Ollama", value: "langchain_ollama:ChatOllama" },
+];
+
+const emptyDraft = (): ModelDraft => ({
+ name: "",
+ display_name: "",
+ description: "",
+ use: "langchain_openai:ChatOpenAI",
+ model: "",
+ api_key: "",
+ keep_existing_key: false,
+ clear_api_key: false,
+ base_url: "",
+ request_timeout: 30,
+ max_retries: 2,
+ max_tokens: undefined,
+ temperature: undefined,
+ stream_usage: true,
+ supports_thinking: false,
+ supports_reasoning_effort: false,
+ supports_vision: false,
+ when_thinking_enabled: null,
+ when_thinking_disabled: null,
+ thinking: null,
+ use_responses_api: null,
+ output_version: null,
+ extra_settings: {},
+ extra_settings_text: "{}",
+});
+
+function draftFromModel(model: ManagedModel): ModelDraft {
+ return {
+ ...model,
+ api_key: "",
+ keep_existing_key: model.api_key_configured,
+ clear_api_key: false,
+ extra_settings_text: JSON.stringify(model.extra_settings ?? {}, null, 2),
+ };
+}
+
+function optionalString(value: string | null | undefined): string | null {
+ const normalized = (value ?? "").trim();
+ return normalized || null;
+}
+
+function toNumber(value: number | string | null | undefined): number | undefined {
+ if (value === "" || value === null || value === undefined) return undefined;
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : undefined;
+}
+
+function buildPayload(draft: ModelDraft): ModelSavePayload {
+ let extraSettings: Record
= {};
+ try {
+ const parsed = JSON.parse(draft.extra_settings_text || "{}") as unknown;
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
+ throw new Error("额外参数必须是 JSON 对象");
+ }
+ extraSettings = parsed as Record;
+ } catch (error) {
+ throw error instanceof Error ? error : new Error("额外参数不是有效 JSON");
+ }
+ const payload: ModelSavePayload = {
+ name: draft.name.trim(),
+ display_name: optionalString(draft.display_name),
+ description: optionalString(draft.description),
+ use: draft.use.trim(),
+ model: draft.model.trim(),
+ clear_api_key: Boolean(draft.clear_api_key),
+ base_url: optionalString(draft.base_url),
+ request_timeout: toNumber(draft.request_timeout),
+ max_retries: toNumber(draft.max_retries),
+ max_tokens: toNumber(draft.max_tokens),
+ temperature: toNumber(draft.temperature),
+ stream_usage: draft.stream_usage,
+ supports_thinking: draft.supports_thinking,
+ supports_reasoning_effort: draft.supports_reasoning_effort,
+ supports_vision: draft.supports_vision,
+ when_thinking_enabled: draft.when_thinking_enabled,
+ when_thinking_disabled: draft.when_thinking_disabled,
+ thinking: draft.thinking,
+ use_responses_api: draft.use_responses_api,
+ output_version: optionalString(draft.output_version),
+ extra_settings: extraSettings,
+ };
+ if (draft.api_key.trim()) payload.api_key = draft.api_key.trim();
+ return payload;
+}
+
+function StatCard({ icon: Icon, label, value }: { icon: typeof CpuIcon; label: string; value: number }) {
+ return (
+
+ );
+}
+
+function Field({ label, required = false, children }: { label: string; required?: boolean; children: ReactNode }) {
+ return ;
+}
+
+function ModelEditor({ model, onClose }: { model: ManagedModel | null; onClose: () => void }) {
+ const isNew = model === null;
+ const [draft, setDraft] = useState(() => model ? draftFromModel(model) : emptyDraft());
+ const [advanced, setAdvanced] = useState(false);
+ const [testResult, setTestResult] = useState<{ success: boolean; latency: number; message: string; preview?: string | null } | null>(null);
+ const save = useSaveManagedModel();
+ const test = useTestManagedModel();
+ const patch = (next: Partial) => setDraft((current) => ({ ...current, ...next }));
+
+ const handleTest = async () => {
+ try {
+ const payload = buildPayload(draft);
+ const result = await test.mutateAsync(payload);
+ setTestResult({ success: result.success, latency: result.latency_ms, message: result.message, preview: result.preview });
+ if (!result.success) return void MessagePlugin.error(`连接失败:${result.message}`);
+ MessagePlugin.success(`连接成功${result.latency_ms ? `,${result.latency_ms} ms` : ""}`);
+ } catch (error) {
+ MessagePlugin.error(error instanceof Error ? error.message : "连接测试失败");
+ }
+ };
+
+ const handleSave = async () => {
+ try {
+ const payload = buildPayload(draft);
+ if (!payload.name || !payload.use || !payload.model) {
+ return void MessagePlugin.warning("请填写模型标识、驱动与实际模型名");
+ }
+ await save.mutateAsync({ name: model?.name ?? payload.name, payload, isNew });
+ MessagePlugin.success(isNew ? "模型已添加,所有页面的模型选择器已刷新" : "模型配置已保存并立即生效");
+ onClose();
+ } catch (error) {
+ MessagePlugin.error(error instanceof Error ? error.message : "保存模型配置失败");
+ }
+ };
+
+ return (
+
+ );
+}
+
+export default function ModelManagementPage() {
+ const { data, isLoading, error, refetch, isFetching } = useManagedModels();
+ const [search, setSearch] = useState("");
+ const [filter, setFilter] = useState<"all" | "thinking" | "vision">("all");
+ const [editor, setEditor] = useState(null);
+ const models = useMemo(() => {
+ const keyword = search.trim().toLowerCase();
+ return (data?.models ?? []).filter((model) => {
+ const matchesFilter = filter === "all" || (filter === "thinking" ? model.supports_thinking : model.supports_vision);
+ const matchesSearch = !keyword || [model.name, model.display_name, model.model, model.use].filter(Boolean).join(" ").toLowerCase().includes(keyword);
+ return matchesFilter && matchesSearch;
+ });
+ }, [data?.models, filter, search]);
+ const stats = useMemo(() => ({ total: data?.models.length ?? 0, thinking: data?.models.filter((item) => item.supports_thinking).length ?? 0, vision: data?.models.filter((item) => item.supports_vision).length ?? 0 }), [data?.models]);
+ return (
+
{isLoading && 加载模型配置…
}{error && {error.message}
}{!isLoading && !error && models.length === 0 && 没有匹配的模型配置
}{!isLoading && !error && models.length > 0 && {models.map((model) =>
{model.display_name || model.name}
config.yaml{model.name}
setEditor(model)}>配置 {model.description ? {model.description}
: 未填写描述
}驱动:{model.use}模型:{model.model}
{model.api_key_configured && 已配置密钥}{model.supports_thinking && 思考}{model.supports_vision && 视觉}{model.supports_reasoning_effort && 推理档位}
)}
}{data?.config_path &&
当前配置文件:{data.config_path}{data.updated_at ? ` · 最近更新 ${new Date(data.updated_at).toLocaleString()}` : ""}
}
{editor !== null && setEditor(null)} />}
+ );
+}
diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx
index ab1b01c3..ea425f4e 100644
--- a/frontend-web/src/pages/WorkspaceRoutes.tsx
+++ b/frontend-web/src/pages/WorkspaceRoutes.tsx
@@ -43,6 +43,7 @@ import MemoryPage from './MemoryPage';
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
import ModelConcurrencySettingsPage from './ModelConcurrencySettingsPage';
+import ModelManagementPage from './ModelManagementPage';
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
import SkillEvolutionPage from './SkillEvolutionPage';
@@ -786,6 +787,18 @@ export default function WorkspaceRoutes({
)
}
/>
+
+
+
+ ) : (
+
+ )
+ }
+ />
str:
+ normalized = value.strip()
+ if not normalized:
+ raise ValueError("value cannot be empty")
+ return normalized
+
+ @field_validator("display_name", "description", "base_url", "api_key", "output_version")
+ @classmethod
+ def _trim_optional(cls, value: str | None) -> str | None:
+ return value.strip() if isinstance(value, str) else value
+
+
+class ModelTestRequest(BaseModel):
+ model: ModelUpsertRequest
+
+
+class ModelTestResponse(BaseModel):
+ success: bool
+ latency_ms: int
+ message: str
+ preview: str | None = None
+
+
+def _mask_env_source(value: Any) -> str | None:
+ if isinstance(value, str) and value.startswith("$") and len(value) > 1:
+ return value[1:]
+ return None
+
+
+def _to_admin_item(raw: dict[str, Any]) -> ModelAdminItem:
+ api_key = raw.get("api_key")
+ extra = {
+ key: copy.deepcopy(value)
+ for key, value in raw.items()
+ if key not in _PRESERVED_FIELDS
+ }
+ return ModelAdminItem(
+ name=str(raw.get("name") or ""),
+ display_name=raw.get("display_name"),
+ description=raw.get("description"),
+ use=str(raw.get("use") or ""),
+ model=str(raw.get("model") or ""),
+ base_url=raw.get("base_url"),
+ request_timeout=raw.get("request_timeout"),
+ max_retries=raw.get("max_retries"),
+ max_tokens=raw.get("max_tokens"),
+ temperature=raw.get("temperature"),
+ stream_usage=raw.get("stream_usage"),
+ supports_thinking=bool(raw.get("supports_thinking", False)),
+ supports_reasoning_effort=bool(raw.get("supports_reasoning_effort", False)),
+ supports_vision=bool(raw.get("supports_vision", False)),
+ when_thinking_enabled=copy.deepcopy(raw.get("when_thinking_enabled")),
+ when_thinking_disabled=copy.deepcopy(raw.get("when_thinking_disabled")),
+ thinking=copy.deepcopy(raw.get("thinking")),
+ use_responses_api=raw.get("use_responses_api"),
+ output_version=raw.get("output_version"),
+ extra_settings=extra,
+ api_key_configured=bool(api_key),
+ api_key_source=_mask_env_source(api_key),
+ )
+
+
+def _resolve_config_path() -> Path:
+ return AppConfig.resolve_config_path()
+
+
+def _read_config_data(path: Path) -> tuple[str, dict[str, Any], list[dict[str, Any]]]:
+ raw_text = path.read_text(encoding="utf-8")
+ try:
+ data = yaml.safe_load(raw_text) or {}
+ except yaml.YAMLError as exc:
+ raise HTTPException(status_code=500, detail=f"Unable to parse active config.yaml: {exc}") from exc
+ if not isinstance(data, dict):
+ raise HTTPException(status_code=500, detail="The active config.yaml root must be a mapping")
+ raw_models = data.get("models") or []
+ if not isinstance(raw_models, list) or not all(isinstance(item, dict) for item in raw_models):
+ raise HTTPException(status_code=500, detail="config.yaml models must be a list of mappings")
+ return raw_text, data, [copy.deepcopy(item) for item in raw_models]
+
+
+def _render_models_block(models: list[dict[str, Any]], newline: str) -> str:
+ rendered = yaml.safe_dump(
+ {"models": models},
+ allow_unicode=True,
+ sort_keys=False,
+ default_flow_style=False,
+ width=120,
+ )
+ if newline != "\n":
+ rendered = rendered.replace("\n", newline)
+ return rendered if rendered.endswith(newline) else f"{rendered}{newline}"
+
+
+def _replace_models_block(raw_text: str, models: list[dict[str, Any]]) -> str:
+ """Replace the active model entries without touching other config sections.
+
+ PyYAML does not retain comments. Keep every comment found in the old
+ model section as documentation after the newly rendered model list rather
+ than silently deleting an operator's notes or disabled examples.
+ """
+
+ block_match = _MODEL_BLOCK_RE.search(raw_text)
+ if block_match is None:
+ raise HTTPException(status_code=500, detail="The active config.yaml has no top-level models section")
+ newline = "\r\n" if "\r\n" in raw_text else "\n"
+ next_key = _TOP_LEVEL_KEY_RE.search(raw_text, block_match.end())
+ end = next_key.start() if next_key else len(raw_text)
+ previous_section = raw_text[block_match.end() : end]
+ preserved_comments = "".join(
+ line
+ for line in previous_section.splitlines(keepends=True)
+ if line.lstrip().startswith("#")
+ )
+ rendered = _render_models_block(models, newline)
+ if preserved_comments:
+ if not preserved_comments.endswith(("\n", "\r")):
+ preserved_comments = f"{preserved_comments}{newline}"
+ rendered = f"{rendered}{preserved_comments}"
+ return f"{raw_text[:block_match.start()]}{rendered}{raw_text[end:]}"
+
+
+def _backup_and_write_config(path: Path, raw_before: str, raw_after: str) -> Path:
+ backup_dir = path.parent / _BACKUP_DIR_NAME
+ backup_dir.mkdir(parents=True, exist_ok=True)
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
+ backup_path = backup_dir / f"{path.stem}.{stamp}.yaml"
+ # Persist the exact text that was read for this mutation. This makes the
+ # rollback deterministic even if another operator changes the file while
+ # the gateway is processing a request.
+ with backup_path.open("w", encoding="utf-8", newline="") as handle:
+ handle.write(raw_before)
+
+ fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
+ handle.write(raw_after)
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(temp_name, path)
+ except Exception:
+ try:
+ os.unlink(temp_name)
+ except OSError:
+ pass
+ raise
+ return backup_path
+
+
+def _restore_backup(path: Path, backup_path: Path) -> None:
+ try:
+ shutil.copy2(backup_path, path)
+ except Exception:
+ logger.exception("Failed to restore model config backup at %s", backup_path)
+
+
+def _request_to_raw_model(payload: ModelUpsertRequest, existing: dict[str, Any] | None = None) -> dict[str, Any]:
+ merged = copy.deepcopy(existing or {})
+ supplied = payload.model_dump(exclude={"extra_settings", "clear_api_key"}, exclude_none=True)
+ merged.update(supplied)
+ for key, value in payload.extra_settings.items():
+ normalized = str(key).strip()
+ if not normalized:
+ raise HTTPException(status_code=422, detail="Extra setting names cannot be blank")
+ if normalized in _EDITOR_FIELDS or normalized in {"api_key", "clear_api_key", "extra_settings"}:
+ raise HTTPException(status_code=422, detail=f"Extra setting '{normalized}' duplicates a managed field")
+ merged[normalized] = copy.deepcopy(value)
+ if payload.clear_api_key:
+ merged.pop("api_key", None)
+ elif payload.api_key is None and existing is not None and "api_key" in existing:
+ merged["api_key"] = existing["api_key"]
+
+ # Validate the raw schema without resolving `$ENV_VAR` values. Environment
+ # resolution happens during the post-write runtime reload and is rolled back
+ # if unavailable.
+ try:
+ ModelConfig.model_validate(merged)
+ except Exception as exc:
+ raise HTTPException(status_code=422, detail=f"Invalid model configuration: {exc}") from exc
+ return merged
+
+
+def _redact_text(value: str, secrets: list[str]) -> str:
+ result = value
+ for secret in secrets:
+ if secret:
+ result = result.replace(secret, "***")
+ return result[:800]
+
+
+async def _require_admin(request: Request) -> None:
+ user = getattr(request.state, "user", None)
+ if user is None:
+ user = await get_optional_user_from_request(request)
+ if user is None:
+ raise HTTPException(status_code=401, detail="Authentication is required")
+ if getattr(user, "system_role", None) != "admin":
+ raise HTTPException(status_code=403, detail="Model configuration requires an administrator")
+
+
+async def _save_models(request: Request, raw_before: str, models: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ path = _resolve_config_path()
+ raw_after = _replace_models_block(raw_before, models)
+ backup_path = await asyncio.to_thread(_backup_and_write_config, path, raw_before, raw_after)
+ try:
+ config = reload_app_config(str(path))
+ request.app.state.config = config
+ except Exception as exc:
+ logger.warning("Updated model configuration could not be activated: %s", type(exc).__name__)
+ await asyncio.to_thread(_restore_backup, path, backup_path)
+ try:
+ request.app.state.config = reload_app_config(str(path))
+ except Exception:
+ logger.exception("Failed to reload restored model config")
+ raise HTTPException(
+ status_code=422,
+ detail="The model configuration could not be activated; the previous config was restored.",
+ ) from exc
+ return models
+
+
+@router.get("", response_model=ModelAdminListResponse)
+async def list_model_configurations(request: Request) -> ModelAdminListResponse:
+ await _require_admin(request)
+ path = _resolve_config_path()
+ _, _, models = await asyncio.to_thread(_read_config_data, path)
+ updated_at = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC).isoformat()
+ return ModelAdminListResponse(
+ models=[_to_admin_item(item) for item in models],
+ config_path=str(path),
+ updated_at=updated_at,
+ )
+
+
+@router.post("", response_model=ModelAdminItem, status_code=201)
+async def create_model_configuration(body: ModelUpsertRequest, request: Request) -> ModelAdminItem:
+ await _require_admin(request)
+ async with _MUTATION_LOCK:
+ path = _resolve_config_path()
+ raw_before, _, models = await asyncio.to_thread(_read_config_data, path)
+ if any(str(item.get("name")) == body.name for item in models):
+ raise HTTPException(status_code=409, detail=f"A model named '{body.name}' already exists")
+ created = _request_to_raw_model(body)
+ await _save_models(request, raw_before, [*models, created])
+ return _to_admin_item(created)
+
+
+@router.put("/{model_name}", response_model=ModelAdminItem)
+async def update_model_configuration(model_name: str, body: ModelUpsertRequest, request: Request) -> ModelAdminItem:
+ await _require_admin(request)
+ if body.name != model_name:
+ raise HTTPException(status_code=422, detail="A model's stable name cannot be changed; edit its display name instead")
+ async with _MUTATION_LOCK:
+ path = _resolve_config_path()
+ raw_before, _, models = await asyncio.to_thread(_read_config_data, path)
+ index = next((i for i, item in enumerate(models) if str(item.get("name")) == model_name), None)
+ if index is None:
+ raise HTTPException(status_code=404, detail=f"Model '{model_name}' was not found")
+ updated = _request_to_raw_model(body, models[index])
+ next_models = [*models]
+ next_models[index] = updated
+ await _save_models(request, raw_before, next_models)
+ return _to_admin_item(updated)
+
+
+@router.post("/test", response_model=ModelTestResponse)
+async def test_model_connection(body: ModelTestRequest, request: Request) -> ModelTestResponse:
+ """Perform one bounded minimal completion without persisting the draft."""
+
+ await _require_admin(request)
+ path = _resolve_config_path()
+ _, _, models = await asyncio.to_thread(_read_config_data, path)
+ existing = next((item for item in models if str(item.get("name")) == body.model.name), None)
+ candidate = _request_to_raw_model(body.model, existing)
+ candidate_secret = str(candidate.get("api_key") or "")
+ secrets = [candidate_secret]
+ try:
+ runtime_candidate = AppConfig.resolve_env_variables(copy.deepcopy(candidate))
+ # An environment-variable API key is resolved only in memory for this
+ # request. Redact both forms because provider exceptions can include
+ # either the original `$NAME` reference or its resolved value.
+ resolved_secret = str(runtime_candidate.get("api_key") or "")
+ secrets = [candidate_secret, resolved_secret]
+ model_config = ModelConfig.model_validate(runtime_candidate)
+ base_config = request.app.state.config
+ test_config = base_config.model_copy(update={"models": [model_config]})
+ chat_model = create_chat_model(model_config.name, app_config=test_config, force_disable_thinking=True)
+ started = perf_counter()
+ response = await asyncio.wait_for(
+ chat_model.ainvoke([HumanMessage(content="Reply with exactly: OK")]),
+ timeout=min(float(candidate.get("request_timeout") or 30), 45),
+ )
+ latency_ms = round((perf_counter() - started) * 1000)
+ preview = _redact_text(str(getattr(response, "content", "OK")), secrets)
+ return ModelTestResponse(success=True, latency_ms=latency_ms, message="Connection successful", preview=preview)
+ except Exception as exc:
+ # Provider errors sometimes echo a credential. Never log the full
+ # exception or return it without redacting the submitted key.
+ logger.warning("Model connection test failed for '%s': %s", candidate.get("name"), type(exc).__name__)
+ return ModelTestResponse(
+ success=False,
+ latency_ms=0,
+ message=_redact_text(str(exc) or type(exc).__name__, secrets),
+ )
diff --git a/offline-backend-20260512/backend/tests/test_model_management.py b/offline-backend-20260512/backend/tests/test_model_management.py
new file mode 100644
index 00000000..728e6708
--- /dev/null
+++ b/offline-backend-20260512/backend/tests/test_model_management.py
@@ -0,0 +1,92 @@
+"""Focused regression tests for administrator model configuration helpers."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+import yaml
+
+from app.gateway.routers import model_management as router
+
+
+def test_replace_models_block_keeps_other_sections_and_comments() -> None:
+ raw = """config_version: 1
+models:
+ # Existing provider note
+ - name: old-model
+ use: langchain_openai:ChatOpenAI
+ model: old-model
+# Disabled provider example remains useful to operators.
+# - name: disabled-model
+# model: disabled-model
+agents:
+ default: lead_agent
+"""
+
+ replaced = router._replace_models_block(
+ raw,
+ [
+ {
+ "name": "new-model",
+ "use": "langchain_openai:ChatOpenAI",
+ "model": "new-model",
+ }
+ ],
+ )
+
+ parsed = yaml.safe_load(replaced)
+ assert parsed["models"] == [
+ {
+ "name": "new-model",
+ "use": "langchain_openai:ChatOpenAI",
+ "model": "new-model",
+ }
+ ]
+ assert parsed["agents"] == {"default": "lead_agent"}
+ assert "# Existing provider note" in replaced
+ assert "# Disabled provider example remains useful to operators." in replaced
+
+
+def test_connection_error_redaction_hides_literal_and_resolved_keys() -> None:
+ message = "provider rejected raw-secret and resolved-secret"
+ assert router._redact_text(message, ["raw-secret", "resolved-secret"]) == "provider rejected *** and ***"
+
+
+@pytest.mark.asyncio
+async def test_connection_test_uses_one_minimal_prompt_without_writing_config(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ config_path = tmp_path / "config.yaml"
+ original = "models: []\nagent: lead_agent\n"
+ config_path.write_text(original, encoding="utf-8")
+ messages_seen = []
+
+ class FakeChatModel:
+ async def ainvoke(self, messages):
+ messages_seen.extend(messages)
+ return SimpleNamespace(content="OK")
+
+ monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
+ monkeypatch.setattr(router, "create_chat_model", lambda *_args, **_kwargs: FakeChatModel())
+ request = SimpleNamespace(
+ state=SimpleNamespace(user=SimpleNamespace(system_role="admin")),
+ app=SimpleNamespace(state=SimpleNamespace(config=SimpleNamespace(model_copy=lambda **_kwargs: SimpleNamespace()))),
+ )
+ body = router.ModelTestRequest(
+ model=router.ModelUpsertRequest(
+ name="test-model",
+ use="langchain_openai:ChatOpenAI",
+ model="test-model",
+ api_key="raw-secret",
+ )
+ )
+
+ result = await router.test_model_connection(body, request)
+
+ assert result.success is True
+ assert result.preview == "OK"
+ assert len(messages_seen) == 1
+ assert messages_seen[0].content == "Reply with exactly: OK"
+ assert config_path.read_text(encoding="utf-8") == original