handleOptionClick(option.id)}
- className="dark:border-border dark:hover:bg-accent hover:bg-accent flex cursor-pointer items-center justify-between border-b border-[#D9D9D9] p-3 last:border-b-0"
- role="option"
- aria-selected={isSelected}
- >
-
- {option.icon && renderIcon(option.icon)}
-
- {option.label}
-
-
-
-
- {isSelected && (
-

- )}
+ (() => {
+ const hasGroups = filteredOptions.some((o) => !!o.group);
+ const renderOption = (option: OptionType) => {
+ const isSelected = selectedIds.has(option.id);
+ return (
+
handleOptionClick(option.id)}
+ className="dark:border-border dark:hover:bg-accent hover:bg-accent flex cursor-pointer items-center justify-between border-b border-[#D9D9D9] p-3 last:border-b-0"
+ role="option"
+ aria-selected={isSelected}
+ >
+
+ {option.icon && renderIcon(option.icon)}
+
+ {option.label}
+
+
+
+
+ {isSelected && (
+

+ )}
+
+ );
+ };
+
+ if (!hasGroups) {
+ return filteredOptions.map(renderOption);
+ }
+
+ const groupOrder: string[] = [];
+ const groupMap = new Map
();
+ filteredOptions.forEach((opt) => {
+ const key = opt.group || '';
+ if (!groupMap.has(key)) {
+ groupOrder.push(key);
+ groupMap.set(key, []);
+ }
+ groupMap.get(key)!.push(opt);
+ });
+
+ return groupOrder.map((groupKey) => (
+
+ {groupKey && (
+
+ {groupKey}
+
+ )}
+ {(groupMap.get(groupKey) || []).map(renderOption)}
- );
- })
+ ));
+ })()
)}
)}
diff --git a/frontend/src/components/SettingsBar.tsx b/frontend/src/components/SettingsBar.tsx
index 584d7483..123333f0 100644
--- a/frontend/src/components/SettingsBar.tsx
+++ b/frontend/src/components/SettingsBar.tsx
@@ -14,6 +14,7 @@ const useTabs = () => {
t('settings.analytics.label'),
t('settings.logs.label'),
t('settings.tools.label'),
+ t('settings.customModels.label'),
];
return tabs;
};
diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json
index 8bbb2b67..a2e53ca3 100644
--- a/frontend/src/locale/de.json
+++ b/frontend/src/locale/de.json
@@ -244,6 +244,71 @@
}
}
},
+ "customModels": {
+ "label": "Eigene Modelle",
+ "subtitle": "Verbinde dein eigenes OpenAI-kompatibles Modell.",
+ "addModel": "Modell hinzufügen",
+ "empty": "Keine Modelle gefunden",
+ "searchPlaceholder": "Modelle suchen...",
+ "modalSubtitle": "Verbinde einen beliebigen OpenAI-kompatiblen Endpunkt (Mistral, Together, vLLM usw.).",
+ "addTitle": "Eigenes Modell hinzufügen",
+ "editTitle": "Eigenes Modell bearbeiten",
+ "save": "Speichern",
+ "saving": "Speichern",
+ "testConnection": "Verbindung testen",
+ "testing": "Test läuft",
+ "testSuccess": "Verbindung erfolgreich.",
+ "testHintNew": "Fülle Basis-URL, Modell-ID und API-Schlüssel aus, um Verbindungstests zu aktivieren.",
+ "disabledBadge": "Deaktiviert",
+ "deleteWarning": "Möchtest du das eigene Modell \"{{modelName}}\" wirklich löschen?",
+ "actionsMenuAria": "Aktionen für {{modelName}}",
+ "modelsGroup": {
+ "builtin": "DocsGPT-Modelle",
+ "user": "Meine Modelle"
+ },
+ "actions": {
+ "edit": "Bearbeiten",
+ "delete": "Löschen"
+ },
+ "fields": {
+ "displayName": "Anzeigename",
+ "modelId": "Modell-ID",
+ "description": "Beschreibung",
+ "baseUrl": "Basis-URL",
+ "apiKey": "API-Schlüssel"
+ },
+ "placeholders": {
+ "displayName": "Mein Mistral",
+ "modelId": "z. B. mistral-large-latest",
+ "description": "Optionale Beschreibung",
+ "baseUrl": "https://api.mistral.ai/v1",
+ "apiKey": "sk-...",
+ "apiKeyEdit": "Leer lassen, um den vorhandenen Schlüssel beizubehalten"
+ },
+ "hints": {
+ "apiKeyEdit": "Leer lassen, um den vorhandenen Schlüssel beizubehalten."
+ },
+ "capabilities": {
+ "title": "Fähigkeiten",
+ "contextWindowShort": "Kontextfenster",
+ "chips": {
+ "tools": "Tools",
+ "structuredOutput": "Strukturierte Ausgabe",
+ "images": "Bilder"
+ }
+ },
+ "errors": {
+ "displayNameRequired": "Anzeigename ist erforderlich",
+ "modelIdRequired": "Modell-ID ist erforderlich",
+ "baseUrlRequired": "Basis-URL ist erforderlich",
+ "baseUrlScheme": "Basis-URL muss mit http:// oder https:// beginnen",
+ "baseUrlInvalid": "Bitte gib eine gültige URL ein",
+ "apiKeyRequired": "API-Schlüssel ist erforderlich",
+ "contextWindowRange": "Kontextfenster muss zwischen 1.000 und 10.000.000 liegen",
+ "saveFailed": "Eigenes Modell konnte nicht gespeichert werden",
+ "testFailed": "Verbindungstest fehlgeschlagen"
+ }
+ },
"scrollTabsLeft": "Tabs nach links scrollen",
"tabsAriaLabel": "Einstellungs-Tabs",
"scrollTabsRight": "Tabs nach rechts scrollen"
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index 689da277..40162b9c 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -256,6 +256,71 @@
}
}
},
+ "customModels": {
+ "label": "Custom Models",
+ "subtitle": "Bring your own OpenAI-compatible model.",
+ "addModel": "Add Model",
+ "empty": "No models found",
+ "searchPlaceholder": "Search models...",
+ "modalSubtitle": "Connect any OpenAI-compatible endpoint (Mistral, Together, vLLM, etc).",
+ "addTitle": "Add Custom Model",
+ "editTitle": "Edit Custom Model",
+ "save": "Save",
+ "saving": "Saving",
+ "testConnection": "Test connection",
+ "testing": "Testing",
+ "testSuccess": "Connection successful.",
+ "testHintNew": "Fill in Base URL, Model ID, and API key to enable connection tests.",
+ "disabledBadge": "Disabled",
+ "deleteWarning": "Are you sure you want to delete the custom model \"{{modelName}}\"?",
+ "actionsMenuAria": "Actions for {{modelName}}",
+ "modelsGroup": {
+ "builtin": "DocsGPT Models",
+ "user": "My Models"
+ },
+ "actions": {
+ "edit": "Edit",
+ "delete": "Delete"
+ },
+ "fields": {
+ "displayName": "Display name",
+ "modelId": "Model ID",
+ "description": "Description",
+ "baseUrl": "Base URL",
+ "apiKey": "API key"
+ },
+ "placeholders": {
+ "displayName": "My Mistral",
+ "modelId": "e.g. mistral-large-latest",
+ "description": "Optional description",
+ "baseUrl": "https://api.mistral.ai/v1",
+ "apiKey": "sk-...",
+ "apiKeyEdit": "Leave blank to keep existing"
+ },
+ "hints": {
+ "apiKeyEdit": "Leave blank to keep existing key."
+ },
+ "capabilities": {
+ "title": "Capabilities",
+ "contextWindowShort": "Context window",
+ "chips": {
+ "tools": "Tools",
+ "structuredOutput": "Structured output",
+ "images": "Images"
+ }
+ },
+ "errors": {
+ "displayNameRequired": "Display name is required",
+ "modelIdRequired": "Model ID is required",
+ "baseUrlRequired": "Base URL is required",
+ "baseUrlScheme": "Base URL must start with http:// or https://",
+ "baseUrlInvalid": "Please enter a valid URL",
+ "apiKeyRequired": "API key is required",
+ "contextWindowRange": "Context window must be between 1,000 and 10,000,000",
+ "saveFailed": "Failed to save custom model",
+ "testFailed": "Connection test failed"
+ }
+ },
"scrollTabsLeft": "Scroll tabs left",
"tabsAriaLabel": "Settings tabs",
"scrollTabsRight": "Scroll tabs right"
diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json
index d02308de..15b3d3d7 100644
--- a/frontend/src/locale/es.json
+++ b/frontend/src/locale/es.json
@@ -244,6 +244,71 @@
}
}
},
+ "customModels": {
+ "label": "Modelos personalizados",
+ "subtitle": "Trae tu propio modelo compatible con OpenAI.",
+ "addModel": "Añadir modelo",
+ "empty": "No se encontraron modelos",
+ "searchPlaceholder": "Buscar modelos...",
+ "modalSubtitle": "Conecta cualquier endpoint compatible con OpenAI (Mistral, Together, vLLM, etc.).",
+ "addTitle": "Añadir modelo personalizado",
+ "editTitle": "Editar modelo personalizado",
+ "save": "Guardar",
+ "saving": "Guardando",
+ "testConnection": "Probar conexión",
+ "testing": "Probando",
+ "testSuccess": "Conexión correcta.",
+ "testHintNew": "Completa URL base, ID de modelo y clave API para habilitar las pruebas de conexión.",
+ "disabledBadge": "Deshabilitado",
+ "deleteWarning": "¿Seguro que quieres eliminar el modelo personalizado \"{{modelName}}\"?",
+ "actionsMenuAria": "Acciones para {{modelName}}",
+ "modelsGroup": {
+ "builtin": "Modelos de DocsGPT",
+ "user": "Mis modelos"
+ },
+ "actions": {
+ "edit": "Editar",
+ "delete": "Eliminar"
+ },
+ "fields": {
+ "displayName": "Nombre para mostrar",
+ "modelId": "ID del modelo",
+ "description": "Descripción",
+ "baseUrl": "URL base",
+ "apiKey": "Clave API"
+ },
+ "placeholders": {
+ "displayName": "Mi Mistral",
+ "modelId": "p. ej. mistral-large-latest",
+ "description": "Descripción opcional",
+ "baseUrl": "https://api.mistral.ai/v1",
+ "apiKey": "sk-...",
+ "apiKeyEdit": "Déjalo en blanco para mantener la actual"
+ },
+ "hints": {
+ "apiKeyEdit": "Déjalo en blanco para mantener la clave actual."
+ },
+ "capabilities": {
+ "title": "Capacidades",
+ "contextWindowShort": "Ventana de contexto",
+ "chips": {
+ "tools": "Herramientas",
+ "structuredOutput": "Salida estructurada",
+ "images": "Imágenes"
+ }
+ },
+ "errors": {
+ "displayNameRequired": "El nombre para mostrar es obligatorio",
+ "modelIdRequired": "El ID del modelo es obligatorio",
+ "baseUrlRequired": "La URL base es obligatoria",
+ "baseUrlScheme": "La URL base debe empezar por http:// o https://",
+ "baseUrlInvalid": "Introduce una URL válida",
+ "apiKeyRequired": "La clave API es obligatoria",
+ "contextWindowRange": "La ventana de contexto debe estar entre 1.000 y 10.000.000",
+ "saveFailed": "No se pudo guardar el modelo personalizado",
+ "testFailed": "La prueba de conexión falló"
+ }
+ },
"scrollTabsLeft": "Desplazar pestañas a la izquierda",
"tabsAriaLabel": "Pestañas de configuración",
"scrollTabsRight": "Desplazar pestañas a la derecha"
diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json
index c6b304ab..5b028ec1 100644
--- a/frontend/src/locale/jp.json
+++ b/frontend/src/locale/jp.json
@@ -244,6 +244,71 @@
}
}
},
+ "customModels": {
+ "label": "カスタムモデル",
+ "subtitle": "OpenAI 互換のモデルを自分で接続できます。",
+ "addModel": "モデルを追加",
+ "empty": "モデルが見つかりません",
+ "searchPlaceholder": "モデルを検索...",
+ "modalSubtitle": "OpenAI 互換のエンドポイントを接続できます (Mistral、Together、vLLM など)。",
+ "addTitle": "カスタムモデルを追加",
+ "editTitle": "カスタムモデルを編集",
+ "save": "保存",
+ "saving": "保存中",
+ "testConnection": "接続をテスト",
+ "testing": "テスト中",
+ "testSuccess": "接続に成功しました。",
+ "testHintNew": "接続テストを有効にするには、ベース URL、モデル ID、API キーを入力してください。",
+ "disabledBadge": "無効",
+ "deleteWarning": "カスタムモデル「{{modelName}}」を削除してもよろしいですか?",
+ "actionsMenuAria": "{{modelName}} のアクション",
+ "modelsGroup": {
+ "builtin": "DocsGPT モデル",
+ "user": "マイモデル"
+ },
+ "actions": {
+ "edit": "編集",
+ "delete": "削除"
+ },
+ "fields": {
+ "displayName": "表示名",
+ "modelId": "モデル ID",
+ "description": "説明",
+ "baseUrl": "ベース URL",
+ "apiKey": "API キー"
+ },
+ "placeholders": {
+ "displayName": "My Mistral",
+ "modelId": "例: mistral-large-latest",
+ "description": "説明 (任意)",
+ "baseUrl": "https://api.mistral.ai/v1",
+ "apiKey": "sk-...",
+ "apiKeyEdit": "既存のキーを保持する場合は空欄のまま"
+ },
+ "hints": {
+ "apiKeyEdit": "既存のキーを保持するには空欄のままにしてください。"
+ },
+ "capabilities": {
+ "title": "機能",
+ "contextWindowShort": "コンテキストウィンドウ",
+ "chips": {
+ "tools": "ツール",
+ "structuredOutput": "構造化出力",
+ "images": "画像"
+ }
+ },
+ "errors": {
+ "displayNameRequired": "表示名は必須です",
+ "modelIdRequired": "モデル ID は必須です",
+ "baseUrlRequired": "ベース URL は必須です",
+ "baseUrlScheme": "ベース URL は http:// または https:// で始まる必要があります",
+ "baseUrlInvalid": "有効な URL を入力してください",
+ "apiKeyRequired": "API キーは必須です",
+ "contextWindowRange": "コンテキストウィンドウは 1,000 から 10,000,000 の範囲で指定してください",
+ "saveFailed": "カスタムモデルの保存に失敗しました",
+ "testFailed": "接続テストに失敗しました"
+ }
+ },
"scrollTabsLeft": "タブを左にスクロール",
"tabsAriaLabel": "設定タブ",
"scrollTabsRight": "タブを右にスクロール"
diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json
index fbd5f12a..0d299b3c 100644
--- a/frontend/src/locale/ru.json
+++ b/frontend/src/locale/ru.json
@@ -244,6 +244,71 @@
}
}
},
+ "customModels": {
+ "label": "Свои модели",
+ "subtitle": "Подключите свою модель, совместимую с OpenAI.",
+ "addModel": "Добавить модель",
+ "empty": "Модели не найдены",
+ "searchPlaceholder": "Поиск моделей...",
+ "modalSubtitle": "Подключите любой OpenAI-совместимый эндпоинт (Mistral, Together, vLLM и т. п.).",
+ "addTitle": "Добавить свою модель",
+ "editTitle": "Изменить свою модель",
+ "save": "Сохранить",
+ "saving": "Сохранение",
+ "testConnection": "Проверить соединение",
+ "testing": "Проверка",
+ "testSuccess": "Соединение установлено.",
+ "testHintNew": "Заполните Base URL, ID модели и API-ключ, чтобы включить проверку соединения.",
+ "disabledBadge": "Отключено",
+ "deleteWarning": "Удалить свою модель «{{modelName}}»?",
+ "actionsMenuAria": "Действия для {{modelName}}",
+ "modelsGroup": {
+ "builtin": "Модели DocsGPT",
+ "user": "Мои модели"
+ },
+ "actions": {
+ "edit": "Изменить",
+ "delete": "Удалить"
+ },
+ "fields": {
+ "displayName": "Отображаемое имя",
+ "modelId": "ID модели",
+ "description": "Описание",
+ "baseUrl": "Base URL",
+ "apiKey": "API-ключ"
+ },
+ "placeholders": {
+ "displayName": "Мой Mistral",
+ "modelId": "напр. mistral-large-latest",
+ "description": "Необязательное описание",
+ "baseUrl": "https://api.mistral.ai/v1",
+ "apiKey": "sk-...",
+ "apiKeyEdit": "Оставьте пустым, чтобы сохранить текущий"
+ },
+ "hints": {
+ "apiKeyEdit": "Оставьте пустым, чтобы сохранить текущий ключ."
+ },
+ "capabilities": {
+ "title": "Возможности",
+ "contextWindowShort": "Контекстное окно",
+ "chips": {
+ "tools": "Инструменты",
+ "structuredOutput": "Структурированный вывод",
+ "images": "Изображения"
+ }
+ },
+ "errors": {
+ "displayNameRequired": "Укажите отображаемое имя",
+ "modelIdRequired": "Укажите ID модели",
+ "baseUrlRequired": "Укажите Base URL",
+ "baseUrlScheme": "Base URL должен начинаться с http:// или https://",
+ "baseUrlInvalid": "Введите корректный URL",
+ "apiKeyRequired": "Укажите API-ключ",
+ "contextWindowRange": "Контекстное окно должно быть от 1 000 до 10 000 000",
+ "saveFailed": "Не удалось сохранить свою модель",
+ "testFailed": "Проверка соединения не удалась"
+ }
+ },
"scrollTabsLeft": "Прокрутить вкладки влево",
"tabsAriaLabel": "Вкладки настроек",
"scrollTabsRight": "Прокрутить вкладки вправо"
diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json
index 13fd99df..a2a8cba4 100644
--- a/frontend/src/locale/zh-TW.json
+++ b/frontend/src/locale/zh-TW.json
@@ -244,6 +244,71 @@
}
}
},
+ "customModels": {
+ "label": "自訂模型",
+ "subtitle": "接入你自己的 OpenAI 相容模型。",
+ "addModel": "新增模型",
+ "empty": "找不到模型",
+ "searchPlaceholder": "搜尋模型...",
+ "modalSubtitle": "可連接任何 OpenAI 相容端點(Mistral、Together、vLLM 等)。",
+ "addTitle": "新增自訂模型",
+ "editTitle": "編輯自訂模型",
+ "save": "儲存",
+ "saving": "儲存中",
+ "testConnection": "測試連線",
+ "testing": "測試中",
+ "testSuccess": "連線成功。",
+ "testHintNew": "請填寫 Base URL、模型 ID 與 API 金鑰以啟用連線測試。",
+ "disabledBadge": "已停用",
+ "deleteWarning": "確定要刪除自訂模型「{{modelName}}」嗎?",
+ "actionsMenuAria": "{{modelName}} 的操作",
+ "modelsGroup": {
+ "builtin": "DocsGPT 模型",
+ "user": "我的模型"
+ },
+ "actions": {
+ "edit": "編輯",
+ "delete": "刪除"
+ },
+ "fields": {
+ "displayName": "顯示名稱",
+ "modelId": "模型 ID",
+ "description": "說明",
+ "baseUrl": "Base URL",
+ "apiKey": "API 金鑰"
+ },
+ "placeholders": {
+ "displayName": "My Mistral",
+ "modelId": "例:mistral-large-latest",
+ "description": "選填說明",
+ "baseUrl": "https://api.mistral.ai/v1",
+ "apiKey": "sk-...",
+ "apiKeyEdit": "留空則保留原金鑰"
+ },
+ "hints": {
+ "apiKeyEdit": "留空以保留現有金鑰。"
+ },
+ "capabilities": {
+ "title": "功能",
+ "contextWindowShort": "脈絡視窗",
+ "chips": {
+ "tools": "工具",
+ "structuredOutput": "結構化輸出",
+ "images": "圖片"
+ }
+ },
+ "errors": {
+ "displayNameRequired": "顯示名稱為必填",
+ "modelIdRequired": "模型 ID 為必填",
+ "baseUrlRequired": "Base URL 為必填",
+ "baseUrlScheme": "Base URL 必須以 http:// 或 https:// 開頭",
+ "baseUrlInvalid": "請輸入有效的 URL",
+ "apiKeyRequired": "API 金鑰為必填",
+ "contextWindowRange": "脈絡視窗須介於 1,000 至 10,000,000 之間",
+ "saveFailed": "儲存自訂模型失敗",
+ "testFailed": "連線測試失敗"
+ }
+ },
"scrollTabsLeft": "向左捲動標籤",
"tabsAriaLabel": "設定標籤",
"scrollTabsRight": "向右捲動標籤"
diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json
index 1bbef6c5..2e9c0604 100644
--- a/frontend/src/locale/zh.json
+++ b/frontend/src/locale/zh.json
@@ -244,6 +244,71 @@
}
}
},
+ "customModels": {
+ "label": "自定义模型",
+ "subtitle": "接入你自己的 OpenAI 兼容模型。",
+ "addModel": "添加模型",
+ "empty": "未找到模型",
+ "searchPlaceholder": "搜索模型...",
+ "modalSubtitle": "可连接任何 OpenAI 兼容的接口(Mistral、Together、vLLM 等)。",
+ "addTitle": "添加自定义模型",
+ "editTitle": "编辑自定义模型",
+ "save": "保存",
+ "saving": "保存中",
+ "testConnection": "测试连接",
+ "testing": "测试中",
+ "testSuccess": "连接成功。",
+ "testHintNew": "请填写 Base URL、模型 ID 和 API 密钥以启用连接测试。",
+ "disabledBadge": "已禁用",
+ "deleteWarning": "确定要删除自定义模型「{{modelName}}」吗?",
+ "actionsMenuAria": "{{modelName}} 的操作",
+ "modelsGroup": {
+ "builtin": "DocsGPT 模型",
+ "user": "我的模型"
+ },
+ "actions": {
+ "edit": "编辑",
+ "delete": "删除"
+ },
+ "fields": {
+ "displayName": "显示名称",
+ "modelId": "模型 ID",
+ "description": "描述",
+ "baseUrl": "Base URL",
+ "apiKey": "API 密钥"
+ },
+ "placeholders": {
+ "displayName": "My Mistral",
+ "modelId": "如 mistral-large-latest",
+ "description": "可选描述",
+ "baseUrl": "https://api.mistral.ai/v1",
+ "apiKey": "sk-...",
+ "apiKeyEdit": "留空则保留原密钥"
+ },
+ "hints": {
+ "apiKeyEdit": "留空以保留现有密钥。"
+ },
+ "capabilities": {
+ "title": "功能",
+ "contextWindowShort": "上下文窗口",
+ "chips": {
+ "tools": "工具",
+ "structuredOutput": "结构化输出",
+ "images": "图片"
+ }
+ },
+ "errors": {
+ "displayNameRequired": "显示名称必填",
+ "modelIdRequired": "模型 ID 必填",
+ "baseUrlRequired": "Base URL 必填",
+ "baseUrlScheme": "Base URL 必须以 http:// 或 https:// 开头",
+ "baseUrlInvalid": "请输入有效的 URL",
+ "apiKeyRequired": "API 密钥必填",
+ "contextWindowRange": "上下文窗口必须在 1,000 至 10,000,000 之间",
+ "saveFailed": "保存自定义模型失败",
+ "testFailed": "连接测试失败"
+ }
+ },
"scrollTabsLeft": "向左滚动标签",
"tabsAriaLabel": "设置标签",
"scrollTabsRight": "向右滚动标签"
diff --git a/frontend/src/modals/CustomModelModal.tsx b/frontend/src/modals/CustomModelModal.tsx
new file mode 100644
index 00000000..61e7b2e6
--- /dev/null
+++ b/frontend/src/modals/CustomModelModal.tsx
@@ -0,0 +1,597 @@
+import { Check } from 'lucide-react';
+import { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useSelector } from 'react-redux';
+
+import customModelsService from '../api/services/customModelsService';
+import Spinner from '../components/Spinner';
+import { Input } from '../components/ui/input';
+import { Label } from '../components/ui/label';
+import { ActiveState } from '../models/misc';
+import { selectToken } from '../preferences/preferenceSlice';
+import WrapperComponent from './WrapperModal';
+
+import type { CreateCustomModelPayload, CustomModel } from '../models/types';
+
+interface CustomModelModalProps {
+ modalState: ActiveState;
+ setModalState: (state: ActiveState) => void;
+ model?: CustomModel | null;
+ onSaved: (model: CustomModel) => void;
+}
+
+interface FormState {
+ display_name: string;
+ upstream_model_id: string;
+ description: string;
+ base_url: string;
+ api_key: string;
+ supports_tools: boolean;
+ supports_structured_output: boolean;
+ supports_images: boolean;
+ context_window: number | '';
+ enabled: boolean;
+}
+
+const DEFAULT_CONTEXT_WINDOW = 128000;
+const MIN_CONTEXT_WINDOW = 1000;
+const MAX_CONTEXT_WINDOW = 10_000_000;
+
+const buildInitialFormState = (model?: CustomModel | null): FormState => {
+ if (!model) {
+ return {
+ display_name: '',
+ upstream_model_id: '',
+ description: '',
+ base_url: '',
+ api_key: '',
+ supports_tools: true,
+ supports_structured_output: true,
+ supports_images: false,
+ context_window: DEFAULT_CONTEXT_WINDOW,
+ enabled: true,
+ };
+ }
+ const attachments = Array.isArray(model.capabilities?.attachments)
+ ? model.capabilities.attachments
+ : [];
+ return {
+ display_name: model.display_name || '',
+ upstream_model_id: model.upstream_model_id || '',
+ description: model.description || '',
+ base_url: model.base_url || '',
+ api_key: '',
+ supports_tools: model.capabilities?.supports_tools ?? true,
+ supports_structured_output:
+ model.capabilities?.supports_structured_output ?? true,
+ supports_images: attachments.includes('image'),
+ context_window:
+ model.capabilities?.context_window ?? DEFAULT_CONTEXT_WINDOW,
+ enabled: model.enabled ?? true,
+ };
+};
+
+export default function CustomModelModal({
+ modalState,
+ setModalState,
+ model,
+ onSaved,
+}: CustomModelModalProps) {
+ const { t } = useTranslation();
+ const token = useSelector(selectToken);
+ const isEditMode = !!model?.id;
+
+ const [formData, setFormData] = useState
(() =>
+ buildInitialFormState(model),
+ );
+ const [errors, setErrors] = useState<{ [key: string]: string }>({});
+ const [saving, setSaving] = useState(false);
+ const [testing, setTesting] = useState(false);
+ const [testResult, setTestResult] = useState<{
+ ok: boolean;
+ message: string;
+ } | null>(null);
+
+ useEffect(() => {
+ if (modalState === 'ACTIVE') {
+ setFormData(buildInitialFormState(model));
+ setErrors({});
+ setTestResult(null);
+ setSaving(false);
+ setTesting(false);
+ }
+ }, [modalState, model]);
+
+ const closeModal = () => {
+ setModalState('INACTIVE');
+ };
+
+ const handleChange = (
+ name: K,
+ value: FormState[K],
+ ) => {
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ if (errors[name as string] || errors.general) {
+ setErrors((prev) => {
+ const next = { ...prev };
+ delete next[name as string];
+ delete next.general;
+ delete next.base_url_remote;
+ return next;
+ });
+ }
+ setTestResult(null);
+ };
+
+ const validate = (): boolean => {
+ const newErrors: { [key: string]: string } = {};
+ if (!formData.display_name.trim()) {
+ newErrors.display_name = t(
+ 'settings.customModels.errors.displayNameRequired',
+ );
+ }
+ if (!formData.upstream_model_id.trim()) {
+ newErrors.upstream_model_id = t(
+ 'settings.customModels.errors.modelIdRequired',
+ );
+ }
+ const trimmedUrl = formData.base_url.trim();
+ if (!trimmedUrl) {
+ newErrors.base_url = t('settings.customModels.errors.baseUrlRequired');
+ } else if (!/^https?:\/\//i.test(trimmedUrl)) {
+ newErrors.base_url = t('settings.customModels.errors.baseUrlScheme');
+ } else {
+ try {
+ new URL(trimmedUrl);
+ } catch {
+ newErrors.base_url = t('settings.customModels.errors.baseUrlInvalid');
+ }
+ }
+ if (!isEditMode && !formData.api_key.trim()) {
+ newErrors.api_key = t('settings.customModels.errors.apiKeyRequired');
+ }
+ const ctxValue =
+ formData.context_window === '' ? NaN : Number(formData.context_window);
+ if (
+ Number.isNaN(ctxValue) ||
+ ctxValue < MIN_CONTEXT_WINDOW ||
+ ctxValue > MAX_CONTEXT_WINDOW
+ ) {
+ newErrors.context_window = t(
+ 'settings.customModels.errors.contextWindowRange',
+ );
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const buildPayload = (): CreateCustomModelPayload => {
+ const ctxValue =
+ formData.context_window === ''
+ ? DEFAULT_CONTEXT_WINDOW
+ : Number(formData.context_window);
+ const payload: CreateCustomModelPayload = {
+ upstream_model_id: formData.upstream_model_id.trim(),
+ display_name: formData.display_name.trim(),
+ description: formData.description.trim(),
+ base_url: formData.base_url.trim(),
+ capabilities: {
+ supports_tools: formData.supports_tools,
+ supports_structured_output: formData.supports_structured_output,
+ attachments: formData.supports_images ? ['image'] : [],
+ context_window: ctxValue,
+ },
+ enabled: formData.enabled,
+ };
+ if (formData.api_key.trim()) {
+ payload.api_key = formData.api_key.trim();
+ }
+ return payload;
+ };
+
+ const mapErrorToField = (
+ message: string,
+ ): { field: string; message: string } => {
+ const lower = message.toLowerCase();
+ if (
+ lower.includes('reachable') ||
+ lower.includes('public internet') ||
+ lower.includes('ssrf') ||
+ lower.includes('url') ||
+ lower.includes('host')
+ ) {
+ return { field: 'base_url_remote', message };
+ }
+ return { field: 'general', message };
+ };
+
+ const handleSave = async () => {
+ if (!validate()) return;
+ setSaving(true);
+ setTestResult(null);
+ try {
+ const payload = buildPayload();
+ const saved = isEditMode
+ ? await customModelsService.updateCustomModel(model!.id, payload, token)
+ : await customModelsService.createCustomModel(payload, token);
+ onSaved(saved);
+ closeModal();
+ } catch (err) {
+ const message =
+ err instanceof Error
+ ? err.message
+ : t('settings.customModels.errors.saveFailed');
+ const mapped = mapErrorToField(message);
+ setErrors((prev) => ({ ...prev, [mapped.field]: mapped.message }));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ // Edit mode allows blank api_key (by-id endpoint falls back to stored).
+ const trimmedBaseUrl = formData.base_url.trim();
+ const trimmedApiKey = formData.api_key.trim();
+ const trimmedUpstreamId = formData.upstream_model_id.trim();
+ const canTest = isEditMode
+ ? !!(trimmedBaseUrl && trimmedUpstreamId)
+ : !!(trimmedBaseUrl && trimmedApiKey && trimmedUpstreamId);
+ const testDisabledHint = canTest
+ ? undefined
+ : t('settings.customModels.testHintNew');
+
+ const handleTest = async () => {
+ if (!canTest) return;
+ setTesting(true);
+ setTestResult(null);
+ try {
+ const result =
+ isEditMode && model?.id
+ ? await customModelsService.testCustomModel(model.id, token, {
+ base_url: trimmedBaseUrl,
+ api_key: trimmedApiKey,
+ upstream_model_id: trimmedUpstreamId,
+ })
+ : await customModelsService.testCustomModelPayload(
+ {
+ base_url: trimmedBaseUrl,
+ api_key: trimmedApiKey,
+ upstream_model_id: trimmedUpstreamId,
+ },
+ token,
+ );
+ if (result.ok) {
+ setTestResult({
+ ok: true,
+ message: t('settings.customModels.testSuccess'),
+ });
+ } else {
+ const message =
+ result.error || t('settings.customModels.errors.testFailed');
+ setTestResult({ ok: false, message });
+ const mapped = mapErrorToField(message);
+ if (mapped.field === 'base_url_remote') {
+ setErrors((prev) => ({ ...prev, base_url_remote: message }));
+ }
+ }
+ } catch (err) {
+ const message =
+ err instanceof Error
+ ? err.message
+ : t('settings.customModels.errors.testFailed');
+ setTestResult({ ok: false, message });
+ } finally {
+ setTesting(false);
+ }
+ };
+
+ if (modalState !== 'ACTIVE') return null;
+
+ return (
+
+
+
+
+ {isEditMode
+ ? t('settings.customModels.editTitle')
+ : t('settings.customModels.addTitle')}
+
+
+ {t('settings.customModels.modalSubtitle')}
+
+
+
+
+ {/* Row 1: Display name + Model ID side-by-side */}
+
+
+
+
handleChange('display_name', e.target.value)}
+ placeholder={t(
+ 'settings.customModels.placeholders.displayName',
+ )}
+ aria-invalid={!!errors.display_name || undefined}
+ className="rounded-xl"
+ />
+ {errors.display_name && (
+
+ {errors.display_name}
+
+ )}
+
+
+
+
+
+ handleChange('upstream_model_id', e.target.value)
+ }
+ placeholder={t('settings.customModels.placeholders.modelId')}
+ aria-invalid={!!errors.upstream_model_id || undefined}
+ className="rounded-xl"
+ />
+ {errors.upstream_model_id && (
+
+ {errors.upstream_model_id}
+
+ )}
+
+
+
+ {/* Row 2: Base URL + API key side-by-side */}
+
+
+
+
handleChange('base_url', e.target.value)}
+ placeholder={t('settings.customModels.placeholders.baseUrl')}
+ aria-invalid={
+ !!errors.base_url || !!errors.base_url_remote || undefined
+ }
+ className="rounded-xl"
+ />
+ {errors.base_url && (
+
{errors.base_url}
+ )}
+ {errors.base_url_remote && (
+
+ {errors.base_url_remote}
+
+ )}
+
+
+
+
+
handleChange('api_key', e.target.value)}
+ placeholder={
+ isEditMode
+ ? t('settings.customModels.placeholders.apiKeyEdit')
+ : t('settings.customModels.placeholders.apiKey')
+ }
+ aria-invalid={!!errors.api_key || undefined}
+ className="rounded-xl"
+ />
+ {isEditMode && (
+
+ {t('settings.customModels.hints.apiKeyEdit')}
+
+ )}
+ {errors.api_key && (
+
{errors.api_key}
+ )}
+
+
+
+ {/* Row 3: Description (full width, optional) */}
+
+
+ handleChange('description', e.target.value)}
+ placeholder={t(
+ 'settings.customModels.placeholders.description',
+ )}
+ className="rounded-xl"
+ />
+
+
+ {/* Row 4: Capabilities — flat (no border), chips + inline ctx */}
+
+
+
+
+ handleChange('supports_tools', !formData.supports_tools)
+ }
+ />
+
+ handleChange(
+ 'supports_structured_output',
+ !formData.supports_structured_output,
+ )
+ }
+ />
+
+ handleChange('supports_images', !formData.supports_images)
+ }
+ />
+
+
+
+
{
+ const v = e.target.value;
+ if (v === '') {
+ handleChange('context_window', '');
+ } else {
+ const n = parseInt(v, 10);
+ if (!Number.isNaN(n)) {
+ handleChange('context_window', n);
+ }
+ }
+ }}
+ aria-invalid={!!errors.context_window || undefined}
+ className="w-full rounded-xl sm:w-40"
+ />
+ {errors.context_window && (
+
+ {errors.context_window}
+
+ )}
+
+
+
+ {testResult && (
+
+ {testResult.message}
+
+ )}
+
+ {errors.general && (
+
+ {errors.general}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+interface CapabilityChipProps {
+ label: string;
+ active: boolean;
+ onClick: () => void;
+}
+
+function CapabilityChip({ label, active, onClick }: CapabilityChipProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/models/types.ts b/frontend/src/models/types.ts
index 04946dec..cf8502fd 100644
--- a/frontend/src/models/types.ts
+++ b/frontend/src/models/types.ts
@@ -1,3 +1,5 @@
+export type ModelSource = 'builtin' | 'user';
+
export interface AvailableModel {
id: string;
provider: string;
@@ -9,6 +11,7 @@ export interface AvailableModel {
supports_structured_output: boolean;
supports_streaming: boolean;
enabled: boolean;
+ source?: ModelSource;
}
export interface Model {
@@ -22,4 +25,38 @@ export interface Model {
supports_tools: boolean;
supports_structured_output: boolean;
supports_streaming: boolean;
+ source?: ModelSource;
+}
+
+export interface CustomModelCapabilities {
+ supports_tools: boolean;
+ supports_structured_output: boolean;
+ attachments: string[];
+ context_window: number;
+}
+
+export interface CustomModel {
+ id: string;
+ upstream_model_id: string;
+ display_name: string;
+ description?: string;
+ base_url: string;
+ capabilities: CustomModelCapabilities;
+ enabled: boolean;
+ source: 'user';
+}
+
+export interface CreateCustomModelPayload {
+ upstream_model_id: string;
+ display_name: string;
+ description?: string;
+ base_url: string;
+ api_key?: string;
+ capabilities: CustomModelCapabilities;
+ enabled: boolean;
+}
+
+export interface CustomModelTestResult {
+ ok: boolean;
+ error?: string;
}
diff --git a/frontend/src/preferences/preferenceSlice.ts b/frontend/src/preferences/preferenceSlice.ts
index 41b8cc31..d674c910 100644
--- a/frontend/src/preferences/preferenceSlice.ts
+++ b/frontend/src/preferences/preferenceSlice.ts
@@ -250,6 +250,22 @@ prefListenerMiddleware.startListening({
},
});
+// Reconcile selectedModel when availableModels changes so a deleted
+// BYOM doesn't leave a stale id in localStorage.
+prefListenerMiddleware.startListening({
+ matcher: isAnyOf(setAvailableModels),
+ effect: (_action, listenerApi) => {
+ const state = listenerApi.getState() as RootState;
+ const { selectedModel, availableModels } = state.preference;
+ if (!availableModels.length) return;
+ if (!selectedModel) return;
+ const stillValid = availableModels.some((m) => m.id === selectedModel.id);
+ if (!stillValid) {
+ listenerApi.dispatch(setSelectedModel(availableModels[0]));
+ }
+ },
+});
+
export const selectApiKey = (state: RootState) => state.preference.apiKey;
export const selectApiKeyStatus = (state: RootState) =>
!!state.preference.apiKey;
diff --git a/frontend/src/settings/CustomModels.tsx b/frontend/src/settings/CustomModels.tsx
new file mode 100644
index 00000000..0bee8794
--- /dev/null
+++ b/frontend/src/settings/CustomModels.tsx
@@ -0,0 +1,313 @@
+import { Globe, Tag, Trash } from 'lucide-react';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useDispatch, useSelector } from 'react-redux';
+
+import customModelsService from '../api/services/customModelsService';
+import modelService from '../api/services/modelService';
+import Edit from '../assets/edit.svg';
+import NoFilesDarkIcon from '../assets/no-files-dark.svg';
+import NoFilesIcon from '../assets/no-files.svg';
+import SearchIcon from '../assets/search.svg';
+import ThreeDotsIcon from '../assets/three-dots.svg';
+import ContextMenu, { MenuOption } from '../components/ContextMenu';
+import SkeletonLoader from '../components/SkeletonLoader';
+import { useDarkTheme, useLoaderState } from '../hooks';
+import ConfirmationModal from '../modals/ConfirmationModal';
+import CustomModelModal from '../modals/CustomModelModal';
+import { ActiveState } from '../models/misc';
+import {
+ selectToken,
+ setAvailableModels,
+} from '../preferences/preferenceSlice';
+
+import type { CustomModel } from '../models/types';
+
+const formatBaseUrlHost = (baseUrl: string): string => {
+ if (!baseUrl) return '';
+ try {
+ const url = new URL(baseUrl);
+ return url.host || url.hostname || baseUrl;
+ } catch {
+ const stripped = baseUrl.replace(/^https?:\/\//i, '');
+ const slashIdx = stripped.indexOf('/');
+ return slashIdx >= 0 ? stripped.slice(0, slashIdx) : stripped;
+ }
+};
+
+export default function CustomModels() {
+ const { t } = useTranslation();
+ const dispatch = useDispatch();
+ const token = useSelector(selectToken);
+ const [isDarkTheme] = useDarkTheme();
+
+ const [models, setModels] = React.useState([]);
+ const [searchTerm, setSearchTerm] = React.useState('');
+ const [loading, setLoading] = useLoaderState(false);
+ const [modalState, setModalState] = React.useState('INACTIVE');
+ const [editingModel, setEditingModel] = React.useState(
+ null,
+ );
+ const [activeMenuId, setActiveMenuId] = React.useState(null);
+ const menuRefs = React.useRef<{
+ [key: string]: React.RefObject;
+ }>({});
+ const [deleteState, setDeleteState] = React.useState('INACTIVE');
+ const [modelToDelete, setModelToDelete] = React.useState(
+ null,
+ );
+
+ // Ref instead of useCallback: useLoaderState returns a fresh setter
+ // each render, which would loop the effect (thousands of req/s).
+ const fetchModelsRef = React.useRef<() => Promise>(async () => {});
+ fetchModelsRef.current = async () => {
+ setLoading(true);
+ try {
+ const data = await customModelsService.listCustomModels(token);
+ setModels(data);
+ } catch (err) {
+ console.error('Failed to load custom models:', err);
+ setModels([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ React.useEffect(() => {
+ fetchModelsRef.current();
+ }, [token]);
+
+ React.useEffect(() => {
+ models.forEach((model) => {
+ if (!menuRefs.current[model.id]) {
+ menuRefs.current[model.id] = React.createRef();
+ }
+ });
+ }, [models]);
+
+ const openAddModal = () => {
+ setEditingModel(null);
+ setModalState('ACTIVE');
+ };
+
+ const openEditModal = (model: CustomModel) => {
+ setEditingModel(model);
+ setModalState('ACTIVE');
+ };
+
+ // Refresh Redux availableModels so the chat dropdown reconciles a
+ // selectedModel UUID that was just deleted/disabled.
+ const refreshGlobalAvailableModels = React.useCallback(async () => {
+ try {
+ const response = await modelService.getModels(token);
+ if (!response.ok) return;
+ const data = await response.json();
+ const transformed = modelService.transformModels(data.models || []);
+ dispatch(setAvailableModels(transformed));
+ } catch (err) {
+ console.error('Failed to refresh global available models:', err);
+ }
+ }, [dispatch, token]);
+
+ const handleSaved = (saved: CustomModel) => {
+ setModels((prev) => {
+ const idx = prev.findIndex((m) => m.id === saved.id);
+ if (idx === -1) return [saved, ...prev];
+ const next = [...prev];
+ next[idx] = saved;
+ return next;
+ });
+ refreshGlobalAvailableModels();
+ };
+
+ const requestDelete = (model: CustomModel) => {
+ setModelToDelete(model);
+ setDeleteState('ACTIVE');
+ };
+
+ const confirmDelete = async () => {
+ if (!modelToDelete) return;
+ try {
+ await customModelsService.deleteCustomModel(modelToDelete.id, token);
+ setModels((prev) => prev.filter((m) => m.id !== modelToDelete.id));
+ refreshGlobalAvailableModels();
+ } catch (err) {
+ console.error('Failed to delete custom model:', err);
+ } finally {
+ setModelToDelete(null);
+ setDeleteState('INACTIVE');
+ }
+ };
+
+ const getMenuOptions = (model: CustomModel): MenuOption[] => [
+ {
+ icon: Edit,
+ label: t('settings.customModels.actions.edit'),
+ onClick: () => openEditModal(model),
+ variant: 'primary',
+ iconWidth: 14,
+ iconHeight: 14,
+ },
+ {
+ icon: Trash,
+ label: t('settings.customModels.actions.delete'),
+ onClick: () => requestDelete(model),
+ variant: 'danger',
+ iconWidth: 16,
+ iconHeight: 16,
+ },
+ ];
+
+ const filteredModels = models.filter((model) => {
+ const q = searchTerm.toLowerCase();
+ return (
+ model.display_name.toLowerCase().includes(q) ||
+ model.upstream_model_id.toLowerCase().includes(q)
+ );
+ });
+
+ const renderEmptyState = () => (
+
+

+
+ {t('settings.customModels.empty')}
+
+
+ );
+
+ return (
+
+
+
+ {t('settings.customModels.subtitle')}
+
+
+
+

+
setSearchTerm(e.target.value)}
+ className="border-border bg-card text-foreground placeholder:text-muted-foreground h-11 w-full rounded-full border py-2 pr-5 pl-11 text-sm shadow-[0_1px_4px_rgba(0,0,0,0.06)] transition-shadow outline-none focus:shadow-[0_2px_8px_rgba(0,0,0,0.1)] dark:shadow-none"
+ />
+
+
+
+
+ {loading ? (
+
+
+
+ ) : (
+
+ {filteredModels.length === 0
+ ? renderEmptyState()
+ : filteredModels.map((model) => (
+
+
{
+ e.stopPropagation();
+ setActiveMenuId(
+ activeMenuId === model.id ? null : model.id,
+ );
+ }}
+ className="absolute top-3 right-3 z-10 cursor-pointer"
+ >
+

+
{
+ setActiveMenuId(isOpen ? model.id : null);
+ }}
+ options={getMenuOptions(model)}
+ anchorRef={menuRefs.current[model.id]}
+ position="bottom-right"
+ offset={{ x: 0, y: 0 }}
+ />
+
+
+
+
+ {model.display_name}
+
+ {!model.enabled && (
+
+ {t('settings.customModels.disabledBadge')}
+
+ )}
+
+
+
+
+
+ {model.upstream_model_id}
+
+
+
+
+
+ {formatBaseUrlHost(model.base_url)}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/settings/index.tsx b/frontend/src/settings/index.tsx
index ecff9fc6..4be7da1f 100644
--- a/frontend/src/settings/index.tsx
+++ b/frontend/src/settings/index.tsx
@@ -21,6 +21,7 @@ import {
setSourceDocs,
} from '../preferences/preferenceSlice';
import Analytics from './Analytics';
+import CustomModels from './CustomModels';
import Sources from './Sources';
import General from './General';
import Logs from './Logs';
@@ -39,6 +40,8 @@ export default function Settings() {
return t('settings.analytics.label');
if (path.includes('/settings/logs')) return t('settings.logs.label');
if (path.includes('/settings/tools')) return t('settings.tools.label');
+ if (path.includes('/settings/custom-models'))
+ return t('settings.customModels.label');
return t('settings.general.label');
};
@@ -52,6 +55,8 @@ export default function Settings() {
navigate('/settings/analytics');
else if (tab === t('settings.logs.label')) navigate('/settings/logs');
else if (tab === t('settings.tools.label')) navigate('/settings/tools');
+ else if (tab === t('settings.customModels.label'))
+ navigate('/settings/custom-models');
};
React.useEffect(() => {
@@ -113,6 +118,7 @@ export default function Settings() {
} />
} />
} />
+ } />
} />
diff --git a/tests/agents/test_workflow_engine.py b/tests/agents/test_workflow_engine.py
index 617636d9..e8736b07 100644
--- a/tests/agents/test_workflow_engine.py
+++ b/tests/agents/test_workflow_engine.py
@@ -116,7 +116,7 @@ def test_execute_agent_node_normalizes_wrapped_schema_before_agent_create(monkey
)
monkeypatch.setattr(
"application.core.model_utils.get_model_capabilities",
- lambda _model_id: {"supports_structured_output": True},
+ lambda _model_id, **_kwargs: {"supports_structured_output": True},
)
list(engine._execute_agent_node(node))
@@ -242,7 +242,7 @@ def test_validate_workflow_structure_rejects_unsupported_structured_output_model
monkeypatch.setattr(
workflow_routes,
"get_model_capabilities",
- lambda _model_id: {"supports_structured_output": False},
+ lambda _model_id, **_kwargs: {"supports_structured_output": False},
)
nodes = [
@@ -296,7 +296,7 @@ def test_execute_agent_node_raises_when_structured_output_violates_schema(monkey
)
monkeypatch.setattr(
"application.core.model_utils.get_model_capabilities",
- lambda _model_id: {"supports_structured_output": True},
+ lambda _model_id, **_kwargs: {"supports_structured_output": True},
)
with pytest.raises(ValueError, match="Structured output did not match schema"):
@@ -322,7 +322,7 @@ def test_execute_agent_node_raises_when_schema_set_and_response_not_json(monkeyp
)
monkeypatch.setattr(
"application.core.model_utils.get_model_capabilities",
- lambda _model_id: {"supports_structured_output": True},
+ lambda _model_id, **_kwargs: {"supports_structured_output": True},
)
with pytest.raises(
@@ -360,11 +360,11 @@ class TestWorkflowEngineAdditionalCoverage:
)
monkeypatch.setattr(
"application.core.model_utils.get_provider_from_model_id",
- lambda _: None,
+ lambda _, **_kwargs: None,
)
monkeypatch.setattr(
"application.core.model_utils.get_model_capabilities",
- lambda _: None,
+ lambda _, **_kwargs: None,
)
list(engine._execute_agent_node(node))
@@ -390,11 +390,11 @@ class TestWorkflowEngineAdditionalCoverage:
)
monkeypatch.setattr(
"application.core.model_utils.get_provider_from_model_id",
- lambda _: "openai",
+ lambda _, **_kwargs: "openai",
)
monkeypatch.setattr(
"application.core.model_utils.get_model_capabilities",
- lambda _: None,
+ lambda _, **_kwargs: None,
)
list(engine._execute_agent_node(node))
@@ -416,11 +416,11 @@ class TestWorkflowEngineAdditionalCoverage:
)
monkeypatch.setattr(
"application.core.model_utils.get_provider_from_model_id",
- lambda _: "openai",
+ lambda _, **_kwargs: "openai",
)
monkeypatch.setattr(
"application.core.model_utils.get_model_capabilities",
- lambda _: {"supports_structured_output": False},
+ lambda _, **_kwargs: {"supports_structured_output": False},
)
with pytest.raises(ValueError, match="does not support structured output"):
@@ -450,11 +450,11 @@ class TestWorkflowEngineAdditionalCoverage:
)
monkeypatch.setattr(
"application.core.model_utils.get_provider_from_model_id",
- lambda _: None,
+ lambda _, **_kwargs: None,
)
monkeypatch.setattr(
"application.core.model_utils.get_model_capabilities",
- lambda _: {"supports_structured_output": True},
+ lambda _, **_kwargs: {"supports_structured_output": True},
)
list(engine._execute_agent_node(node))
@@ -481,11 +481,11 @@ class TestWorkflowEngineAdditionalCoverage:
)
monkeypatch.setattr(
"application.core.model_utils.get_provider_from_model_id",
- lambda _: None,
+ lambda _, **_kwargs: None,
)
monkeypatch.setattr(
"application.core.model_utils.get_model_capabilities",
- lambda _: {"supports_structured_output": True},
+ lambda _, **_kwargs: {"supports_structured_output": True},
)
with pytest.raises(
diff --git a/tests/api/answer/services/compression/test_orchestrator.py b/tests/api/answer/services/compression/test_orchestrator.py
index 67701614..5f9a154c 100644
--- a/tests/api/answer/services/compression/test_orchestrator.py
+++ b/tests/api/answer/services/compression/test_orchestrator.py
@@ -162,8 +162,10 @@ class TestCompressIfNeeded:
current_query_tokens=1000,
)
+ # user_id flows through so BYOM custom-model UUIDs resolve to
+ # the user's declared context window in the threshold check.
mock_threshold_checker.should_compress.assert_called_once_with(
- sample_conversation, "gpt-4", 1000
+ sample_conversation, "gpt-4", 1000, user_id="user1"
)
@@ -270,8 +272,12 @@ class TestPerformCompression:
)
orch._perform_compression("c1", conversation, "gpt-4", decoded_token)
- # Verify the override model was used
- mock_get_provider.assert_called_with("gpt-3.5-turbo")
+ # Verify the override model was used. user_id flows from
+ # decoded_token['sub'] so per-user BYOM custom-model UUIDs
+ # resolve.
+ mock_get_provider.assert_called_with(
+ "gpt-3.5-turbo", user_id=decoded_token["sub"]
+ )
@patch(
"application.api.answer.services.compression.orchestrator.get_provider_from_model_id"
@@ -362,7 +368,12 @@ class TestCompressMidExecution:
)
mock_perform.assert_called_once_with(
- "conv1", sample_conversation, "gpt-4", decoded_token
+ "conv1",
+ sample_conversation,
+ "gpt-4",
+ decoded_token,
+ user_id="user1",
+ model_user_id=None,
)
def test_loads_conversation_when_not_provided(
diff --git a/tests/api/user/test_user_custom_models_routes.py b/tests/api/user/test_user_custom_models_routes.py
new file mode 100644
index 00000000..dfc6e3eb
--- /dev/null
+++ b/tests/api/user/test_user_custom_models_routes.py
@@ -0,0 +1,688 @@
+"""Tests for the BYOM REST API at /api/user/models."""
+
+from __future__ import annotations
+
+from contextlib import contextmanager
+from unittest.mock import MagicMock, patch
+
+import pytest
+from flask import Flask
+
+
+@pytest.fixture
+def app():
+ return Flask(__name__)
+
+
+@contextmanager
+def _patch_db(conn):
+ """Patch the routes' db helpers to yield the given pg connection."""
+
+ @contextmanager
+ def _yield_conn():
+ yield conn
+
+ with patch(
+ "application.api.user.models.routes.db_session", _yield_conn
+ ), patch(
+ "application.api.user.models.routes.db_readonly", _yield_conn
+ ):
+ yield
+
+
+@pytest.fixture(autouse=True)
+def _reset_registry():
+ from application.core.model_registry import ModelRegistry
+
+ ModelRegistry.reset()
+ yield
+ ModelRegistry.reset()
+
+
+# Auth
+
+
+@pytest.mark.unit
+class TestAuth:
+ def test_list_unauthenticated_returns_401(self, app):
+ from application.api.user.models.routes import (
+ UserModelsCollectionResource,
+ )
+
+ with app.test_request_context("/api/user/models"):
+ from flask import request
+
+ request.decoded_token = None
+ resp = UserModelsCollectionResource().get()
+ assert resp.status_code == 401
+
+ def test_create_unauthenticated_returns_401(self, app):
+ from application.api.user.models.routes import (
+ UserModelsCollectionResource,
+ )
+
+ with app.test_request_context(
+ "/api/user/models",
+ method="POST",
+ json={
+ "upstream_model_id": "x",
+ "display_name": "x",
+ "base_url": "https://api.openai.com/v1",
+ "api_key": "k",
+ },
+ ):
+ from flask import request
+
+ request.decoded_token = None
+ resp = UserModelsCollectionResource().post()
+ assert resp.status_code == 401
+
+
+# Create
+
+
+@pytest.mark.unit
+class TestCreate:
+ def test_creates_and_returns_201_without_api_key(self, app, pg_conn):
+ from application.api.user.models.routes import (
+ UserModelsCollectionResource,
+ )
+
+ # Mock DNS so the SSRF check passes for api.mistral.ai without
+ # hitting the network.
+ with patch("application.security.safe_url.socket.getaddrinfo") as gai:
+ gai.return_value = [
+ (None, None, None, None, ("104.18.0.1", 0))
+ ]
+ with app.test_request_context(
+ "/api/user/models",
+ method="POST",
+ json={
+ "upstream_model_id": "mistral-large-latest",
+ "display_name": "My Mistral",
+ "base_url": "https://api.mistral.ai/v1",
+ "api_key": "sk-mistral-test",
+ "capabilities": {
+ "supports_tools": True,
+ "context_window": 128000,
+ },
+ },
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "user-1"}
+ with _patch_db(pg_conn):
+ resp = UserModelsCollectionResource().post()
+
+ assert resp.status_code == 201
+ body = resp.get_json()
+ assert body["upstream_model_id"] == "mistral-large-latest"
+ assert body["source"] == "user"
+ # Critical: api_key must NEVER appear in the response
+ assert "api_key" not in body
+ for v in body.values():
+ assert v != "sk-mistral-test"
+
+ def test_create_rejects_missing_required_fields(self, app, pg_conn):
+ from application.api.user.models.routes import (
+ UserModelsCollectionResource,
+ )
+
+ with app.test_request_context(
+ "/api/user/models",
+ method="POST",
+ json={"upstream_model_id": "x"}, # missing the others
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "user-1"}
+ with _patch_db(pg_conn):
+ resp = UserModelsCollectionResource().post()
+ assert resp.status_code == 400
+
+ def test_create_rejects_loopback_url(self, app, pg_conn):
+ from application.api.user.models.routes import (
+ UserModelsCollectionResource,
+ )
+
+ with app.test_request_context(
+ "/api/user/models",
+ method="POST",
+ json={
+ "upstream_model_id": "x",
+ "display_name": "x",
+ "base_url": "https://127.0.0.1/v1",
+ "api_key": "k",
+ },
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "user-1"}
+ with _patch_db(pg_conn):
+ resp = UserModelsCollectionResource().post()
+ assert resp.status_code == 400
+ body = resp.get_json()
+ assert "error" in body
+
+ def test_create_rejects_unknown_attachment_alias(self, app, pg_conn):
+ """The UI sends ``["image"]`` as an alias; bad strings ("video",
+ typos) must reject at the boundary so the DB never holds
+ garbage that the registry would later silently drop.
+ """
+ from application.api.user.models.routes import (
+ UserModelsCollectionResource,
+ )
+
+ with patch("application.security.safe_url.socket.getaddrinfo") as gai:
+ gai.return_value = [
+ (None, None, None, None, ("104.18.0.1", 0))
+ ]
+ with app.test_request_context(
+ "/api/user/models",
+ method="POST",
+ json={
+ "upstream_model_id": "m",
+ "display_name": "M",
+ "base_url": "https://api.mistral.ai/v1",
+ "api_key": "k",
+ "capabilities": {"attachments": ["video"]},
+ },
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "user-1"}
+ with _patch_db(pg_conn):
+ resp = UserModelsCollectionResource().post()
+ assert resp.status_code == 400
+ body = resp.get_json()
+ assert "video" in body["error"]
+
+ def test_create_accepts_image_alias_and_raw_mime(self, app, pg_conn):
+ """The known ``image`` alias and raw MIME types both pass."""
+ from application.api.user.models.routes import (
+ UserModelsCollectionResource,
+ )
+
+ with patch("application.security.safe_url.socket.getaddrinfo") as gai:
+ gai.return_value = [
+ (None, None, None, None, ("104.18.0.1", 0))
+ ]
+ with app.test_request_context(
+ "/api/user/models",
+ method="POST",
+ json={
+ "upstream_model_id": "m",
+ "display_name": "M",
+ "base_url": "https://api.mistral.ai/v1",
+ "api_key": "k",
+ "capabilities": {
+ "attachments": ["image", "application/pdf"],
+ },
+ },
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "user-1"}
+ with _patch_db(pg_conn):
+ resp = UserModelsCollectionResource().post()
+ assert resp.status_code == 201
+
+ def test_create_rejects_private_ip_dns(self, app, pg_conn):
+ from application.api.user.models.routes import (
+ UserModelsCollectionResource,
+ )
+
+ with patch("application.security.safe_url.socket.getaddrinfo") as gai:
+ # Hostname resolves to a private IP only — must reject
+ gai.return_value = [
+ (None, None, None, None, ("10.0.0.5", 0))
+ ]
+ with app.test_request_context(
+ "/api/user/models",
+ method="POST",
+ json={
+ "upstream_model_id": "x",
+ "display_name": "x",
+ "base_url": "https://evil.example.com/v1",
+ "api_key": "k",
+ },
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "user-1"}
+ with _patch_db(pg_conn):
+ resp = UserModelsCollectionResource().post()
+ assert resp.status_code == 400
+
+
+# List / get / patch / delete
+
+
+def _create_via_repo(pg_conn, user_id="user-1", **kwargs):
+ from application.storage.db.repositories.user_custom_models import (
+ UserCustomModelsRepository,
+ )
+
+ return UserCustomModelsRepository(pg_conn).create(
+ user_id=user_id,
+ upstream_model_id=kwargs.pop("upstream_model_id", "mistral-large-latest"),
+ display_name=kwargs.pop("display_name", "My Mistral"),
+ base_url=kwargs.pop("base_url", "https://api.mistral.ai/v1"),
+ api_key_plaintext=kwargs.pop("api_key_plaintext", "sk-mistral-test"),
+ **kwargs,
+ )
+
+
+@pytest.mark.unit
+class TestList:
+ def test_lists_only_users_own(self, app, pg_conn):
+ from application.api.user.models.routes import (
+ UserModelsCollectionResource,
+ )
+
+ _create_via_repo(pg_conn, user_id="alice", upstream_model_id="alice-1")
+ _create_via_repo(pg_conn, user_id="bob", upstream_model_id="bob-1")
+
+ with app.test_request_context("/api/user/models"):
+ from flask import request
+
+ request.decoded_token = {"sub": "alice"}
+ with _patch_db(pg_conn):
+ resp = UserModelsCollectionResource().get()
+ assert resp.status_code == 200
+ body = resp.get_json()
+ upstream_ids = {m["upstream_model_id"] for m in body["models"]}
+ assert upstream_ids == {"alice-1"}
+ # Never expose the api_key
+ for m in body["models"]:
+ assert "api_key" not in m
+
+
+@pytest.mark.unit
+class TestGet:
+ def test_returns_404_for_other_users_model(self, app, pg_conn):
+ from application.api.user.models.routes import UserModelResource
+
+ created = _create_via_repo(pg_conn, user_id="alice")
+ with app.test_request_context(
+ f"/api/user/models/{created['id']}"
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "bob"}
+ with _patch_db(pg_conn):
+ resp = UserModelResource().get(model_id=created["id"])
+ assert resp.status_code == 404
+
+
+@pytest.mark.unit
+class TestPatch:
+ def test_patch_updates_display_name(self, app, pg_conn):
+ from application.api.user.models.routes import UserModelResource
+
+ created = _create_via_repo(pg_conn, user_id="user-1")
+ with app.test_request_context(
+ f"/api/user/models/{created['id']}",
+ method="PATCH",
+ json={"display_name": "Renamed"},
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "user-1"}
+ with _patch_db(pg_conn):
+ resp = UserModelResource().patch(model_id=created["id"])
+ assert resp.status_code == 200
+ body = resp.get_json()
+ assert body["display_name"] == "Renamed"
+
+ def test_patch_blank_api_key_keeps_existing(self, app, pg_conn):
+ """Critical PATCH semantic: empty/missing api_key in body must
+ preserve the stored ciphertext (the UI sends a blank password
+ field when the user wants to keep the existing key)."""
+ from application.api.user.models.routes import UserModelResource
+ from application.storage.db.repositories.user_custom_models import (
+ UserCustomModelsRepository,
+ )
+
+ created = _create_via_repo(pg_conn, user_id="user-1")
+ original_key_plaintext = "sk-mistral-test"
+
+ with app.test_request_context(
+ f"/api/user/models/{created['id']}",
+ method="PATCH",
+ json={"display_name": "Just rename me", "api_key": ""},
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "user-1"}
+ with _patch_db(pg_conn):
+ resp = UserModelResource().patch(model_id=created["id"])
+ assert resp.status_code == 200
+
+ # Decrypted key is unchanged
+ repo = UserCustomModelsRepository(pg_conn)
+ assert (
+ repo.get_decrypted_api_key(created["id"], "user-1")
+ == original_key_plaintext
+ )
+
+
+@pytest.mark.unit
+class TestDelete:
+ def test_delete_removes_row_and_invalidates_cache(self, app, pg_conn):
+ from application.api.user.models.routes import UserModelResource
+ from application.core.model_registry import ModelRegistry
+
+ created = _create_via_repo(pg_conn, user_id="user-1")
+ # Warm the registry's per-user cache via a lookup
+ with patch(
+ "application.storage.db.session.db_readonly"
+ ) as ro:
+ @contextmanager
+ def _y():
+ yield pg_conn
+
+ ro.side_effect = _y
+ ModelRegistry.get_instance().get_model(created["id"], user_id="user-1")
+ # Now delete via the route — invalidation must happen so a
+ # subsequent lookup misses
+ with app.test_request_context(
+ f"/api/user/models/{created['id']}", method="DELETE"
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "user-1"}
+ with _patch_db(pg_conn):
+ resp = UserModelResource().delete(model_id=created["id"])
+ assert resp.status_code == 200
+ # Cache invalidated → next lookup re-queries DB and finds nothing
+ assert "user-1" not in ModelRegistry.get_instance()._user_models
+
+
+# /api/models combined view
+
+
+@pytest.mark.unit
+class TestSecurityCreateRejectsBlankFields:
+ """P1 #1 partial: blank api_key on create must be rejected so we
+ can never end up with an unroutable BYOM record that would cause
+ LLMCreator to leak settings.API_KEY to the user-supplied URL."""
+
+ def test_create_rejects_blank_api_key(self, app, pg_conn):
+ from application.api.user.models.routes import (
+ UserModelsCollectionResource,
+ )
+
+ with patch("application.security.safe_url.socket.getaddrinfo") as gai:
+ gai.return_value = [(None, None, None, None, ("104.18.0.1", 0))]
+ with app.test_request_context(
+ "/api/user/models",
+ method="POST",
+ json={
+ "upstream_model_id": "x",
+ "display_name": "x",
+ "base_url": "https://api.mistral.ai/v1",
+ "api_key": " ", # whitespace only
+ },
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "u"}
+ with _patch_db(pg_conn):
+ resp = UserModelsCollectionResource().post()
+ assert resp.status_code == 400
+ body = resp.get_json()
+ assert "api_key" in (body.get("error") or "").lower()
+
+ def test_patch_rejects_blank_required_field(self, app, pg_conn):
+ from application.api.user.models.routes import UserModelResource
+ from application.storage.db.repositories.user_custom_models import (
+ UserCustomModelsRepository,
+ )
+
+ created = UserCustomModelsRepository(pg_conn).create(
+ user_id="u",
+ upstream_model_id="x",
+ display_name="x",
+ base_url="https://api.mistral.ai/v1",
+ api_key_plaintext="sk-x",
+ )
+
+ with app.test_request_context(
+ f"/api/user/models/{created['id']}",
+ method="PATCH",
+ json={"base_url": " "},
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "u"}
+ with _patch_db(pg_conn):
+ resp = UserModelResource().patch(model_id=created["id"])
+ assert resp.status_code == 400
+
+
+@pytest.mark.unit
+class TestPayloadConnectionTest:
+ """Verifies the payload-based test endpoint. Lets the UI's 'Test
+ connection' button work *before* the model is saved — operators
+ expect to validate their endpoint + key before committing."""
+
+ def test_payload_test_rejects_unsafe_url(self, app, pg_conn):
+ from application.api.user.models.routes import (
+ UserModelTestPayloadResource,
+ )
+
+ with app.test_request_context(
+ "/api/user/models/test",
+ method="POST",
+ json={
+ "base_url": "https://127.0.0.1/v1",
+ "api_key": "sk-anything",
+ "upstream_model_id": "x",
+ },
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "user-1"}
+ with _patch_db(pg_conn):
+ resp = UserModelTestPayloadResource().post()
+ assert resp.status_code == 400
+ body = resp.get_json()
+ assert body["ok"] is False
+
+ def test_payload_test_returns_ok_when_upstream_responds_2xx(
+ self, app, pg_conn
+ ):
+ from application.api.user.models.routes import (
+ UserModelTestPayloadResource,
+ )
+
+ # pinned_post is the IP-pinned dispatch helper. Patching it
+ # bypasses both the SSRF guard and the network — the success
+ # path we're verifying here is the route's response handling.
+ with patch("application.api.user.models.routes.pinned_post") as rp:
+ rp.return_value = MagicMock(
+ status_code=200,
+ headers={"Content-Type": "application/json"},
+ text='{"ok": true}',
+ )
+ with app.test_request_context(
+ "/api/user/models/test",
+ method="POST",
+ json={
+ "base_url": "https://api.mistral.ai/v1",
+ "api_key": "sk-mistral-test",
+ "upstream_model_id": "mistral-large-latest",
+ },
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "user-1"}
+ with _patch_db(pg_conn):
+ resp = UserModelTestPayloadResource().post()
+ assert resp.status_code == 200
+ body = resp.get_json()
+ assert body["ok"] is True
+ # Verify the upstream call carried the user's submitted key (not
+ # whatever's in the DB) and the right model name.
+ call_args = rp.call_args
+ assert call_args.kwargs["headers"]["Authorization"] == "Bearer sk-mistral-test"
+ assert call_args.kwargs["json"]["model"] == "mistral-large-latest"
+
+ def test_payload_test_unauthenticated_returns_401(self, app, pg_conn):
+ from application.api.user.models.routes import (
+ UserModelTestPayloadResource,
+ )
+
+ with app.test_request_context(
+ "/api/user/models/test",
+ method="POST",
+ json={
+ "base_url": "https://api.mistral.ai/v1",
+ "api_key": "k",
+ "upstream_model_id": "x",
+ },
+ ):
+ from flask import request
+
+ request.decoded_token = None
+ with _patch_db(pg_conn):
+ resp = UserModelTestPayloadResource().post()
+ assert resp.status_code == 401
+
+ def test_payload_test_missing_fields_returns_400(self, app, pg_conn):
+ from application.api.user.models.routes import (
+ UserModelTestPayloadResource,
+ )
+
+ with app.test_request_context(
+ "/api/user/models/test",
+ method="POST",
+ json={"base_url": "https://api.mistral.ai/v1"},
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "user-1"}
+ with _patch_db(pg_conn):
+ resp = UserModelTestPayloadResource().post()
+ assert resp.status_code == 400
+
+
+@pytest.mark.unit
+class TestByIdConnectionTestAcceptsOverrides:
+ """P3: in edit mode the modal sends current form state as overrides
+ so the test reflects in-flight edits (not the saved record)."""
+
+ def _make_row(self, pg_conn):
+ from application.storage.db.repositories.user_custom_models import (
+ UserCustomModelsRepository,
+ )
+
+ return UserCustomModelsRepository(pg_conn).create(
+ user_id="u",
+ upstream_model_id="stored-model",
+ display_name="Stored",
+ base_url="https://stored.example.com/v1",
+ api_key_plaintext="sk-stored",
+ )
+
+ def _post_test(self, app, pg_conn, model_id, body):
+ from application.api.user.models.routes import UserModelTestResource
+
+ with patch("application.api.user.models.routes.pinned_post") as rp:
+ rp.return_value = MagicMock(
+ status_code=200,
+ headers={"Content-Type": "application/json"},
+ text='{"ok": true}',
+ )
+ with app.test_request_context(
+ f"/api/user/models/{model_id}/test", method="POST", json=body
+ ):
+ from flask import request
+
+ request.decoded_token = {"sub": "u"}
+ with _patch_db(pg_conn):
+ UserModelTestResource().post(model_id=model_id)
+ return rp.call_args
+
+ def test_overrides_win_when_supplied(self, app, pg_conn):
+ row = self._make_row(pg_conn)
+ ca = self._post_test(
+ app,
+ pg_conn,
+ row["id"],
+ {
+ "base_url": "https://new.example.com/v1",
+ "api_key": "sk-new",
+ "upstream_model_id": "new-model",
+ },
+ )
+ assert ca.args[0] == "https://new.example.com/v1/chat/completions"
+ assert ca.kwargs["headers"]["Authorization"] == "Bearer sk-new"
+ assert ca.kwargs["json"]["model"] == "new-model"
+
+ def test_blank_overrides_fall_back_to_stored(self, app, pg_conn):
+ """The classic edit-mode flow: user changed base_url, left
+ api_key blank — server uses the new URL but the stored key."""
+ row = self._make_row(pg_conn)
+ ca = self._post_test(
+ app,
+ pg_conn,
+ row["id"],
+ {
+ "base_url": "https://new.example.com/v1",
+ "api_key": "",
+ "upstream_model_id": "",
+ },
+ )
+ assert ca.args[0] == "https://new.example.com/v1/chat/completions"
+ # Stored key (decrypted) was used.
+ assert ca.kwargs["headers"]["Authorization"] == "Bearer sk-stored"
+ # Stored upstream_model_id was used.
+ assert ca.kwargs["json"]["model"] == "stored-model"
+
+ def test_empty_body_uses_all_stored_values(self, app, pg_conn):
+ row = self._make_row(pg_conn)
+ ca = self._post_test(app, pg_conn, row["id"], {})
+ assert ca.args[0] == "https://stored.example.com/v1/chat/completions"
+ assert ca.kwargs["headers"]["Authorization"] == "Bearer sk-stored"
+ assert ca.kwargs["json"]["model"] == "stored-model"
+
+
+@pytest.mark.unit
+class TestApiModelsListWithUser:
+ def test_includes_user_models_when_authenticated(self, app, pg_conn):
+ """GET /api/models with auth should surface the user's BYOM
+ records alongside built-ins, each tagged with `source`."""
+ from application.api.user.models.routes import ModelsListResource
+ from application.core.model_registry import ModelRegistry
+
+ created = _create_via_repo(
+ pg_conn, user_id="user-1", display_name="My Mistral"
+ )
+
+ # Patch the *registry's* db_readonly so the per-user layer load
+ # uses the test connection.
+ @contextmanager
+ def _yield():
+ yield pg_conn
+
+ with patch(
+ "application.storage.db.session.db_readonly", _yield
+ ):
+ ModelRegistry.reset()
+ with app.test_request_context("/api/models"):
+ from flask import request
+
+ request.decoded_token = {"sub": "user-1"}
+ resp = ModelsListResource().get()
+
+ assert resp.status_code == 200
+ body = resp.get_json()
+ ids = [m["id"] for m in body["models"]]
+ assert created["id"] in ids
+ # Source label tags it for the UI
+ user_entries = [m for m in body["models"] if m["id"] == created["id"]]
+ assert user_entries[0]["source"] == "user"
+ # Built-ins still present
+ assert any(m.get("source") == "builtin" for m in body["models"])
diff --git a/tests/conftest.py b/tests/conftest.py
index a19256ac..92af6dfd 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -122,6 +122,11 @@ def mock_llm():
llm._supports_tools = True
llm._supports_structured_output = Mock(return_value=False)
llm.__class__.__name__ = "MockLLM"
+ # Mirror BaseLLM.__init__: real LLMCreator stores the resolved
+ # upstream model name on self.model_id. Tests that build agents via
+ # ``mock_llm_creator`` rely on the agent's ``upstream_model_id``
+ # falling through to this attribute.
+ llm.model_id = "gpt-4"
return llm
diff --git a/tests/core/test_byom_user_aware_helpers.py b/tests/core/test_byom_user_aware_helpers.py
new file mode 100644
index 00000000..5decf0d9
--- /dev/null
+++ b/tests/core/test_byom_user_aware_helpers.py
@@ -0,0 +1,1268 @@
+"""Regression tests for the three P1 BYOM fixes.
+
+P1 #1 — model_utils helpers must accept and honor ``user_id`` so per-user
+ BYOM custom-model UUIDs resolve via the registry per-user layer.
+P1 #2 — Agent ``_llm_gen`` must pass the *upstream* model id (the user's
+ typed name, e.g. ``mistral-large-latest``) to the provider's
+ chat-completion API, not the BYOM UUID we use as the registry id.
+P1 #3 — covered indirectly: the route already accepts a user_id; this
+ suite locks in that ``/api/models``'s ``get_enabled_models``
+ properly distinguishes authenticated vs anonymous responses.
+"""
+
+from __future__ import annotations
+
+import time
+from contextlib import contextmanager
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from application.core.model_registry import ModelRegistry
+from application.core.model_utils import (
+ get_all_available_models,
+ get_api_key_for_model,
+ get_base_url_for_model,
+ get_model_capabilities,
+ get_provider_from_model_id,
+ get_token_limit,
+ validate_model_id,
+)
+from application.storage.db.repositories.user_custom_models import (
+ UserCustomModelsRepository,
+)
+
+
+def _seed_user_layer(reg, user_id, layer_dict):
+ """Plant a BYOM layer directly into the registry's per-user cache.
+
+ Mirrors the internal ``(layer, version_at_load, loaded_at)`` tuple
+ shape (see ``ModelRegistry._user_models``). Tests poking the cache
+ use this rather than assigning a bare dict so the production-side
+ TTL/version logic keeps working.
+ """
+ reg._user_models[user_id] = (layer_dict, None, time.monotonic())
+
+
+def _make_settings(**overrides):
+ s = MagicMock()
+ s.OPENAI_BASE_URL = None
+ s.OPENAI_API_KEY = None
+ s.OPENAI_API_BASE = None
+ s.ANTHROPIC_API_KEY = None
+ s.GOOGLE_API_KEY = None
+ s.GROQ_API_KEY = None
+ s.OPEN_ROUTER_API_KEY = None
+ s.NOVITA_API_KEY = None
+ s.HUGGINGFACE_API_KEY = None
+ s.LLM_PROVIDER = ""
+ s.LLM_NAME = None
+ s.API_KEY = None
+ s.MODELS_CONFIG_DIR = None
+ s.DEFAULT_LLM_TOKEN_LIMIT = 128000
+ # Concrete strings — module-level imports under patched settings
+ # (e.g. application.api.user.base's storage init) fail with
+ # MagicMock attribute values.
+ s.STORAGE_TYPE = "local"
+ for k, v in overrides.items():
+ setattr(s, k, v)
+ return s
+
+
+@contextmanager
+def _yield(conn):
+ yield conn
+
+
+@pytest.fixture(autouse=True)
+def _reset_registry():
+ ModelRegistry.reset()
+ yield
+ ModelRegistry.reset()
+
+
+@pytest.fixture
+def byom_model(pg_conn):
+ """Create one BYOM record and return (model_uuid, user_id)."""
+ repo = UserCustomModelsRepository(pg_conn)
+ row = repo.create(
+ user_id="user-1",
+ upstream_model_id="mistral-large-latest",
+ display_name="My Mistral",
+ base_url="https://api.mistral.ai/v1",
+ api_key_plaintext="sk-mistral-test",
+ capabilities={
+ "supports_tools": True,
+ "supports_structured_output": True,
+ "context_window": 200_000,
+ },
+ )
+ return row["id"], "user-1"
+
+
+# ── P1 #1: helpers honor user_id ─────────────────────────────────────────
+
+
+@pytest.mark.unit
+class TestHelpersThreadUserId:
+ def test_validate_model_id_without_user_id_rejects_byom(
+ self, pg_conn, byom_model
+ ):
+ model_uuid, user_id = byom_model
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ ModelRegistry()
+ assert validate_model_id(model_uuid) is False
+
+ def test_validate_model_id_with_user_id_accepts_byom(
+ self, pg_conn, byom_model
+ ):
+ model_uuid, user_id = byom_model
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ ModelRegistry()
+ assert validate_model_id(model_uuid, user_id=user_id) is True
+
+ def test_get_provider_returns_openai_compatible_for_byom(
+ self, pg_conn, byom_model
+ ):
+ model_uuid, user_id = byom_model
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ ModelRegistry()
+ assert get_provider_from_model_id(model_uuid) is None
+ assert (
+ get_provider_from_model_id(model_uuid, user_id=user_id)
+ == "openai_compatible"
+ )
+
+ def test_get_token_limit_uses_byom_context_window(
+ self, pg_conn, byom_model
+ ):
+ model_uuid, user_id = byom_model
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ ModelRegistry()
+ # No user_id → falls back to DEFAULT_LLM_TOKEN_LIMIT
+ assert get_token_limit(model_uuid) == 128_000
+ # With user_id → returns the user's declared 200k
+ assert get_token_limit(model_uuid, user_id=user_id) == 200_000
+
+ def test_get_base_url_for_model_resolves_byom(
+ self, pg_conn, byom_model
+ ):
+ model_uuid, user_id = byom_model
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ ModelRegistry()
+ assert get_base_url_for_model(model_uuid) is None
+ assert (
+ get_base_url_for_model(model_uuid, user_id=user_id)
+ == "https://api.mistral.ai/v1"
+ )
+
+ def test_get_api_key_for_model_resolves_byom(
+ self, pg_conn, byom_model
+ ):
+ model_uuid, user_id = byom_model
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ ModelRegistry()
+ assert get_api_key_for_model(model_uuid) is None
+ assert (
+ get_api_key_for_model(model_uuid, user_id=user_id)
+ == "sk-mistral-test"
+ )
+
+ def test_get_model_capabilities_resolves_byom(
+ self, pg_conn, byom_model
+ ):
+ model_uuid, user_id = byom_model
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ ModelRegistry()
+ assert get_model_capabilities(model_uuid) is None
+ caps = get_model_capabilities(model_uuid, user_id=user_id)
+ assert caps is not None
+ assert caps["supports_tools"] is True
+ assert caps["context_window"] == 200_000
+
+ def test_get_all_available_models_includes_byom_when_authenticated(
+ self, pg_conn, byom_model
+ ):
+ model_uuid, user_id = byom_model
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ ModelRegistry()
+ anon_ids = set(get_all_available_models().keys())
+ user_ids = set(get_all_available_models(user_id=user_id).keys())
+ assert model_uuid not in anon_ids
+ assert model_uuid in user_ids
+
+
+# ── P1 #2: every gen call site sends the upstream id, not the BYOM UUID ──
+
+
+@pytest.mark.unit
+class TestSecurityNoCredLeakOnUndecryptableBYOM:
+ """P1 #1: a BYOM record whose api_key cannot be decrypted MUST NOT
+ be registered. Without this guard, LLMCreator would fall back to
+ the caller-passed api_key (settings.API_KEY for openai_compatible)
+ and POST it to the user-supplied base_url — leaking the instance
+ credential to the user's chosen endpoint."""
+
+ def test_undecryptable_record_skipped_in_registry(
+ self, pg_conn, monkeypatch
+ ):
+ from application.storage.db.repositories.user_custom_models import (
+ UserCustomModelsRepository,
+ )
+
+ repo = UserCustomModelsRepository(pg_conn)
+ created = repo.create(
+ user_id="u",
+ upstream_model_id="mistral-large-latest",
+ display_name="My Mistral",
+ base_url="https://api.mistral.ai/v1",
+ api_key_plaintext="sk-real",
+ )
+
+ # Simulate decryption failure (rotated ENCRYPTION_SECRET_KEY,
+ # corrupted ciphertext, etc.) by stubbing _decrypt_api_key.
+ monkeypatch.setattr(
+ UserCustomModelsRepository,
+ "_decrypt_api_key",
+ staticmethod(lambda *_a, **_kw: None),
+ )
+
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ ModelRegistry()
+ reg = ModelRegistry.get_instance()
+ # The record must NOT appear in the user's layer.
+ assert reg.get_model(created["id"], user_id="u") is None
+ # And the per-user layer is empty (or at least does not
+ # contain a record with api_key=None).
+ for m in reg._user_models_for("u").values():
+ assert m.api_key # never None / empty
+
+ def test_llmcreator_refuses_user_model_with_no_api_key(self, pg_conn):
+ """Belt-and-braces: even if a source=user AvailableModel slipped
+ into the registry without an api_key, LLMCreator must refuse."""
+ from application.core.model_settings import (
+ AvailableModel,
+ ModelProvider,
+ )
+
+ s = _make_settings()
+ with patch("application.core.settings.settings", s):
+ reg = ModelRegistry()
+ # Inject a malformed BYOM record directly to bypass the
+ # registry-level guard tested above.
+ uuid = "0b7e0f4c-1234-5678-9abc-deadbeefcafe"
+ _seed_user_layer(
+ reg,
+ "alice",
+ {
+ uuid: AvailableModel(
+ id=uuid,
+ provider=ModelProvider.OPENAI_COMPATIBLE,
+ display_name="bad",
+ base_url="https://api.mistral.ai/v1",
+ upstream_model_id="mistral-large-latest",
+ source="user",
+ api_key=None, # the dangerous state
+ )
+ },
+ )
+
+ from application.llm.llm_creator import LLMCreator
+
+ with pytest.raises(ValueError, match="no usable API key"):
+ LLMCreator.create_llm(
+ type="openai_compatible",
+ api_key="settings-api-key-DO-NOT-LEAK",
+ user_api_key=None,
+ decoded_token={"sub": "alice"},
+ model_id=uuid,
+ )
+
+
+@pytest.mark.unit
+class TestSecurityDispatchSSRFGuard:
+ """P1 #2: LLMCreator must re-validate the model's base_url at
+ dispatch time. Closes DNS-rebinding window between create-time
+ check and connect; protects against rows that pre-date the SSRF
+ guard or were inserted via direct DB edits."""
+
+ def test_dispatch_rejects_user_model_with_unsafe_base_url(self, pg_conn):
+ from application.core.model_settings import (
+ AvailableModel,
+ ModelProvider,
+ )
+
+ s = _make_settings()
+ with patch("application.core.settings.settings", s):
+ reg = ModelRegistry()
+ uuid = "0b7e0f4c-1234-5678-9abc-deadbeef0099"
+ _seed_user_layer(
+ reg,
+ "alice",
+ {
+ uuid: AvailableModel(
+ id=uuid,
+ provider=ModelProvider.OPENAI_COMPATIBLE,
+ display_name="rebound",
+ base_url="https://10.0.0.5/v1", # unsafe — RFC1918
+ upstream_model_id="model-x",
+ source="user",
+ api_key="sk-real",
+ )
+ },
+ )
+
+ from application.llm.llm_creator import LLMCreator
+
+ with pytest.raises(ValueError, match="Refusing to dispatch"):
+ LLMCreator.create_llm(
+ type="openai_compatible",
+ api_key="some-key",
+ user_api_key=None,
+ decoded_token={"sub": "alice"},
+ model_id=uuid,
+ )
+
+ def test_dispatch_allows_user_model_with_safe_base_url(self, pg_conn):
+ """The SSRF guard must not break the happy path."""
+ from application.core.model_settings import (
+ AvailableModel,
+ ModelProvider,
+ )
+
+ captured: dict = {}
+
+ class _FakeLLM:
+ def __init__(self, api_key, user_api_key, *a, **kw):
+ captured["api_key"] = api_key
+ captured["base_url"] = kw.get("base_url")
+
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.security.safe_url.socket.getaddrinfo"
+ ) as gai:
+ gai.return_value = [(None, None, None, None, ("104.18.0.1", 0))]
+ reg = ModelRegistry()
+ uuid = "0b7e0f4c-1234-5678-9abc-deadbeef0100"
+ _seed_user_layer(
+ reg,
+ "alice",
+ {
+ uuid: AvailableModel(
+ id=uuid,
+ provider=ModelProvider.OPENAI_COMPATIBLE,
+ display_name="ok",
+ base_url="https://api.mistral.ai/v1",
+ upstream_model_id="mistral-large-latest",
+ source="user",
+ api_key="sk-real",
+ )
+ },
+ )
+
+ from application.llm.llm_creator import LLMCreator
+ from application.llm.providers import PROVIDERS_BY_NAME
+
+ with patch.object(
+ PROVIDERS_BY_NAME["openai_compatible"], "llm_class", _FakeLLM
+ ):
+ LLMCreator.create_llm(
+ type="openai_compatible",
+ api_key="caller-key",
+ user_api_key=None,
+ decoded_token={"sub": "alice"},
+ model_id=uuid,
+ )
+
+ assert captured["base_url"] == "https://api.mistral.ai/v1"
+ assert captured["api_key"] == "sk-real" # model.api_key won
+
+ def test_dispatch_injects_pinned_http_client_for_user_model(
+ self, pg_conn
+ ):
+ """LLMCreator must build a DNS-rebinding-safe httpx.Client and
+ forward it to the OpenAI SDK so the SDK's request-time DNS
+ lookup cannot escape the create-time SSRF guard. ``validate_
+ user_base_url`` alone is TOCTOU and does not close the
+ rebinding window — the pinned client is what does."""
+ import httpx
+
+ from application.core.model_settings import (
+ AvailableModel,
+ ModelProvider,
+ )
+ from application.security.safe_url import _PinnedHTTPSTransport
+
+ captured: dict = {}
+
+ class _FakeLLM:
+ def __init__(self, api_key, user_api_key, *a, **kw):
+ captured["http_client"] = kw.get("http_client")
+
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.security.safe_url.socket.getaddrinfo"
+ ) as gai:
+ gai.return_value = [(None, None, None, None, ("104.18.0.1", 0))]
+ reg = ModelRegistry()
+ uuid = "0b7e0f4c-1234-5678-9abc-deadbeef0101"
+ _seed_user_layer(
+ reg,
+ "alice",
+ {
+ uuid: AvailableModel(
+ id=uuid,
+ provider=ModelProvider.OPENAI_COMPATIBLE,
+ display_name="pinned",
+ base_url="https://api.mistral.ai/v1",
+ upstream_model_id="mistral-large-latest",
+ source="user",
+ api_key="sk-real",
+ )
+ },
+ )
+
+ from application.llm.llm_creator import LLMCreator
+ from application.llm.providers import PROVIDERS_BY_NAME
+
+ with patch.object(
+ PROVIDERS_BY_NAME["openai_compatible"], "llm_class", _FakeLLM
+ ):
+ LLMCreator.create_llm(
+ type="openai_compatible",
+ api_key="caller-key",
+ user_api_key=None,
+ decoded_token={"sub": "alice"},
+ model_id=uuid,
+ )
+
+ client = captured["http_client"]
+ try:
+ assert isinstance(client, httpx.Client), (
+ "http_client must be set for user-source models so the "
+ "OpenAI SDK doesn't re-resolve DNS at request time"
+ )
+ assert isinstance(client._transport, _PinnedHTTPSTransport)
+ assert client._transport._host == "api.mistral.ai"
+ assert client._transport._ip_netloc == "104.18.0.1"
+ assert client.follow_redirects is False
+ finally:
+ if client is not None:
+ client.close()
+
+ def test_dispatch_skips_pinned_client_for_builtin_model(self, pg_conn):
+ """Built-in (non-BYOM) models don't need the pinned client —
+ their endpoints are operator-trusted. Skipping avoids
+ unnecessary DNS lookups and keeps the SDK's default httpx
+ client behavior unchanged for the common path."""
+ from application.core.model_settings import (
+ AvailableModel,
+ ModelProvider,
+ )
+
+ captured: dict = {}
+
+ class _FakeLLM:
+ def __init__(self, api_key, user_api_key, *a, **kw):
+ captured["http_client"] = kw.get("http_client")
+
+ s = _make_settings()
+ with patch("application.core.settings.settings", s):
+ reg = ModelRegistry()
+ uuid = "0b7e0f4c-1234-5678-9abc-deadbeef0102"
+ reg._builtin_models = {
+ uuid: AvailableModel(
+ id=uuid,
+ provider=ModelProvider.OPENAI_COMPATIBLE,
+ display_name="builtin",
+ base_url=None,
+ upstream_model_id="gpt-4",
+ source="builtin",
+ )
+ }
+
+ from application.llm.llm_creator import LLMCreator
+ from application.llm.providers import PROVIDERS_BY_NAME
+
+ with patch.object(
+ PROVIDERS_BY_NAME["openai_compatible"], "llm_class", _FakeLLM
+ ):
+ LLMCreator.create_llm(
+ type="openai_compatible",
+ api_key="op-key",
+ user_api_key=None,
+ decoded_token={"sub": "alice"},
+ model_id=uuid,
+ )
+
+ assert captured["http_client"] is None, (
+ "built-in models must not get the pinned client — keeps "
+ "the default OpenAI SDK transport for operator-trusted "
+ "endpoints"
+ )
+
+
+@pytest.mark.unit
+class TestSharedAgentResolvesOwnerBYOM:
+ """P2 #1: a shared agent with a BYOM default_model_id must resolve
+ against the *owner's* per-user layer, not the caller's."""
+
+ def test_owner_byom_resolves_for_caller_request(self, pg_conn):
+ from application.api.answer.services.stream_processor import (
+ StreamProcessor,
+ )
+ from application.storage.db.repositories.user_custom_models import (
+ UserCustomModelsRepository,
+ )
+
+ # Owner creates a BYOM model.
+ repo = UserCustomModelsRepository(pg_conn)
+ owner_model = repo.create(
+ user_id="owner",
+ upstream_model_id="mistral-large-latest",
+ display_name="Owner Mistral",
+ base_url="https://api.mistral.ai/v1",
+ api_key_plaintext="sk-owner",
+ )
+
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ ModelRegistry()
+ # Caller requests against the shared agent — initial_user_id
+ # is the caller, agent_config.user_id is the owner.
+ sp = StreamProcessor.__new__(StreamProcessor)
+ sp.data = {}
+ sp.initial_user_id = "caller"
+ sp.agent_config = {
+ "user_id": "owner",
+ "default_model_id": owner_model["id"],
+ }
+ sp._validate_and_set_model()
+
+ # The owner's BYOM model resolved (not a fallback to system default)
+ assert sp.model_id == owner_model["id"]
+ assert sp.model_user_id == "owner"
+
+ def test_classic_rag_rephrase_resolves_owner_byom(self, pg_conn, monkeypatch):
+ """ClassicRAG must resolve a BYOM model_id through LLMCreator so
+ the rephrase LLM gets the owner's api_key/base_url and dispatches
+ the upstream model name (e.g. ``mistral-large-latest``) — not the
+ registry UUID — when called with chat history + active docs."""
+ from application.llm.providers import PROVIDERS_BY_NAME
+ from application.retriever.classic_rag import ClassicRAG
+ from application.storage.db.repositories.user_custom_models import (
+ UserCustomModelsRepository,
+ )
+
+ repo = UserCustomModelsRepository(pg_conn)
+ owner_model = repo.create(
+ user_id="owner",
+ upstream_model_id="mistral-large-latest",
+ display_name="Owner Mistral",
+ base_url="https://api.mistral.ai/v1",
+ api_key_plaintext="sk-owner",
+ )
+
+ # Capture what LLMCreator hands the provider's llm_class.
+ captured = {}
+
+ class _CapturingLLM:
+ def __init__(self, api_key, user_api_key, **kwargs):
+ captured["api_key"] = api_key
+ captured["user_api_key"] = user_api_key
+ captured["base_url"] = kwargs.get("base_url")
+ captured["model_id"] = kwargs.get("model_id")
+ self.model_id = kwargs.get("model_id")
+
+ def gen(self, model, messages):
+ captured["gen_model"] = model
+ return "rephrased"
+
+ monkeypatch.setattr(
+ PROVIDERS_BY_NAME["openai_compatible"], "llm_class", _CapturingLLM
+ )
+
+ s = _make_settings(API_KEY="instance-secret")
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ ModelRegistry()
+ # Caller != owner: model_user_id="owner" must drive lookup.
+ ClassicRAG(
+ source={"question": "follow-up?", "active_docs": ["vs1"]},
+ chat_history=[{"prompt": "earlier", "response": "answer"}],
+ chunks=2,
+ model_id=owner_model["id"],
+ model_user_id="owner",
+ llm_name="openai_compatible",
+ api_key="instance-secret",
+ decoded_token={"sub": "caller"},
+ )
+
+ assert captured["api_key"] == "sk-owner", (
+ "BYOM api_key must override the caller-passed instance secret"
+ )
+ assert captured["base_url"] == "https://api.mistral.ai/v1"
+ assert captured["model_id"] == "mistral-large-latest"
+ assert captured["gen_model"] == "mistral-large-latest", (
+ "rephrase must send the upstream model name, not the registry UUID"
+ )
+
+ def test_configure_agent_propagates_owner_into_agent_config(self, pg_conn):
+ """_configure_agent must populate agent_config['user_id'] from the
+ agent record so _validate_and_set_model can reach the owner's
+ BYOM layer. Without this, shared-agent BYOM defaults silently
+ fall back to the system default for any non-owner caller."""
+ from application.api.answer.services.stream_processor import (
+ StreamProcessor,
+ )
+ from application.storage.db.repositories.user_custom_models import (
+ UserCustomModelsRepository,
+ )
+
+ repo = UserCustomModelsRepository(pg_conn)
+ owner_model = repo.create(
+ user_id="owner",
+ upstream_model_id="mistral-large-latest",
+ display_name="Owner Mistral",
+ base_url="https://api.mistral.ai/v1",
+ api_key_plaintext="sk-owner",
+ )
+
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ ModelRegistry()
+
+ sp = StreamProcessor.__new__(StreamProcessor)
+ sp.data = {"api_key": "agent-key"}
+ sp.decoded_token = {"sub": "caller"}
+ sp.initial_user_id = "caller"
+ sp.agent_config = {}
+ sp.is_shared_usage = True
+ sp.shared_token = None
+ sp.agent_id = None
+ sp.agent_key = "agent-key"
+ sp._agent_data = None
+
+ agent_record = {
+ "_id": "agent-uuid",
+ "user": "owner",
+ "prompt_id": "default",
+ "agent_type": "classic",
+ "default_model_id": owner_model["id"],
+ "models": [],
+ }
+
+ with patch.object(
+ StreamProcessor, "_resolve_agent_id", return_value="agent-uuid"
+ ), patch.object(
+ StreamProcessor,
+ "_get_agent_key",
+ return_value=("agent-key", True, None),
+ ), patch.object(
+ StreamProcessor, "_get_data_from_api_key", return_value=agent_record
+ ):
+ sp._configure_agent()
+ sp._validate_and_set_model()
+
+ # Owner identity flows through agent_config so the owner's BYOM
+ # model resolves for the caller.
+ assert sp.agent_config["user_id"] == "owner"
+ assert sp.model_id == owner_model["id"]
+ assert sp.model_user_id == "owner"
+
+
+@pytest.mark.unit
+class TestCompressionThresholdHonorsByomContextWindow:
+ """P2 #2: orchestrator passes user_id to the threshold checker so
+ BYOM context windows are honored (not the default 128k limit)."""
+
+ def test_orchestrator_passes_user_id_to_should_compress(self, monkeypatch):
+ from application.api.answer.services.compression.orchestrator import (
+ CompressionOrchestrator,
+ )
+ from application.api.answer.services.compression.threshold_checker import (
+ CompressionThresholdChecker,
+ )
+
+ captured: dict = {}
+
+ def _record(self, conversation, model_id, current_query_tokens, **kw):
+ captured["user_id"] = kw.get("user_id")
+ return False
+
+ monkeypatch.setattr(
+ CompressionThresholdChecker, "should_compress", _record
+ )
+
+ conv_service = MagicMock()
+ conv_service.get_conversation.return_value = {
+ "queries": [{"prompt": "hi", "response": "hello"}]
+ }
+ orch = CompressionOrchestrator(
+ conversation_service=conv_service,
+ threshold_checker=CompressionThresholdChecker(),
+ )
+ orch.compress_if_needed(
+ conversation_id="c1",
+ user_id="alice",
+ model_id="0b7e0f4c-1234-5678-9abc-deadbeef0200",
+ decoded_token={"sub": "alice"},
+ )
+ assert captured["user_id"] == "alice"
+
+
+@pytest.mark.unit
+class TestContinuationPreservesByomScope:
+ """P2: continuation/resume must persist ``model_user_id`` so the
+ resumed LLM dispatch uses the same BYOM scope (owner for shared
+ agents) as the initial call. Without this, resume falls back to
+ ``decoded_token['sub']`` and the registry lookup misses the
+ owner-stored BYOM record."""
+
+ def test_resume_forwards_model_user_id_from_agent_config(
+ self, monkeypatch
+ ):
+ """``resume_from_tool_actions`` reads ``model_user_id`` from the
+ saved ``agent_config`` and forwards it to LLMCreator so the
+ resumed dispatch resolves the owner's BYOM."""
+ from application.api.answer.services import stream_processor as sp_mod
+ from application.llm import llm_creator as llm_creator_mod
+ from application.llm.handlers import handler_creator as handler_mod
+
+ captured: dict = {}
+
+ def _fake_create_llm(*args, **kwargs):
+ captured["model_id"] = kwargs.get("model_id")
+ captured["model_user_id"] = kwargs.get("model_user_id")
+ return MagicMock()
+
+ monkeypatch.setattr(
+ llm_creator_mod.LLMCreator, "create_llm", _fake_create_llm
+ )
+ monkeypatch.setattr(
+ handler_mod.LLMHandlerCreator,
+ "create_handler",
+ lambda *a, **kw: MagicMock(),
+ )
+
+ cont_service = MagicMock()
+ cont_service.load_state.return_value = {
+ "messages": [],
+ "pending_tool_calls": [],
+ "tools_dict": {},
+ "tool_schemas": [],
+ "agent_config": {
+ "model_id": "byom-uuid",
+ "model_user_id": "owner",
+ "llm_name": "openai_compatible",
+ "api_key": "instance-secret",
+ "user_api_key": None,
+ "agent_id": None,
+ "agent_type": "ClassicAgent",
+ "prompt": "",
+ "json_schema": None,
+ "retriever_config": None,
+ },
+ "client_tools": None,
+ }
+ # ContinuationService is imported lazily inside the method —
+ # patch the source module so the lookup at call time resolves
+ # to our mock.
+ from application.api.answer.services import (
+ continuation_service as cont_mod,
+ )
+
+ monkeypatch.setattr(
+ cont_mod, "ContinuationService", lambda: cont_service
+ )
+ from application.agents import tool_executor as te_mod
+
+ monkeypatch.setattr(
+ te_mod, "ToolExecutor", lambda **kw: MagicMock(client_tools=None)
+ )
+
+ # Stub AgentCreator.create_agent so we don't need a full agent
+ # tree — we're only exercising the LLMCreator forwarding.
+ from application.agents import agent_creator as ac_mod
+
+ monkeypatch.setattr(
+ ac_mod.AgentCreator, "create_agent", lambda *a, **kw: MagicMock()
+ )
+
+ sp = sp_mod.StreamProcessor.__new__(sp_mod.StreamProcessor)
+ sp.data = {}
+ sp.decoded_token = {"sub": "caller"}
+ sp.initial_user_id = "caller"
+ sp.conversation_id = "00000000-0000-0000-0000-000000000001"
+ sp.agent_config = {}
+
+ sp.resume_from_tool_actions(
+ tool_actions=[],
+ conversation_id="00000000-0000-0000-0000-000000000001",
+ )
+
+ assert captured["model_id"] == "byom-uuid"
+ assert captured["model_user_id"] == "owner", (
+ "resume must thread the owner's BYOM scope, not fall back to "
+ "the caller's decoded_token['sub']"
+ )
+ # The route layer (StreamResource) reads ``processor.model_user_id``
+ # to forward into ``complete_stream``, which in turn drives both
+ # the post-resume title-LLM save AND the agent_config dict
+ # persisted on a *second* tool pause. Without writing the scope
+ # back onto self here, processor.model_user_id stays at the
+ # __init__ default (None) and the next save_state would persist
+ # ``model_user_id=None`` — losing owner scope on every
+ # subsequent resume.
+ assert sp.model_user_id == "owner"
+
+ def test_save_path_persists_model_user_id_in_agent_config(self):
+ """The save site (``routes/base.py:complete_stream``) must
+ include ``model_user_id`` in the ``agent_config`` dict written
+ to the continuation store."""
+ import inspect
+
+ from application.api.answer.routes.base import BaseAnswerResource
+
+ # complete_stream is the only callsite that constructs the
+ # save dict. Confirm the parameter is in scope and the key is
+ # spelled correctly so a future rename doesn't silently drop it.
+ sig = inspect.signature(BaseAnswerResource.complete_stream)
+ assert "model_user_id" in sig.parameters
+
+ src = inspect.getsource(BaseAnswerResource.complete_stream)
+ # Both: (a) the model_user_id parameter is in scope and (b) the
+ # save dict includes it under the same key the resume side reads.
+ assert '"model_user_id": model_user_id' in src
+
+
+@pytest.mark.unit
+class TestStreamProcessorForwardsByomScopeToBudgetCalls:
+ """P2: every model-scoped lookup downstream of
+ ``_validate_and_set_model`` must use ``self.model_user_id`` so the
+ BYOM context window flows through retriever budget, history trim,
+ compression threshold, and agent-side validation. Without this, a
+ shared-agent owner-BYOM with an 8k window has retrieval/history
+ sized against the default 128k, overfilling the upstream provider."""
+
+ def _make_processor(self, model_user_id="owner", initial_user_id="caller"):
+ from application.api.answer.services.stream_processor import (
+ StreamProcessor,
+ )
+
+ sp = StreamProcessor.__new__(StreamProcessor)
+ sp.data = {"history": "[]"}
+ sp.decoded_token = {"sub": initial_user_id}
+ sp.initial_user_id = initial_user_id
+ sp.model_user_id = model_user_id
+ sp.model_id = "byom-uuid"
+ sp.agent_key = None
+ sp.conversation_id = None
+ sp.compression_orchestrator = MagicMock()
+ sp._agent_data = None
+ return sp
+
+ def test_configure_retriever_passes_model_user_id_to_doc_budget(
+ self, monkeypatch
+ ):
+ from application.api.answer.services import stream_processor as sp_mod
+
+ captured: dict = {}
+
+ def _fake_budget(model_id, user_id=None):
+ captured["model_id"] = model_id
+ captured["user_id"] = user_id
+ return 6000
+
+ monkeypatch.setattr(
+ sp_mod, "calculate_doc_token_budget", _fake_budget
+ )
+
+ sp = self._make_processor(model_user_id="owner")
+ sp._configure_retriever()
+
+ assert captured["model_id"] == "byom-uuid"
+ assert captured["user_id"] == "owner", (
+ "doc budget must size against the owner's BYOM context "
+ "window for shared-agent dispatch, not the caller's"
+ )
+ assert sp.retriever_config["doc_token_limit"] == 6000
+
+ def test_load_conversation_history_passes_model_user_id_to_trim(
+ self, monkeypatch
+ ):
+ from application.api.answer.services import stream_processor as sp_mod
+
+ captured: dict = {}
+
+ def _fake_limit(history, model_id="docsgpt-local", user_id=None):
+ captured["model_id"] = model_id
+ captured["user_id"] = user_id
+ return history
+
+ monkeypatch.setattr(sp_mod, "limit_chat_history", _fake_limit)
+
+ sp = self._make_processor(model_user_id="owner")
+ # No conversation_id → falls into the else branch that calls
+ # limit_chat_history with the inline history payload.
+ sp._load_conversation_history()
+
+ assert captured["model_id"] == "byom-uuid"
+ assert captured["user_id"] == "owner"
+
+ def test_handle_compression_splits_caller_and_model_owner(self):
+ sp = self._make_processor(
+ model_user_id="owner", initial_user_id="caller"
+ )
+ sp.compression_orchestrator.compress_if_needed.return_value = (
+ MagicMock(success=True, history=[], summary=None, summary_tokens=0)
+ )
+
+ sp._handle_compression(conversation={})
+
+ kwargs = sp.compression_orchestrator.compress_if_needed.call_args.kwargs
+ assert kwargs["user_id"] == "caller", (
+ "conversation access check must run under the caller — the "
+ "owner has no relationship to the conversation row"
+ )
+ assert kwargs["model_user_id"] == "owner", (
+ "BYOM context-window / provider lookup must use the model "
+ "owner so a shared-agent owner-BYOM resolves correctly"
+ )
+
+ def test_handle_compression_falls_back_to_caller_when_no_byom_scope(
+ self,
+ ):
+ sp = self._make_processor(model_user_id=None, initial_user_id="caller")
+ sp.compression_orchestrator.compress_if_needed.return_value = (
+ MagicMock(success=True, history=[], summary=None, summary_tokens=0)
+ )
+
+ sp._handle_compression(conversation={})
+
+ kwargs = sp.compression_orchestrator.compress_if_needed.call_args.kwargs
+ assert kwargs["user_id"] == "caller"
+ assert kwargs["model_user_id"] is None
+
+
+@pytest.mark.unit
+class TestBaseAgentTokenLimitUsesModelUserId:
+ """P2: BaseAgent's three ``get_token_limit`` call sites must use
+ ``model_user_id`` (the BYOM owner) when set, falling back to
+ ``self.user`` (the caller / worker spoof). Otherwise shared-agent
+ owner-BYOM token-limit checks size against the caller's layer."""
+
+ def _build_agent(self, model_user_id="owner", caller="caller"):
+ from application.agents.classic_agent import ClassicAgent
+
+ # Stub LLM/handler/executor — we only exercise the get_token_limit
+ # callsites in BaseAgent.
+ return ClassicAgent(
+ endpoint="stream",
+ llm_name="openai_compatible",
+ model_id="byom-uuid",
+ api_key="instance-secret",
+ decoded_token={"sub": caller},
+ model_user_id=model_user_id,
+ llm=MagicMock(model_id="mistral-large-latest"),
+ llm_handler=MagicMock(),
+ tool_executor=MagicMock(),
+ )
+
+ def test_check_context_limit_uses_model_user_id(self, monkeypatch):
+ captured: list = []
+
+ def _fake(model_id, user_id=None):
+ captured.append({"model_id": model_id, "user_id": user_id})
+ return 8000
+
+ monkeypatch.setattr(
+ "application.core.model_utils.get_token_limit", _fake
+ )
+
+ agent = self._build_agent(model_user_id="owner", caller="caller")
+ agent._check_context_limit([{"role": "user", "content": "hi"}])
+
+ assert captured[0]["user_id"] == "owner"
+
+ def test_validate_context_size_uses_model_user_id(self, monkeypatch):
+ captured: list = []
+
+ def _fake(model_id, user_id=None):
+ captured.append({"user_id": user_id})
+ return 8000
+
+ monkeypatch.setattr(
+ "application.core.model_utils.get_token_limit", _fake
+ )
+
+ agent = self._build_agent(model_user_id="owner", caller="caller")
+ agent._validate_context_size([{"role": "user", "content": "hi"}])
+
+ assert captured[0]["user_id"] == "owner"
+
+ def test_build_messages_uses_model_user_id(self, monkeypatch):
+ captured: list = []
+
+ def _fake(model_id, user_id=None):
+ captured.append({"user_id": user_id})
+ return 8000
+
+ monkeypatch.setattr(
+ "application.core.model_utils.get_token_limit", _fake
+ )
+
+ agent = self._build_agent(model_user_id="owner", caller="caller")
+ agent._build_messages(system_prompt="sys", query="q")
+
+ assert captured[0]["user_id"] == "owner"
+
+ def test_falls_back_to_self_user_when_model_user_id_unset(
+ self, monkeypatch
+ ):
+ """Worker path: ``decoded_token['sub']`` is already the owner
+ (worker spoofs it), so ``model_user_id`` is None and the
+ fallback path uses ``self.user``."""
+ captured: list = []
+
+ def _fake(model_id, user_id=None):
+ captured.append({"user_id": user_id})
+ return 8000
+
+ monkeypatch.setattr(
+ "application.core.model_utils.get_token_limit", _fake
+ )
+
+ agent = self._build_agent(model_user_id=None, caller="worker-owner")
+ agent._validate_context_size([{"role": "user", "content": "hi"}])
+
+ assert captured[0]["user_id"] == "worker-owner"
+
+
+@pytest.mark.unit
+class TestNonAgentCallSitesUseUpstreamId:
+ """Locks in the call-site-by-call-site fix for direct ``llm.gen``
+ invocations that bypass the agent's ``_llm_gen``: handler tool
+ loops, conversation summarization, compression, and the classic
+ RAG query-rephrase. Each one was sending the registry UUID
+ upstream, which BYOM endpoints rejected with 401
+ 'key_model_access_denied'."""
+
+ def test_handler_tool_loop_uses_upstream_id(self):
+ """``handlers.base.handle_response`` re-calls the LLM after a
+ tool invocation. The model arg must be the upstream name."""
+ from application.llm.handlers.base import LLMHandler
+
+ captured: dict = {}
+
+ class _FakeLLM:
+ model_id = "mistral-large-latest" # upstream
+
+ def gen(self, model, messages, tools=None):
+ captured["model"] = model
+ resp = MagicMock()
+ resp.choices = [MagicMock()]
+ resp.choices[0].message.content = "done"
+ resp.choices[0].message.tool_calls = None
+ resp.choices[0].finish_reason = "stop"
+ return resp
+
+ agent = MagicMock()
+ agent.llm = _FakeLLM()
+ agent.model_id = "0b7e0f4c-1234-5678-9abc-deadbeef0001" # uuid
+ agent.tools = []
+
+ # Mimic the sub-class call directly to the line we patched.
+ # We're not exercising the full LLMHandler state machine — just
+ # the line that builds the gen call. Recreating it inline:
+ model_arg = (
+ getattr(agent.llm, "model_id", None) or agent.model_id
+ )
+ agent.llm.gen(model=model_arg, messages=[], tools=agent.tools)
+ assert captured["model"] == "mistral-large-latest"
+ assert captured["model"] != agent.model_id
+
+ # Belt-and-braces: also verify the source file actually contains
+ # the fix (catches accidental reverts).
+ from inspect import getsourcefile
+ from pathlib import Path
+
+ text = Path(getsourcefile(LLMHandler)).read_text()
+ # Both gen and gen_stream call sites use the getattr pattern
+ assert text.count("getattr(agent.llm") >= 2
+
+ def test_conversation_service_uses_upstream_id(self):
+ """``conversation_service.save_conversation`` calls
+ ``llm.gen(model=model_id, ...)`` to summarize. ``model_id`` is
+ the registry UUID; must use ``llm.model_id`` instead."""
+ from application.api.answer.services.conversation_service import (
+ ConversationService,
+ )
+
+ captured: dict = {}
+
+ class _FakeLLM:
+ model_id = "mistral-large-latest"
+
+ def gen(self, model, messages, **kwargs):
+ captured["model"] = model
+ return "summary"
+
+ # Inline the patched line to verify the contract — the full
+ # save_conversation path requires DB / agent setup orthogonal
+ # to this test.
+ llm = _FakeLLM()
+ registry_uuid = "0b7e0f4c-1234-5678-9abc-deadbeef0002"
+ model_arg = getattr(llm, "model_id", None) or registry_uuid
+ llm.gen(model=model_arg, messages=[])
+ assert captured["model"] == "mistral-large-latest"
+
+ # Source check: the actual line contains the getattr fix
+ from inspect import getsourcefile
+ from pathlib import Path
+
+ src_path = Path(getsourcefile(ConversationService))
+ text = src_path.read_text()
+ assert "getattr(llm" in text
+
+ def test_compression_service_uses_upstream_id(self):
+ """``CompressionService.compress`` must send ``llm.model_id``
+ (the upstream) to the provider, not ``self.model_id`` (the
+ registry UUID for BYOM)."""
+ from inspect import getsourcefile
+ from pathlib import Path
+
+ from application.api.answer.services.compression.service import (
+ CompressionService,
+ )
+
+ text = Path(getsourcefile(CompressionService)).read_text()
+ # Locks in the call-site fix for the gen invocation.
+ assert "getattr(self.llm" in text
+
+ def test_classic_rag_uses_upstream_id(self):
+ """``ClassicRAG._rephrase_query`` calls ``llm.gen(model=...)``
+ with what was the agent's UUID. Must use ``llm.model_id``."""
+ from inspect import getsourcefile
+ from pathlib import Path
+
+ from application.retriever.classic_rag import ClassicRAG
+
+ text = Path(getsourcefile(ClassicRAG)).read_text()
+ assert "getattr(self.llm" in text
+
+
+@pytest.mark.unit
+class TestAgentSendsUpstreamModelId:
+ def test_llm_gen_passes_upstream_id_to_provider(
+ self, pg_conn, byom_model
+ ):
+ """End-to-end: ClassicAgent's _llm_gen must put the user's
+ upstream model name (``mistral-large-latest``) in the call to
+ ``self.llm.gen_stream(model=...)``, not the registry UUID."""
+ model_uuid, user_id = byom_model
+
+ captured: dict = {}
+
+ class _FakeOpenAILLM:
+ def __init__(self, *a, **kw):
+ # LLMCreator passes model_id=upstream_model_id; agent
+ # caches it as self.upstream_model_id.
+ self.model_id = kw.get("model_id")
+ self.user_api_key = None
+ self._supports_tools = False
+
+ def _supports_structured_output(self):
+ return False
+
+ def supports_tools(self):
+ return False
+
+ def gen_stream(self, model, messages, **kw):
+ captured["model"] = model
+ captured["messages"] = messages
+ yield "ok"
+
+ def prepare_structured_output_format(self, schema):
+ return None
+
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ ModelRegistry()
+
+ from application.llm.providers import PROVIDERS_BY_NAME
+
+ with patch.object(
+ PROVIDERS_BY_NAME["openai_compatible"],
+ "llm_class",
+ _FakeOpenAILLM,
+ ):
+ from application.agents.classic_agent import ClassicAgent
+
+ agent = ClassicAgent(
+ endpoint="https://x",
+ llm_name="openai_compatible",
+ model_id=model_uuid,
+ api_key="caller-key",
+ user_api_key=None,
+ decoded_token={"sub": user_id},
+ prompt="system prompt",
+ chat_history=[],
+ )
+ # Sanity: agent cached the upstream id
+ assert agent.upstream_model_id == "mistral-large-latest"
+
+ # Drive _llm_gen via a minimal message; consume the
+ # generator so the fake gen_stream actually runs.
+ gen = agent._llm_gen([{"role": "user", "content": "hi"}])
+ list(gen)
+
+ assert captured["model"] == "mistral-large-latest"
+ assert captured["model"] != model_uuid
diff --git a/tests/core/test_registry_user_layer.py b/tests/core/test_registry_user_layer.py
new file mode 100644
index 00000000..bb5b3a22
--- /dev/null
+++ b/tests/core/test_registry_user_layer.py
@@ -0,0 +1,505 @@
+"""Tests for the BYOM per-user layer on ModelRegistry.
+
+Covers: per-user lookups don't leak across users, lookups without
+user_id stay built-in only, get_all_models / get_enabled_models /
+model_exists all consult the user layer when given user_id, and the
+explicit invalidate_user clears the cache.
+"""
+
+from __future__ import annotations
+
+from contextlib import contextmanager
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from application.core.model_registry import ModelRegistry
+from application.core.model_settings import ModelProvider
+from application.storage.db.repositories.user_custom_models import (
+ UserCustomModelsRepository,
+)
+
+
+@pytest.fixture(autouse=True)
+def _reset_registry():
+ ModelRegistry.reset()
+ yield
+ ModelRegistry.reset()
+
+
+def _make_settings(**overrides):
+ s = MagicMock()
+ s.OPENAI_BASE_URL = None
+ s.OPENAI_API_KEY = None
+ s.OPENAI_API_BASE = None
+ s.ANTHROPIC_API_KEY = None
+ s.GOOGLE_API_KEY = None
+ s.GROQ_API_KEY = None
+ s.OPEN_ROUTER_API_KEY = None
+ s.NOVITA_API_KEY = None
+ s.HUGGINGFACE_API_KEY = None
+ s.LLM_PROVIDER = ""
+ s.LLM_NAME = None
+ s.API_KEY = None
+ s.MODELS_CONFIG_DIR = None
+ for k, v in overrides.items():
+ setattr(s, k, v)
+ return s
+
+
+@contextmanager
+def _yield(conn):
+ yield conn
+
+
+@pytest.mark.unit
+class TestPerUserLayer:
+ def test_user_models_isolated_per_user(self, pg_conn):
+ """Alice's BYOM model must not appear in Bob's lookups."""
+ repo = UserCustomModelsRepository(pg_conn)
+ alice_model = repo.create(
+ user_id="alice",
+ upstream_model_id="alice-mistral",
+ display_name="Alice Mistral",
+ base_url="https://api.mistral.ai/v1",
+ api_key_plaintext="sk-alice",
+ )
+
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ reg = ModelRegistry()
+ assert reg.get_model(alice_model["id"], user_id="alice") is not None
+ assert reg.get_model(alice_model["id"], user_id="bob") is None
+ # And without a user_id at all, the per-user layer is invisible
+ assert reg.get_model(alice_model["id"]) is None
+
+ def test_get_all_models_includes_user_models(self, pg_conn):
+ repo = UserCustomModelsRepository(pg_conn)
+ created = repo.create(
+ user_id="user-1",
+ upstream_model_id="mistral-large-latest",
+ display_name="My Mistral",
+ base_url="https://api.mistral.ai/v1",
+ api_key_plaintext="sk-test",
+ )
+
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ reg = ModelRegistry()
+ ids_no_user = {m.id for m in reg.get_all_models()}
+ ids_with_user = {
+ m.id for m in reg.get_all_models(user_id="user-1")
+ }
+ assert created["id"] not in ids_no_user
+ assert created["id"] in ids_with_user
+
+ def test_user_models_carry_decrypted_api_key_and_upstream_id(self, pg_conn):
+ repo = UserCustomModelsRepository(pg_conn)
+ created = repo.create(
+ user_id="user-1",
+ upstream_model_id="mistral-large-latest",
+ display_name="My Mistral",
+ base_url="https://api.mistral.ai/v1",
+ api_key_plaintext="sk-test-XYZ",
+ )
+
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ reg = ModelRegistry()
+ m = reg.get_model(created["id"], user_id="user-1")
+
+ assert m is not None
+ assert m.provider == ModelProvider.OPENAI_COMPATIBLE
+ assert m.upstream_model_id == "mistral-large-latest"
+ assert m.api_key == "sk-test-XYZ"
+ assert m.base_url == "https://api.mistral.ai/v1"
+ assert m.source == "user"
+ # The wire format never leaks the api_key
+ d = m.to_dict()
+ assert "api_key" not in d
+ for v in d.values():
+ assert v != "sk-test-XYZ"
+
+ def test_invalidate_user_clears_cache(self, pg_conn):
+ repo = UserCustomModelsRepository(pg_conn)
+ created = repo.create(
+ user_id="user-1",
+ upstream_model_id="x",
+ display_name="X",
+ base_url="https://api.mistral.ai/v1",
+ api_key_plaintext="k",
+ )
+
+ s = _make_settings()
+ # Stub Redis so invalidate_user can publish its version bump
+ # without hitting a real broker. The P1 fix calls ``incr`` on
+ # invalidate; here we just need it not to raise.
+ fake_redis = MagicMock()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ), patch(
+ "application.cache.get_redis_instance", return_value=fake_redis
+ ):
+ reg = ModelRegistry()
+ assert reg.get_model(created["id"], user_id="user-1") is not None
+ # Cache populated
+ assert "user-1" in reg._user_models
+ ModelRegistry.invalidate_user("user-1")
+ assert "user-1" not in reg._user_models
+ # Re-lookup repopulates
+ reg.get_model(created["id"], user_id="user-1")
+ assert "user-1" in reg._user_models
+
+
+@pytest.mark.unit
+class TestLLMCreatorDispatchUsesUpstreamModelId:
+ def test_llmcreator_sends_upstream_id_not_uuid(self, pg_conn):
+ """End-to-end: LLMCreator with a BYOM uuid must construct the
+ OpenAILLM with the user's upstream model name (e.g.
+ ``mistral-large-latest``), not the registry uuid."""
+ repo = UserCustomModelsRepository(pg_conn)
+ created = repo.create(
+ user_id="user-1",
+ upstream_model_id="mistral-large-latest",
+ display_name="My Mistral",
+ base_url="https://api.mistral.ai/v1",
+ api_key_plaintext="sk-mistral-real",
+ )
+
+ captured = {}
+
+ class _FakeLLM:
+ def __init__(self, api_key, user_api_key, *args, **kwargs):
+ captured["api_key"] = api_key
+ captured["base_url"] = kwargs.get("base_url")
+ captured["model_id"] = kwargs.get("model_id")
+
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ ModelRegistry()
+ from application.llm.providers import PROVIDERS_BY_NAME
+
+ with patch.object(
+ PROVIDERS_BY_NAME["openai_compatible"], "llm_class", _FakeLLM
+ ):
+ from application.llm.llm_creator import LLMCreator
+
+ LLMCreator.create_llm(
+ type="openai_compatible",
+ api_key="caller-passed-WRONG",
+ user_api_key=None,
+ decoded_token={"sub": "user-1"},
+ model_id=created["id"],
+ )
+
+ assert captured["api_key"] == "sk-mistral-real"
+ assert captured["base_url"] == "https://api.mistral.ai/v1"
+ assert captured["model_id"] == "mistral-large-latest" # NOT the uuid!
+
+ def test_llmcreator_forwards_byom_capabilities(self, pg_conn):
+ """LLMCreator must thread the registry-resolved ``capabilities``
+ into the LLM. Without it the OpenAILLM hard-codes ``True`` for
+ tools/structured output and advertises image attachments
+ unconditionally, leaking unsupported features to BYOMs that
+ disabled them."""
+ repo = UserCustomModelsRepository(pg_conn)
+ created = repo.create(
+ user_id="user-2",
+ upstream_model_id="my-text-only-model",
+ display_name="Text-only BYOM",
+ base_url="https://api.mistral.ai/v1",
+ api_key_plaintext="sk-real",
+ capabilities={
+ "supports_tools": False,
+ "supports_structured_output": False,
+ "attachments": [],
+ "context_window": 8192,
+ },
+ )
+
+ captured = {}
+
+ class _FakeLLM:
+ def __init__(self, api_key, user_api_key, *args, **kwargs):
+ captured["capabilities"] = kwargs.get("capabilities")
+
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ ModelRegistry()
+ from application.llm.providers import PROVIDERS_BY_NAME
+
+ with patch.object(
+ PROVIDERS_BY_NAME["openai_compatible"], "llm_class", _FakeLLM
+ ):
+ from application.llm.llm_creator import LLMCreator
+
+ LLMCreator.create_llm(
+ type="openai_compatible",
+ api_key="ignored",
+ user_api_key=None,
+ decoded_token={"sub": "user-2"},
+ model_id=created["id"],
+ )
+
+ caps = captured["capabilities"]
+ assert caps is not None
+ assert caps.supports_tools is False
+ assert caps.supports_structured_output is False
+ assert caps.supported_attachment_types == []
+
+ def test_byom_image_alias_expands_to_mime_types(self, pg_conn):
+ """A BYOM stored with ``attachments: ["image"]`` (the alias the
+ UI sends) must surface as concrete MIME types on the registry
+ record, matching the built-in YAML expansion. Without this,
+ handlers/base.prepare_messages compares ``image/png`` against
+ the bare alias and filters every image upload as unsupported.
+ """
+ repo = UserCustomModelsRepository(pg_conn)
+ created = repo.create(
+ user_id="user-1",
+ upstream_model_id="my-vision-model",
+ display_name="Vision BYOM",
+ base_url="https://api.mistral.ai/v1",
+ api_key_plaintext="sk-real",
+ capabilities={"attachments": ["image"]},
+ )
+
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ reg = ModelRegistry()
+ model = reg.get_model(created["id"], user_id="user-1")
+
+ assert model is not None
+ types = model.capabilities.supported_attachment_types
+ assert "image" not in types, (
+ "alias must be expanded, not stored verbatim"
+ )
+ assert any(t.startswith("image/") for t in types)
+ # Must include at least the common web image types so any image
+ # an end user uploads has a chance to match.
+ assert "image/png" in types
+ assert "image/jpeg" in types
+
+ def test_byom_unknown_alias_is_skipped_at_runtime(self, pg_conn):
+ """Operator alias-map edits could orphan a stored alias. The
+ registry must drop the unknown entry rather than the entire
+ layer (which would hide every BYOM the user has).
+ """
+ repo = UserCustomModelsRepository(pg_conn)
+ created = repo.create(
+ user_id="user-1",
+ upstream_model_id="m",
+ display_name="M",
+ base_url="https://api.mistral.ai/v1",
+ api_key_plaintext="k",
+ # Bypass the route validation: write a bad alias straight
+ # to the row to simulate the post-edit orphan case.
+ capabilities={"attachments": ["image", "not-a-real-alias"]},
+ )
+
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ):
+ reg = ModelRegistry()
+ model = reg.get_model(created["id"], user_id="user-1")
+
+ assert model is not None
+ types = model.capabilities.supported_attachment_types
+ assert "not-a-real-alias" not in types
+ assert any(t.startswith("image/") for t in types)
+
+
+@pytest.mark.unit
+class TestCrossProcessInvalidation:
+ """The BYOM cache lives per-process. Without the P1 fix, a CRUD on
+ web-1 would leave the decrypted record (with old api_key/base_url)
+ cached forever in web-2 / Celery. These tests pin down that:
+
+ * ``invalidate_user`` publishes a version bump to Redis
+ * peers reload when the version they saw at load time is stale
+ * the local TTL bounds staleness even when Redis is unreachable
+ * unchanged version + expired TTL extends the entry without a
+ DB read (the common-case fast path)
+ """
+
+ def test_invalidate_user_publishes_redis_version_bump(self, pg_conn):
+ repo = UserCustomModelsRepository(pg_conn)
+ repo.create(
+ user_id="user-1",
+ upstream_model_id="m1",
+ display_name="M1",
+ base_url="https://api.mistral.ai/v1",
+ api_key_plaintext="k",
+ )
+
+ fake_redis = MagicMock()
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ), patch(
+ "application.cache.get_redis_instance", return_value=fake_redis
+ ):
+ ModelRegistry().get_model("anything", user_id="user-1")
+ ModelRegistry.invalidate_user("user-1")
+
+ fake_redis.incr.assert_called_once_with("byom:registry_version:user-1")
+
+ def test_peer_reloads_when_redis_version_changes(self, pg_conn):
+ """Two-process simulation. Peer loads at version=0; another
+ process's CRUD bumps the version and updates Postgres; peer's
+ next post-TTL access sees the version mismatch and reloads,
+ picking up the rotated key it never invalidated locally."""
+ from application.core import model_registry as registry_mod
+
+ repo = UserCustomModelsRepository(pg_conn)
+ created = repo.create(
+ user_id="user-1",
+ upstream_model_id="m-orig",
+ display_name="orig",
+ base_url="https://api.mistral.ai/v1",
+ api_key_plaintext="orig-key",
+ )
+
+ state = {"version": 0}
+
+ class _FakeRedis:
+ def get(self, key):
+ if key == "byom:registry_version:user-1":
+ return str(state["version"]).encode()
+ return None
+
+ def incr(self, key):
+ if key == "byom:registry_version:user-1":
+ state["version"] += 1
+
+ s = _make_settings()
+ # Force TTL to 0 so any subsequent access takes the post-TTL
+ # path without waiting 60s.
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ), patch(
+ "application.cache.get_redis_instance", return_value=_FakeRedis()
+ ), patch.object(
+ registry_mod, "_USER_CACHE_TTL_SECONDS", 0.0
+ ):
+ reg = ModelRegistry()
+ assert (
+ reg.get_model(created["id"], user_id="user-1").api_key
+ == "orig-key"
+ )
+
+ # Another process's CRUD: bump Redis counter + mutate the
+ # row. Note we deliberately do NOT call ``invalidate_user``
+ # in this process — that's the whole point of the test.
+ state["version"] += 1
+ repo.update(
+ created["id"],
+ "user-1",
+ {"api_key_plaintext": "rotated-key"},
+ )
+
+ assert (
+ reg.get_model(created["id"], user_id="user-1").api_key
+ == "rotated-key"
+ )
+
+ def test_ttl_bounds_staleness_when_redis_unavailable(self, pg_conn):
+ """Redis down → fall back to TTL-only invalidation. After the
+ TTL elapses, peers reload regardless."""
+ from application.core import model_registry as registry_mod
+
+ repo = UserCustomModelsRepository(pg_conn)
+ created = repo.create(
+ user_id="user-1",
+ upstream_model_id="m",
+ display_name="m",
+ base_url="https://api.mistral.ai/v1",
+ api_key_plaintext="orig",
+ )
+
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ lambda: _yield(pg_conn),
+ ), patch(
+ "application.cache.get_redis_instance", return_value=None
+ ), patch.object(
+ registry_mod, "_USER_CACHE_TTL_SECONDS", 0.0
+ ):
+ reg = ModelRegistry()
+ first = reg.get_model(created["id"], user_id="user-1")
+ assert first.api_key == "orig"
+
+ repo.update(
+ created["id"],
+ "user-1",
+ {"api_key_plaintext": "rotated"},
+ )
+
+ second = reg.get_model(created["id"], user_id="user-1")
+ assert second.api_key == "rotated"
+
+ def test_unchanged_version_extends_ttl_without_db_read(self, pg_conn):
+ """Hot path: TTL expires but Redis says no invalidation
+ happened — extend the entry without re-reading Postgres."""
+ from application.core import model_registry as registry_mod
+
+ repo = UserCustomModelsRepository(pg_conn)
+ created = repo.create(
+ user_id="user-1",
+ upstream_model_id="m",
+ display_name="m",
+ base_url="https://api.mistral.ai/v1",
+ api_key_plaintext="k",
+ )
+
+ fake_redis = MagicMock()
+ fake_redis.get.return_value = b"7" # constant version
+
+ db_open_count = {"n": 0}
+
+ @contextmanager
+ def _counting_db_readonly():
+ db_open_count["n"] += 1
+ yield pg_conn
+
+ s = _make_settings()
+ with patch("application.core.settings.settings", s), patch(
+ "application.storage.db.session.db_readonly",
+ _counting_db_readonly,
+ ), patch(
+ "application.cache.get_redis_instance", return_value=fake_redis
+ ), patch.object(
+ registry_mod, "_USER_CACHE_TTL_SECONDS", 0.0
+ ):
+ reg = ModelRegistry()
+ reg.get_model(created["id"], user_id="user-1")
+ first_open = db_open_count["n"]
+ # TTL has expired, but Redis returns the same version we
+ # captured at load time → no DB reload.
+ reg.get_model(created["id"], user_id="user-1")
+ reg.get_model(created["id"], user_id="user-1")
+ assert db_open_count["n"] == first_open
diff --git a/tests/llm/handlers/test_llm_handlers.py b/tests/llm/handlers/test_llm_handlers.py
index 6baf04e8..3e9470a7 100644
--- a/tests/llm/handlers/test_llm_handlers.py
+++ b/tests/llm/handlers/test_llm_handlers.py
@@ -1239,6 +1239,131 @@ class TestPerformInMemoryCompression:
assert messages is not None
assert agent.compressed_summary == "summary"
+ def test_uses_agent_model_user_id_for_byom_owner_scope(self):
+ """Shared-agent BYOM dispatch: the agent's model_id is the owner's
+ BYOM UUID and ``decoded_token['sub']`` is a different (caller) user.
+ Provider lookup and LLMCreator must run under the OWNER scope so
+ the registry hits the owner's per-user layer instead of missing
+ and falling back to the deployment default."""
+ handler = ConcreteHandler()
+ agent = Mock()
+ agent.model_id = "byom-uuid-owner"
+ agent.model_user_id = "owner-id"
+ agent.user_api_key = None
+ agent.decoded_token = {"sub": "caller-id"}
+ agent.agent_id = None
+ agent.context_limit_reached = False
+ agent.current_token_count = 0
+
+ mock_metadata = Mock()
+ mock_metadata.compressed_token_count = 100
+ mock_metadata.original_token_count = 1000
+ mock_metadata.compression_ratio = 10.0
+ mock_metadata.to_dict.return_value = {"ratio": 10.0}
+
+ mock_service = Mock()
+ mock_service.compress_conversation.return_value = mock_metadata
+ mock_service.get_compressed_context.return_value = (
+ "summary",
+ [{"prompt": "q", "response": "a"}],
+ )
+
+ provider_lookup = MagicMock(return_value="openai_compatible")
+ create_llm_spy = MagicMock(return_value=Mock())
+
+ with patch.object(
+ handler,
+ "_build_conversation_from_messages",
+ return_value={"queries": [{"prompt": "q", "response": "a"}]},
+ ), patch(
+ "application.core.model_utils.get_provider_from_model_id",
+ provider_lookup,
+ ), patch(
+ "application.core.model_utils.get_api_key_for_provider",
+ return_value="key",
+ ), patch(
+ "application.llm.llm_creator.LLMCreator.create_llm",
+ create_llm_spy,
+ ), patch(
+ "application.api.answer.services.compression.service.CompressionService",
+ return_value=mock_service,
+ ), patch.object(
+ handler,
+ "_rebuild_messages_after_compression",
+ return_value=[{"role": "system", "content": "rebuilt"}],
+ ), patch(
+ "application.core.settings.settings",
+ MagicMock(COMPRESSION_MODEL_OVERRIDE=None),
+ ):
+ success, _ = handler._perform_in_memory_compression(
+ agent, [{"role": "user", "content": "hi"}]
+ )
+
+ assert success is True
+ # Provider lookup must use the owner scope, not the caller's sub.
+ assert provider_lookup.call_args.kwargs["user_id"] == "owner-id"
+ # LLMCreator must receive the owner scope as model_user_id;
+ # without this the BYOM UUID resolves under the caller and the
+ # owner's registered base_url + api_key are missed.
+ assert create_llm_spy.call_args.kwargs["model_user_id"] == "owner-id"
+
+ def test_falls_back_to_caller_sub_when_no_model_user_id(self):
+ """Built-in or caller-owned BYOM: ``agent.model_user_id`` is None.
+ Compression should resolve under the caller's sub, matching the
+ pre-fix behavior."""
+ handler = ConcreteHandler()
+ agent = Mock()
+ agent.model_id = "gpt-4"
+ agent.model_user_id = None
+ agent.user_api_key = None
+ agent.decoded_token = {"sub": "caller-id"}
+ agent.agent_id = None
+ agent.context_limit_reached = False
+ agent.current_token_count = 0
+
+ mock_metadata = Mock()
+ mock_metadata.compressed_token_count = 100
+ mock_metadata.original_token_count = 1000
+ mock_metadata.to_dict.return_value = {}
+
+ mock_service = Mock()
+ mock_service.compress_conversation.return_value = mock_metadata
+ mock_service.get_compressed_context.return_value = ("summary", [])
+
+ provider_lookup = MagicMock(return_value="openai")
+ create_llm_spy = MagicMock(return_value=Mock())
+
+ with patch.object(
+ handler,
+ "_build_conversation_from_messages",
+ return_value={"queries": [{"prompt": "q", "response": "a"}]},
+ ), patch(
+ "application.core.model_utils.get_provider_from_model_id",
+ provider_lookup,
+ ), patch(
+ "application.core.model_utils.get_api_key_for_provider",
+ return_value="key",
+ ), patch(
+ "application.llm.llm_creator.LLMCreator.create_llm",
+ create_llm_spy,
+ ), patch(
+ "application.api.answer.services.compression.service.CompressionService",
+ return_value=mock_service,
+ ), patch.object(
+ handler,
+ "_rebuild_messages_after_compression",
+ return_value=[],
+ ), patch(
+ "application.core.settings.settings",
+ MagicMock(COMPRESSION_MODEL_OVERRIDE=None),
+ ):
+ handler._perform_in_memory_compression(
+ agent, [{"role": "user", "content": "hi"}]
+ )
+
+ assert provider_lookup.call_args.kwargs["user_id"] == "caller-id"
+ assert create_llm_spy.call_args.kwargs["model_user_id"] == "caller-id"
+
# ---------------------------------------------------------------------------
# _perform_mid_execution_compression — additional edge cases
diff --git a/tests/llm/test_base.py b/tests/llm/test_base.py
index e62d0186..6ee20bd0 100644
--- a/tests/llm/test_base.py
+++ b/tests/llm/test_base.py
@@ -197,7 +197,7 @@ class TestFallbackLLMResolution:
mock_fallback = StubLLM()
monkeypatch.setattr(
"application.core.model_utils.get_provider_from_model_id",
- lambda mid: "openai",
+ lambda mid, **_kwargs: "openai",
)
monkeypatch.setattr(
"application.core.model_utils.get_api_key_for_provider",
@@ -223,7 +223,7 @@ class TestFallbackLLMResolution:
monkeypatch.setattr(
"application.core.model_utils.get_provider_from_model_id",
- lambda mid: "openai",
+ lambda mid, **_kwargs: "openai",
)
monkeypatch.setattr(
"application.core.model_utils.get_api_key_for_provider",
@@ -262,7 +262,7 @@ class TestFallbackLLMResolution:
def test_backup_provider_not_found_skipped(self, monkeypatch):
monkeypatch.setattr(
"application.core.model_utils.get_provider_from_model_id",
- lambda mid: None,
+ lambda mid, **_kwargs: None,
)
monkeypatch.setattr(
"application.llm.base.settings",
diff --git a/tests/llm/test_fallback.py b/tests/llm/test_fallback.py
index 510962ca..1606ccff 100644
--- a/tests/llm/test_fallback.py
+++ b/tests/llm/test_fallback.py
@@ -11,9 +11,7 @@ import pytest
from application.llm.base import BaseLLM
-# ---------------------------------------------------------------------------
# Concrete LLM stubs
-# ---------------------------------------------------------------------------
class FakeLLM(BaseLLM):
@@ -59,9 +57,7 @@ class FakeLLM(BaseLLM):
return super().gen_stream(*args, **kwargs)
-# ---------------------------------------------------------------------------
# Helpers
-# ---------------------------------------------------------------------------
def _noop_decorator(func):
@@ -121,9 +117,7 @@ def patch_model_utils(monkeypatch):
CALL_ARGS = dict(model="test-model", messages=[{"role": "user", "content": "hi"}])
-# ---------------------------------------------------------------------------
# Tests — fallback_llm property resolution
-# ---------------------------------------------------------------------------
@pytest.mark.integration
@@ -135,7 +129,7 @@ class TestFallbackLLMResolution:
backup_llm = FakeLLM(responses=["backup response"])
patch_model_utils(
- get_provider=lambda mid: "openai",
+ get_provider=lambda mid, **_kwargs: "openai",
get_api_key=lambda prov: "fake-key",
create_llm=lambda type, **kw: backup_llm,
)
@@ -174,7 +168,7 @@ class TestFallbackLLMResolution:
good_backup = FakeLLM(responses=["good backup"])
call_count = {"n": 0}
- def fake_get_provider(model_id):
+ def fake_get_provider(model_id, **_kwargs):
call_count["n"] += 1
if model_id == "bad-model":
return None # unresolvable
@@ -202,9 +196,7 @@ class TestFallbackLLMResolution:
assert primary.fallback_llm is None
-# ---------------------------------------------------------------------------
# Tests — non-streaming fallback (gen)
-# ---------------------------------------------------------------------------
@pytest.mark.integration
@@ -221,7 +213,7 @@ class TestNonStreamingFallback:
backup = FakeLLM(responses=["backup ok"])
patch_model_utils(
- get_provider=lambda mid: "openai",
+ get_provider=lambda mid, **_kwargs: "openai",
get_api_key=lambda p: "k",
create_llm=lambda type, **kw: backup,
)
@@ -242,9 +234,7 @@ class TestNonStreamingFallback:
primary.gen(**CALL_ARGS)
-# ---------------------------------------------------------------------------
# Tests — streaming fallback (gen_stream)
-# ---------------------------------------------------------------------------
@pytest.mark.integration
@@ -261,7 +251,7 @@ class TestStreamingFallback:
backup = FakeLLM(stream_chunks=["fallback1", "fallback2"])
patch_model_utils(
- get_provider=lambda m: "openai",
+ get_provider=lambda m, **_kwargs: "openai",
get_api_key=lambda p: "k",
create_llm=lambda type, **kw: backup,
)
@@ -280,7 +270,7 @@ class TestStreamingFallback:
backup = FakeLLM(stream_chunks=["recovery1", "recovery2"])
patch_model_utils(
- get_provider=lambda m: "openai",
+ get_provider=lambda m, **_kwargs: "openai",
get_api_key=lambda p: "k",
create_llm=lambda type, **kw: backup,
)
@@ -305,9 +295,7 @@ class TestStreamingFallback:
list(primary.gen_stream(**CALL_ARGS))
-# ---------------------------------------------------------------------------
# Tests — backup model priority over global fallback
-# ---------------------------------------------------------------------------
@pytest.mark.integration
@@ -323,7 +311,7 @@ class TestBackupModelPriority:
return backup
patch_model_utils(
- get_provider=lambda m: "openai",
+ get_provider=lambda m, **_kwargs: "openai",
get_api_key=lambda p: "k",
create_llm=fake_create_llm,
)
@@ -346,7 +334,7 @@ class TestBackupModelPriority:
return backup
patch_model_utils(
- get_provider=lambda m: "openai",
+ get_provider=lambda m, **_kwargs: "openai",
get_api_key=lambda p: "k",
create_llm=fake_create_llm,
)
@@ -366,7 +354,7 @@ class TestBackupModelPriority:
global_fallback = FakeLLM(responses=["global ok"])
call_order = []
- def fake_get_provider(mid):
+ def fake_get_provider(mid, **_kwargs):
if mid == "broken-backup":
return "nonexistent_provider"
return "openai"
@@ -401,9 +389,7 @@ class TestBackupModelPriority:
assert call_order == ["broken-backup", "global-model"]
-# ---------------------------------------------------------------------------
# Tests — fallback uses its own model_id, not the primary's
-# ---------------------------------------------------------------------------
@pytest.mark.integration
@@ -419,7 +405,7 @@ class TestFallbackModelIdOverride:
)
patch_model_utils(
- get_provider=lambda m: "groq",
+ get_provider=lambda m, **_kwargs: "groq",
get_api_key=lambda p: "k",
create_llm=lambda type, **kw: backup,
)
@@ -441,7 +427,7 @@ class TestFallbackModelIdOverride:
)
patch_model_utils(
- get_provider=lambda m: "groq",
+ get_provider=lambda m, **_kwargs: "groq",
get_api_key=lambda p: "k",
create_llm=lambda type, **kw: backup,
)
@@ -465,7 +451,7 @@ class TestFallbackModelIdOverride:
)
patch_model_utils(
- get_provider=lambda m: "groq",
+ get_provider=lambda m, **_kwargs: "groq",
get_api_key=lambda p: "k",
create_llm=lambda type, **kw: backup,
)
@@ -480,3 +466,161 @@ class TestFallbackModelIdOverride:
assert chunks == ["partial1", "partial2", "recovered"]
assert backup.last_model_received == "groq-gpt-oss-120b"
+
+
+# Tests — model_user_id (BYOM owner scope) propagates into fallback resolution
+
+
+@pytest.mark.integration
+class TestFallbackModelUserIdScope:
+ """A shared agent dispatched by user B but owned by user A stores
+ A's BYOM UUIDs as backup_models. Without the P2 fix the fallback
+ property looks up those UUIDs against ``decoded_token['sub']`` (B,
+ the caller), which can't see A's per-user layer — backups are
+ silently skipped and the global FALLBACK_* settings are used
+ instead. These tests pin down that ``model_user_id`` (the owner)
+ is used both for the registry lookup and for the recursive
+ ``LLMCreator.create_llm`` call."""
+
+ def test_backup_lookup_uses_model_user_id_not_caller(
+ self, patch_model_utils
+ ):
+ captured = {"user_id": None}
+
+ def fake_get_provider(model_id, **kwargs):
+ captured["user_id"] = kwargs.get("user_id")
+ return "openai"
+
+ backup = FakeLLM(responses=["ok"])
+ patch_model_utils(
+ get_provider=fake_get_provider,
+ get_api_key=lambda p: "k",
+ create_llm=lambda type, **kw: backup,
+ )
+
+ primary = FakeLLM(
+ decoded_token={"sub": "caller-bob"},
+ model_user_id="owner-alice",
+ backup_models=["alice-byom-uuid"],
+ )
+ _ = primary.fallback_llm
+ assert captured["user_id"] == "owner-alice"
+
+ def test_backup_create_llm_receives_model_user_id(self, patch_model_utils):
+ backup = FakeLLM(responses=["ok"])
+ captured = {}
+
+ def fake_create_llm(type, **kw):
+ captured["model_user_id"] = kw.get("model_user_id")
+ captured["model_id"] = kw.get("model_id")
+ return backup
+
+ patch_model_utils(
+ get_provider=lambda m, **_kwargs: "openai",
+ get_api_key=lambda p: "k",
+ create_llm=fake_create_llm,
+ )
+
+ primary = FakeLLM(
+ decoded_token={"sub": "caller-bob"},
+ model_user_id="owner-alice",
+ backup_models=["alice-byom-uuid"],
+ )
+ _ = primary.fallback_llm
+ assert captured["model_user_id"] == "owner-alice"
+ assert captured["model_id"] == "alice-byom-uuid"
+
+ def test_global_fallback_create_llm_receives_model_user_id(
+ self, monkeypatch, patch_model_utils
+ ):
+ """The global FALLBACK_LLM_NAME path must also forward
+ ``model_user_id`` — operators can configure it to a BYOM UUID
+ that's owned by the same user as the primary model."""
+ backup = FakeLLM(responses=["ok"])
+ captured = {}
+
+ def fake_create_llm(type, **kw):
+ captured["model_user_id"] = kw.get("model_user_id")
+ return backup
+
+ patch_model_utils(create_llm=fake_create_llm)
+ monkeypatch.setattr(
+ "application.llm.base.settings",
+ MagicMock(
+ FALLBACK_LLM_PROVIDER="openai",
+ FALLBACK_LLM_NAME="some-uuid",
+ FALLBACK_LLM_API_KEY="k",
+ API_KEY="k",
+ ),
+ )
+
+ primary = FakeLLM(
+ decoded_token={"sub": "caller-bob"},
+ model_user_id="owner-alice",
+ backup_models=[],
+ )
+ _ = primary.fallback_llm
+ assert captured["model_user_id"] == "owner-alice"
+
+ def test_falls_back_to_caller_when_model_user_id_unset(
+ self, patch_model_utils
+ ):
+ """Built-in models / pre-P2 callers don't pass model_user_id.
+ In that case the caller's sub is still used — preserving
+ existing behaviour."""
+ captured = {}
+
+ def fake_get_provider(model_id, **kwargs):
+ captured["user_id"] = kwargs.get("user_id")
+ return "openai"
+
+ patch_model_utils(
+ get_provider=fake_get_provider,
+ get_api_key=lambda p: "k",
+ create_llm=lambda type, **kw: FakeLLM(responses=["ok"]),
+ )
+
+ primary = FakeLLM(
+ decoded_token={"sub": "caller-bob"},
+ model_user_id=None,
+ backup_models=["some-builtin-id"],
+ )
+ _ = primary.fallback_llm
+ assert captured["user_id"] == "caller-bob"
+
+
+# Tests — LLMCreator wires model_user_id through to BaseLLM
+
+
+@pytest.mark.unit
+class TestLLMCreatorPassesModelUserId:
+ """End-to-end through ``LLMCreator.create_llm``: the constructed
+ LLM must store ``model_user_id`` so its fallback property can
+ resolve under the right scope."""
+
+ def test_model_user_id_set_on_constructed_llm(self, monkeypatch):
+ from application.llm.llm_creator import LLMCreator
+ from application.llm.providers import PROVIDERS_BY_NAME
+
+ captured = {}
+
+ class _CapturingLLM:
+ def __init__(self, api_key, user_api_key, *args, **kwargs):
+ captured["model_user_id"] = kwargs.get("model_user_id")
+
+ # Pick any registered provider — we only need the constructor
+ # call to land in our fake.
+ monkeypatch.setattr(
+ PROVIDERS_BY_NAME["openai"], "llm_class", _CapturingLLM
+ )
+
+ LLMCreator.create_llm(
+ type="openai",
+ api_key="k",
+ user_api_key=None,
+ decoded_token={"sub": "caller-bob"},
+ model_id=None,
+ model_user_id="owner-alice",
+ )
+
+ assert captured["model_user_id"] == "owner-alice"
diff --git a/tests/llm/test_openai.py b/tests/llm/test_openai.py
index 13aa7879..973dbe2f 100644
--- a/tests/llm/test_openai.py
+++ b/tests/llm/test_openai.py
@@ -23,9 +23,7 @@ import pytest
from application.llm.openai import OpenAILLM, _truncate_base64_for_logging
-# ---------------------------------------------------------------------------
# Fake client helpers
-# ---------------------------------------------------------------------------
class _Msg:
def __init__(self, content=None, tool_calls=None):
@@ -116,9 +114,7 @@ def llm():
return instance
-# ---------------------------------------------------------------------------
# _truncate_base64_for_logging
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -170,9 +166,7 @@ class TestTruncateBase64ForLogging:
assert "BASE64_DATA_TRUNCATED" in result[0]["content"]["nested"]
-# ---------------------------------------------------------------------------
# _normalize_reasoning_value
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -212,9 +206,7 @@ class TestNormalizeReasoningValue:
assert OpenAILLM._normalize_reasoning_value(val) == "ab"
-# ---------------------------------------------------------------------------
# _extract_reasoning_text
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -240,9 +232,7 @@ class TestExtractReasoningText:
assert OpenAILLM._extract_reasoning_text(delta) == ""
-# ---------------------------------------------------------------------------
# _clean_messages_openai
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -340,9 +330,7 @@ class TestCleanMessagesOpenai:
llm._clean_messages_openai(msgs)
-# ---------------------------------------------------------------------------
# _raw_gen
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -392,9 +380,7 @@ class TestRawGen:
assert kwargs["tools"] == tools
-# ---------------------------------------------------------------------------
# _raw_gen_stream
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -444,9 +430,7 @@ class TestRawGenStream:
assert closed["called"]
-# ---------------------------------------------------------------------------
# _supports_tools / _supports_structured_output
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -459,9 +443,104 @@ class TestSupports:
assert llm._supports_structured_output() is True
-# ---------------------------------------------------------------------------
+# BYOM capability enforcement at dispatch
+
+
+@pytest.mark.unit
+class TestBYOMCapabilityEnforcement:
+ """LLMCreator threads ``capabilities`` from the registry into the LLM.
+ These tests verify that a BYOM with restrictive caps doesn't get tools,
+ structured output, or unsupported attachment types at dispatch — even
+ when the caller forwards them."""
+
+ @staticmethod
+ def _llm_with_caps(
+ supports_tools=False,
+ supports_structured_output=False,
+ attachments=None,
+ ):
+ from application.core.model_settings import ModelCapabilities
+ instance = OpenAILLM(
+ api_key="sk-test",
+ user_api_key=None,
+ capabilities=ModelCapabilities(
+ supports_tools=supports_tools,
+ supports_structured_output=supports_structured_output,
+ supported_attachment_types=attachments or [],
+ ),
+ )
+ instance.client = FakeClient()
+ return instance
+
+ def test_supports_tools_respects_disabled_caps(self):
+ llm = self._llm_with_caps(supports_tools=False)
+ assert llm._supports_tools() is False
+
+ def test_supports_tools_respects_enabled_caps(self):
+ llm = self._llm_with_caps(supports_tools=True)
+ assert llm._supports_tools() is True
+
+ def test_supports_structured_output_respects_caps(self):
+ llm_off = self._llm_with_caps(supports_structured_output=False)
+ llm_on = self._llm_with_caps(supports_structured_output=True)
+ assert llm_off._supports_structured_output() is False
+ assert llm_on._supports_structured_output() is True
+
+ def test_get_supported_attachment_types_respects_caps(self):
+ llm = self._llm_with_caps(attachments=[])
+ assert llm.get_supported_attachment_types() == []
+ llm2 = self._llm_with_caps(attachments=["image/png"])
+ assert llm2.get_supported_attachment_types() == ["image/png"]
+
+ def test_raw_gen_drops_tools_when_caps_deny(self):
+ llm = self._llm_with_caps(supports_tools=False)
+ tools = [{"type": "function", "function": {"name": "t"}}]
+ msgs = [{"role": "user", "content": "hi"}]
+ llm._raw_gen(
+ llm, model="gpt", messages=msgs, stream=False, tools=tools
+ )
+ kwargs = llm.client.chat.completions.last_kwargs
+ assert "tools" not in kwargs
+
+ def test_raw_gen_drops_response_format_when_caps_deny(self):
+ llm = self._llm_with_caps(supports_structured_output=False)
+ msgs = [{"role": "user", "content": "hi"}]
+ llm._raw_gen(
+ llm,
+ model="gpt",
+ messages=msgs,
+ stream=False,
+ response_format={"type": "json_object"},
+ )
+ kwargs = llm.client.chat.completions.last_kwargs
+ assert "response_format" not in kwargs
+
+ def test_raw_gen_stream_drops_tools_when_caps_deny(self):
+ llm = self._llm_with_caps(supports_tools=False)
+ tools = [{"type": "function", "function": {"name": "t"}}]
+ msgs = [{"role": "user", "content": "hi"}]
+ list(
+ llm._raw_gen_stream(
+ llm, model="gpt", messages=msgs, stream=True, tools=tools
+ )
+ )
+ kwargs = llm.client.chat.completions.last_kwargs
+ assert "tools" not in kwargs
+
+ def test_no_caps_keeps_provider_defaults(self, llm):
+ # ``llm`` fixture builds an OpenAILLM with capabilities=None,
+ # i.e. provider-class defaults. Tools/structured output should
+ # pass through unchanged.
+ tools = [{"type": "function", "function": {"name": "t"}}]
+ msgs = [{"role": "user", "content": "hi"}]
+ llm._raw_gen(
+ llm, model="gpt", messages=msgs, stream=False, tools=tools
+ )
+ kwargs = llm.client.chat.completions.last_kwargs
+ assert kwargs["tools"] == tools
+
+
# prepare_structured_output_format
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -546,9 +625,7 @@ class TestPrepareStructuredOutputFormat:
assert result["json_schema"]["description"] == "Structured response"
-# ---------------------------------------------------------------------------
# get_supported_attachment_types
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -560,9 +637,7 @@ class TestGetSupportedAttachmentTypes:
assert len(result) > 0
-# ---------------------------------------------------------------------------
# prepare_messages_with_attachments
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -654,9 +729,7 @@ class TestPrepareMessagesWithAttachments:
assert isinstance(user_msg["content"], list)
-# ---------------------------------------------------------------------------
# _get_base64_image
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -678,9 +751,7 @@ class TestGetBase64Image:
llm._get_base64_image({"path": "/nonexistent"})
-# ---------------------------------------------------------------------------
# AzureOpenAILLM
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -718,9 +789,7 @@ class TestAzureOpenAILLM:
assert issubclass(oai_mod.AzureOpenAILLM, oai_mod.OpenAILLM)
-# ---------------------------------------------------------------------------
# _truncate_base64_for_logging — additional edges
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -744,9 +813,7 @@ class TestTruncateBase64ForLoggingAdditional:
assert result[0]["content"] == "no base64 here"
-# ---------------------------------------------------------------------------
# _clean_messages_openai — additional edges
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -835,9 +902,7 @@ class TestCleanMessagesOpenaiAdditional:
llm._clean_messages_openai(msgs)
-# ---------------------------------------------------------------------------
# _normalize_reasoning_value — additional edges
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -862,9 +927,7 @@ class TestNormalizeReasoningValueAdditional:
assert OpenAILLM._normalize_reasoning_value(obj) == ""
-# ---------------------------------------------------------------------------
# _extract_reasoning_text — additional edges
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -881,9 +944,7 @@ class TestExtractReasoningTextAdditional:
assert OpenAILLM._extract_reasoning_text(delta) == "dict_thought"
-# ---------------------------------------------------------------------------
# _raw_gen_stream — additional edges
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -933,9 +994,7 @@ class TestRawGenStreamAdditional:
assert any(hasattr(c, "finish_reason") for c in chunks)
-# ---------------------------------------------------------------------------
# prepare_structured_output_format — additional edges
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -968,9 +1027,7 @@ class TestPrepareStructuredOutputAdditional:
assert one_of[0]["additionalProperties"] is False
-# ---------------------------------------------------------------------------
# prepare_messages_with_attachments — additional edges
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -1022,9 +1079,7 @@ class TestPrepareMessagesWithAttachmentsAdditional:
assert len(text_parts) == 0
-# ---------------------------------------------------------------------------
# _upload_file_to_openai — additional edges
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -1053,9 +1108,7 @@ class TestUploadFileToOpenai:
llm._upload_file_to_openai({"path": "/tmp/file.pdf"})
-# ---------------------------------------------------------------------------
# OpenAILLM constructor — additional edges
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -1134,9 +1187,7 @@ class TestOpenAILLMConstructor:
)
-# ---------------------------------------------------------------------------
# _upload_file_to_openai — coverage lines 489-517
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -1181,9 +1232,7 @@ class TestUploadFileToOpenai2:
llm._upload_file_to_openai({"path": "/file.pdf"})
-# ---------------------------------------------------------------------------
# _normalize_reasoning_value — additional edges for line 155, 198
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -1206,9 +1255,7 @@ class TestNormalizeReasoningAdditional:
assert result == "ab"
-# ---------------------------------------------------------------------------
# _extract_reasoning_text — additional edge for line 198
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -1227,9 +1274,7 @@ class TestExtractReasoningTextAdditional2:
assert result == ""
-# ---------------------------------------------------------------------------
# prepare_structured_output_format — error path for line 348, 395
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -1247,9 +1292,7 @@ class TestPrepareStructuredOutputAdditional2:
assert result is None
-# ---------------------------------------------------------------------------
# Coverage — remaining uncovered lines
-# ---------------------------------------------------------------------------
@pytest.mark.unit
@@ -1408,13 +1451,11 @@ class TestUploadFileToOpenaiLines489To517:
assert result == "file-no-cache"
-# ---------------------------------------------------------------------------
# Additional coverage for openai.py
# Lines: 49 (truncate_content v passthrough), 80-82 (default base_url),
# 137 (function_response content), 198 (delta get fallback),
# 304 (_supports_structured_output), 395 (no user_message append),
# 469 (_get_base64_image missing path), 489-517 (_upload_file_to_openai)
-# ---------------------------------------------------------------------------
@pytest.mark.unit
diff --git a/tests/security/test_safe_url.py b/tests/security/test_safe_url.py
new file mode 100644
index 00000000..acc8532b
--- /dev/null
+++ b/tests/security/test_safe_url.py
@@ -0,0 +1,710 @@
+"""Unit tests for ``application.security.safe_url``.
+
+These tests must run offline, so every "valid public host" case mocks
+``socket.getaddrinfo`` to return a known public IP. Cases that test
+IP-literal validation do not need DNS at all and rely on the
+short-circuit path inside ``validate_user_base_url``.
+"""
+
+from __future__ import annotations
+
+import socket
+from unittest import mock
+
+import pytest
+import requests
+
+from application.security.safe_url import (
+ UnsafeUserUrlError,
+ _PinnedHTTPSTransport,
+ pinned_httpx_client,
+ pinned_post,
+ validate_user_base_url,
+)
+
+
+def _addrinfo(*ips: str) -> list[tuple]:
+ """Build a fake ``socket.getaddrinfo`` return value for ``ips``."""
+
+ out: list[tuple] = []
+ for ip in ips:
+ family = socket.AF_INET6 if ":" in ip else socket.AF_INET
+ port_tuple = (ip, 0, 0, 0) if family == socket.AF_INET6 else (ip, 0)
+ out.append((family, socket.SOCK_STREAM, 0, "", port_tuple))
+ return out
+
+
+# Valid URLs (DNS mocked to a known-public IP)
+
+
+@pytest.mark.unit
+def test_allows_openai_api():
+ with mock.patch("socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")):
+ assert validate_user_base_url("https://api.openai.com/v1") is None
+
+
+@pytest.mark.unit
+def test_allows_mistral_api():
+ with mock.patch("socket.getaddrinfo", return_value=_addrinfo("172.67.144.116")):
+ assert validate_user_base_url("https://api.mistral.ai/v1") is None
+
+
+@pytest.mark.unit
+def test_allows_http_with_port():
+ with mock.patch("socket.getaddrinfo", return_value=_addrinfo("93.184.216.34")):
+ assert validate_user_base_url("http://example.com:8080/v1") is None
+
+
+# Scheme rejection
+
+
+@pytest.mark.unit
+def test_rejects_file_scheme():
+ with pytest.raises(UnsafeUserUrlError, match="scheme"):
+ validate_user_base_url("file:///etc/passwd")
+
+
+@pytest.mark.unit
+def test_rejects_gopher_scheme():
+ with pytest.raises(UnsafeUserUrlError, match="scheme"):
+ validate_user_base_url("gopher://example.com")
+
+
+@pytest.mark.unit
+def test_rejects_ftp_scheme():
+ with pytest.raises(UnsafeUserUrlError, match="scheme"):
+ validate_user_base_url("ftp://example.com")
+
+
+# Hostname-string blocklist
+
+
+@pytest.mark.unit
+def test_rejects_localhost_hostname():
+ with pytest.raises(UnsafeUserUrlError, match="not allowed"):
+ validate_user_base_url("https://localhost/v1")
+
+
+@pytest.mark.unit
+def test_rejects_localhost_localdomain():
+ with pytest.raises(UnsafeUserUrlError, match="not allowed"):
+ validate_user_base_url("https://localhost.localdomain/v1")
+
+
+@pytest.mark.unit
+def test_rejects_localhost_uppercase():
+ # Hostname check must be case-insensitive.
+ with pytest.raises(UnsafeUserUrlError, match="not allowed"):
+ validate_user_base_url("https://LocalHost/v1")
+
+
+@pytest.mark.unit
+def test_rejects_ip6_localhost_alias():
+ with pytest.raises(UnsafeUserUrlError, match="not allowed"):
+ validate_user_base_url("https://ip6-localhost/v1")
+
+
+@pytest.mark.unit
+def test_rejects_gcp_metadata_hostname():
+ with pytest.raises(UnsafeUserUrlError, match="not allowed"):
+ validate_user_base_url("https://metadata.google.internal/computeMetadata/v1/")
+
+
+# IP-literal rejection (no DNS hit needed; covered by short-circuit)
+
+
+@pytest.mark.unit
+def test_rejects_loopback_ipv4_literal():
+ with pytest.raises(UnsafeUserUrlError, match="blocked address"):
+ validate_user_base_url("https://127.0.0.1/v1")
+
+
+@pytest.mark.unit
+def test_rejects_loopback_ipv6_literal():
+ with pytest.raises(UnsafeUserUrlError, match="not allowed|blocked"):
+ validate_user_base_url("https://[::1]/v1")
+
+
+@pytest.mark.unit
+def test_rejects_private_10_8():
+ with pytest.raises(UnsafeUserUrlError, match="blocked address"):
+ validate_user_base_url("https://10.0.0.5/v1")
+
+
+@pytest.mark.unit
+def test_rejects_private_172_16_low_boundary():
+ with pytest.raises(UnsafeUserUrlError, match="blocked address"):
+ validate_user_base_url("https://172.16.0.5/v1")
+
+
+@pytest.mark.unit
+def test_rejects_private_172_16_high_boundary():
+ with pytest.raises(UnsafeUserUrlError, match="blocked address"):
+ validate_user_base_url("https://172.31.0.5/v1")
+
+
+@pytest.mark.unit
+def test_rejects_private_192_168():
+ with pytest.raises(UnsafeUserUrlError, match="blocked address"):
+ validate_user_base_url("https://192.168.1.1/v1")
+
+
+@pytest.mark.unit
+def test_rejects_aws_metadata_link_local():
+ with pytest.raises(UnsafeUserUrlError, match="blocked address"):
+ validate_user_base_url("https://169.254.169.254/latest/meta-data/")
+
+
+@pytest.mark.unit
+def test_rejects_unique_local_ipv6():
+ with pytest.raises(UnsafeUserUrlError, match="blocked address"):
+ validate_user_base_url("https://[fc00::1]/v1")
+
+
+@pytest.mark.unit
+def test_rejects_link_local_ipv6():
+ with pytest.raises(UnsafeUserUrlError, match="blocked address"):
+ validate_user_base_url("https://[fe80::1]/v1")
+
+
+@pytest.mark.unit
+def test_rejects_multicast_ipv4():
+ with pytest.raises(UnsafeUserUrlError, match="blocked address"):
+ validate_user_base_url("https://224.0.0.1/v1")
+
+
+@pytest.mark.unit
+def test_rejects_unspecified_zero_address():
+ # 0.0.0.0 is in the literal hostname blocklist AND also caught as
+ # unspecified; either error message is acceptable.
+ with pytest.raises(UnsafeUserUrlError, match="not allowed|blocked"):
+ validate_user_base_url("https://0.0.0.0/v1")
+
+
+@pytest.mark.unit
+def test_rejects_carrier_grade_nat():
+ # 100.64.0.0/10 is NOT covered by ``ipaddress.is_private``.
+ with pytest.raises(UnsafeUserUrlError, match="blocked address"):
+ validate_user_base_url("https://100.64.0.1/v1")
+
+
+# Parse / structural failures
+
+
+@pytest.mark.unit
+def test_rejects_garbage_string():
+ with pytest.raises(UnsafeUserUrlError):
+ validate_user_base_url("not a url")
+
+
+@pytest.mark.unit
+def test_rejects_empty_string():
+ with pytest.raises(UnsafeUserUrlError, match="non-empty"):
+ validate_user_base_url("")
+
+
+@pytest.mark.unit
+def test_rejects_whitespace_only_string():
+ with pytest.raises(UnsafeUserUrlError, match="non-empty"):
+ validate_user_base_url(" ")
+
+
+@pytest.mark.unit
+def test_rejects_url_without_hostname():
+ with pytest.raises(UnsafeUserUrlError):
+ validate_user_base_url("https:///v1")
+
+
+# DNS-mocking tests for hostnames (rebinding-style scenarios)
+
+
+@pytest.mark.unit
+def test_rejects_hostname_resolving_only_to_private():
+ with mock.patch("socket.getaddrinfo", return_value=_addrinfo("10.0.0.5")):
+ with pytest.raises(UnsafeUserUrlError, match="blocked address"):
+ validate_user_base_url("https://internal.example.com/v1")
+
+
+@pytest.mark.unit
+def test_rejects_hostname_with_mixed_public_and_private():
+ # Public IP first, private second — must still reject because ANY
+ # blocked address in the answer set is enough.
+ with mock.patch(
+ "socket.getaddrinfo",
+ return_value=_addrinfo("93.184.216.34", "10.0.0.5"),
+ ):
+ with pytest.raises(UnsafeUserUrlError, match="blocked address"):
+ validate_user_base_url("https://rebinding.example.com/v1")
+
+
+@pytest.mark.unit
+def test_rejects_hostname_when_dns_fails():
+ with mock.patch(
+ "socket.getaddrinfo",
+ side_effect=socket.gaierror("nodename nor servname provided"),
+ ):
+ with pytest.raises(UnsafeUserUrlError, match="could not resolve"):
+ validate_user_base_url("https://nonexistent.invalid/v1")
+
+
+@pytest.mark.unit
+def test_rejects_hostname_resolving_to_metadata_ip_via_dns():
+ # Even if a hostname looks innocent, if DNS hands us 169.254.169.254
+ # we must refuse — defense-in-depth at dispatch time.
+ with mock.patch("socket.getaddrinfo", return_value=_addrinfo("169.254.169.254")):
+ with pytest.raises(UnsafeUserUrlError, match="blocked address"):
+ validate_user_base_url("https://innocent.example.com/v1")
+
+
+@pytest.mark.unit
+def test_rejects_hostname_resolving_to_ipv6_loopback_via_dns():
+ with mock.patch("socket.getaddrinfo", return_value=_addrinfo("::1")):
+ with pytest.raises(UnsafeUserUrlError, match="blocked address"):
+ validate_user_base_url("https://aaaa-only.example.com/v1")
+
+
+# pinned_post — single-resolve, IP-pinned outbound HTTP
+
+
+class _StubResponse:
+ """Drop-in for a ``requests.Response`` that ``Session.send`` returns."""
+
+ def __init__(self, status_code: int = 200) -> None:
+ self.status_code = status_code
+ self.text = ""
+ self.headers = {"Content-Type": "application/json"}
+
+
+def _capture_send(monkeypatch):
+ """Replace ``requests.Session.send`` with a capturing stub.
+
+ Returns a dict that fills with ``prepared`` (the
+ ``PreparedRequest``) and ``send_kwargs`` once a request is issued,
+ plus ``adapters`` so callers can inspect what was mounted on the
+ session.
+ """
+ captured: dict = {}
+
+ def _send(self, prepared, **kwargs):
+ captured["prepared"] = prepared
+ captured["send_kwargs"] = kwargs
+ captured["adapters"] = dict(self.adapters)
+ return _StubResponse()
+
+ monkeypatch.setattr(requests.Session, "send", _send)
+ return captured
+
+
+@pytest.mark.unit
+def test_pinned_post_rewrites_host_to_resolved_ipv4(monkeypatch):
+ captured = _capture_send(monkeypatch)
+ with mock.patch("socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")):
+ pinned_post(
+ "https://api.openai.com/v1/chat/completions",
+ json={"hi": True},
+ headers={"Authorization": "Bearer sk-x"},
+ timeout=5,
+ allow_redirects=False,
+ )
+
+ prepared = captured["prepared"]
+ assert prepared.url == "https://104.18.6.192/v1/chat/completions"
+ # Host header carries the original hostname so vhost-routing and
+ # SNI/cert verification target the right server.
+ assert prepared.headers["Host"] == "api.openai.com"
+ # Caller-supplied headers are preserved.
+ assert prepared.headers["Authorization"] == "Bearer sk-x"
+ # The body was sent as JSON.
+ assert prepared.body == b'{"hi": true}'
+
+
+@pytest.mark.unit
+def test_pinned_post_brackets_ipv6_in_url(monkeypatch):
+ captured = _capture_send(monkeypatch)
+ with mock.patch(
+ "socket.getaddrinfo", return_value=_addrinfo("2606:4700::6810:1234")
+ ):
+ pinned_post(
+ "https://example.com/v1/x",
+ json={},
+ headers={},
+ timeout=5,
+ allow_redirects=False,
+ )
+ assert (
+ captured["prepared"].url == "https://[2606:4700::6810:1234]/v1/x"
+ )
+ assert captured["prepared"].headers["Host"] == "example.com"
+
+
+@pytest.mark.unit
+def test_pinned_post_preserves_explicit_port(monkeypatch):
+ captured = _capture_send(monkeypatch)
+ with mock.patch("socket.getaddrinfo", return_value=_addrinfo("93.184.216.34")):
+ pinned_post(
+ "https://api.example.com:8443/v1/test",
+ json={},
+ headers={},
+ timeout=5,
+ allow_redirects=False,
+ )
+ assert captured["prepared"].url == "https://93.184.216.34:8443/v1/test"
+ # Host header keeps the original :port — proxies and vhost routers
+ # rely on this, and SNI conventionally carries the bare hostname.
+ assert captured["prepared"].headers["Host"] == "api.example.com:8443"
+
+
+@pytest.mark.unit
+def test_pinned_post_handles_ip_literal_url_without_dns(monkeypatch):
+ """If the URL already has an IP literal, no DNS lookup happens."""
+
+ captured = _capture_send(monkeypatch)
+ with mock.patch("socket.getaddrinfo") as gai:
+ pinned_post(
+ "https://93.184.216.34/v1/x",
+ json={},
+ headers={},
+ timeout=5,
+ allow_redirects=False,
+ )
+ assert gai.call_count == 0
+ assert captured["prepared"].url == "https://93.184.216.34/v1/x"
+ assert captured["prepared"].headers["Host"] == "93.184.216.34"
+
+
+@pytest.mark.unit
+def test_pinned_post_resolves_dns_exactly_once(monkeypatch):
+ """The whole point of the helper: one resolution, one connection.
+
+ A DNS-rebinding attacker wins by getting a second ``getaddrinfo``
+ call after the first one was validated. If this assertion ever
+ fails, the SSRF guard has regressed.
+ """
+
+ captured = _capture_send(monkeypatch)
+ with mock.patch(
+ "socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")
+ ) as gai:
+ pinned_post(
+ "https://api.openai.com/v1/chat/completions",
+ json={},
+ headers={},
+ timeout=5,
+ allow_redirects=False,
+ )
+ assert gai.call_count == 1
+ assert captured["prepared"].url.startswith("https://104.18.6.192/")
+
+
+@pytest.mark.unit
+def test_pinned_post_mounts_pinned_adapter_for_https(monkeypatch):
+ """For HTTPS, a custom adapter must be mounted that overrides
+ ``server_hostname`` / ``assert_hostname`` so SNI and cert
+ verification target the original hostname even though we connect
+ to an IP literal.
+ """
+
+ captured = _capture_send(monkeypatch)
+ with mock.patch("socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")):
+ pinned_post(
+ "https://api.openai.com/v1/x",
+ json={},
+ headers={},
+ timeout=5,
+ allow_redirects=False,
+ )
+
+ https_adapter = captured["adapters"]["https://"]
+ # _PinnedHostAdapter is the symbol we expect; also check it carries
+ # the original hostname so SNI/cert verification line up.
+ assert type(https_adapter).__name__ == "_PinnedHostAdapter"
+ assert https_adapter._server_hostname == "api.openai.com"
+
+
+@pytest.mark.unit
+def test_pinned_post_does_not_mount_https_adapter_for_http(monkeypatch):
+ """For HTTP, no SNI/cert logic is needed — the default adapter
+ should remain in place; only the URL rewrite + Host header matter."""
+
+ captured = _capture_send(monkeypatch)
+ with mock.patch("socket.getaddrinfo", return_value=_addrinfo("93.184.216.34")):
+ pinned_post(
+ "http://example.com/v1/x",
+ json={},
+ headers={},
+ timeout=5,
+ allow_redirects=False,
+ )
+ https_adapter = captured["adapters"]["https://"]
+ assert type(https_adapter).__name__ == "HTTPAdapter"
+
+
+@pytest.mark.unit
+def test_pinned_post_raises_for_blocked_dns_result(monkeypatch):
+ """A hostname that resolves to a private IP must be rejected
+ *before* any HTTP request is dispatched."""
+
+ captured = _capture_send(monkeypatch)
+ with mock.patch("socket.getaddrinfo", return_value=_addrinfo("10.0.0.5")):
+ with pytest.raises(UnsafeUserUrlError, match="blocked address"):
+ pinned_post(
+ "https://internal.example.com/v1/x",
+ json={},
+ headers={},
+ timeout=5,
+ allow_redirects=False,
+ )
+ assert "prepared" not in captured
+
+
+@pytest.mark.unit
+def test_pinned_post_raises_for_loopback_ip_literal(monkeypatch):
+ captured = _capture_send(monkeypatch)
+ with pytest.raises(UnsafeUserUrlError, match="blocked address"):
+ pinned_post(
+ "https://127.0.0.1/v1/x",
+ json={},
+ headers={},
+ timeout=5,
+ allow_redirects=False,
+ )
+ assert "prepared" not in captured
+
+
+@pytest.mark.unit
+def test_pinned_post_raises_for_disallowed_scheme(monkeypatch):
+ captured = _capture_send(monkeypatch)
+ with pytest.raises(UnsafeUserUrlError, match="scheme"):
+ pinned_post(
+ "ftp://example.com/v1/x",
+ json={},
+ headers={},
+ timeout=5,
+ allow_redirects=False,
+ )
+ assert "prepared" not in captured
+
+
+@pytest.mark.unit
+def test_pinned_post_overrides_caller_supplied_host_header(monkeypatch):
+ """If the caller passes their own Host header, the helper must
+ still set it to the resolved hostname so the in-flight request
+ doesn't disagree with what was validated."""
+
+ captured = _capture_send(monkeypatch)
+ with mock.patch("socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")):
+ pinned_post(
+ "https://api.openai.com/v1/x",
+ json={},
+ headers={"Host": "evil.example.com"},
+ timeout=5,
+ allow_redirects=False,
+ )
+ assert captured["prepared"].headers["Host"] == "api.openai.com"
+
+
+# pinned_httpx_client — DNS-rebinding-safe httpx transport for SDK use
+
+
+def _capture_httpx_handle_request(monkeypatch):
+ """Patch ``httpx.HTTPTransport.handle_request`` to record the
+ request reaching the parent transport, and return a fake response.
+
+ The pinned transport's ``handle_request`` rewrites
+ ``request.url.host`` and sets ``sni_hostname`` *before* delegating
+ to ``super().handle_request``. Capturing what the parent sees
+ gives us the actual values that would feed httpcore's connect
+ (and thus what TCP would dial / SNI would advertise) without
+ opening a real socket.
+ """
+
+ import httpx
+
+ captured: dict = {}
+
+ def fake_handle(self, request):
+ captured["url"] = request.url
+ captured["sni"] = request.extensions.get("sni_hostname")
+ captured["host_header"] = request.headers.get("host")
+ return httpx.Response(200, content=b"ok")
+
+ monkeypatch.setattr(
+ "httpx.HTTPTransport.handle_request", fake_handle
+ )
+ return captured
+
+
+@pytest.mark.unit
+def test_pinned_httpx_client_returns_pinned_transport():
+ """The factory must wire its transport in unchanged and bind it
+ to the validated host and IP."""
+
+ with mock.patch(
+ "socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")
+ ):
+ client = pinned_httpx_client("https://api.example.com/v1")
+ try:
+ assert isinstance(client._transport, _PinnedHTTPSTransport)
+ assert client._transport._host == "api.example.com"
+ assert client._transport._ip_netloc == "104.18.6.192"
+ finally:
+ client.close()
+
+
+@pytest.mark.unit
+def test_pinned_httpx_client_disables_redirects():
+ """SSRF guard only inspects the supplied URL — following 3xx would
+ let a hostile upstream bounce the in-network request to an
+ internal address."""
+
+ with mock.patch(
+ "socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")
+ ):
+ client = pinned_httpx_client("https://api.example.com/v1")
+ try:
+ assert client.follow_redirects is False
+ finally:
+ client.close()
+
+
+@pytest.mark.unit
+def test_pinned_httpx_transport_rewrites_url_to_validated_ip(monkeypatch):
+ """The core invariant: every request reaching httpcore has its URL
+ host pointed at the IP literal we validated, so TCP dials that IP
+ rather than triggering a fresh DNS resolution."""
+
+ captured = _capture_httpx_handle_request(monkeypatch)
+
+ with mock.patch(
+ "socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")
+ ):
+ client = pinned_httpx_client("https://api.example.com/v1")
+ try:
+ client.get("https://api.example.com/v1/test")
+ finally:
+ client.close()
+
+ assert captured["url"].host == "104.18.6.192"
+
+
+@pytest.mark.unit
+def test_pinned_httpx_transport_sets_sni_for_original_hostname(monkeypatch):
+ """TLS SNI / cert verification must use the original hostname; the
+ transport sets it via the ``sni_hostname`` extension that
+ httpcore forwards to ``start_tls``'s ``server_hostname``."""
+
+ captured = _capture_httpx_handle_request(monkeypatch)
+
+ with mock.patch(
+ "socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")
+ ):
+ client = pinned_httpx_client("https://api.example.com/v1")
+ try:
+ client.get("https://api.example.com/v1/test")
+ finally:
+ client.close()
+
+ assert captured["sni"] == b"api.example.com"
+
+
+@pytest.mark.unit
+def test_pinned_httpx_transport_preserves_host_header(monkeypatch):
+ """``Host`` is auto-set by ``httpx.Request._prepare`` from the URL
+ netloc *before* our transport rewrites the URL host. The header
+ must still carry the original hostname."""
+
+ captured = _capture_httpx_handle_request(monkeypatch)
+
+ with mock.patch(
+ "socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")
+ ):
+ client = pinned_httpx_client("https://api.example.com/v1")
+ try:
+ client.get("https://api.example.com/v1/test")
+ finally:
+ client.close()
+
+ assert captured["host_header"] == "api.example.com"
+
+
+@pytest.mark.unit
+def test_pinned_httpx_client_closes_dns_rebinding_window(monkeypatch):
+ """The TOCTOU lock-in test: validate against a public IP, then
+ have DNS rebind to a private (loopback) IP, then send a request.
+ The transport must dial the *first* validated IP — not the
+ rebound one — guaranteeing no second DNS lookup interferes."""
+
+ captured = _capture_httpx_handle_request(monkeypatch)
+
+ # First lookup (at validation time) returns a public IP.
+ public = _addrinfo("104.18.6.192")
+ # Subsequent lookups (which the transport must NEVER trigger)
+ # would return loopback if a hostile resolver flipped them.
+ private = _addrinfo("127.0.0.1")
+
+ getaddrinfo_calls: list = []
+
+ def fake_getaddrinfo(*args, **kwargs):
+ getaddrinfo_calls.append((args, kwargs))
+ # First call = validation; everything after = post-rebind.
+ return public if len(getaddrinfo_calls) == 1 else private
+
+ monkeypatch.setattr("socket.getaddrinfo", fake_getaddrinfo)
+
+ client = pinned_httpx_client("https://api.attacker.example/v1")
+ try:
+ client.get("https://api.attacker.example/v1/test")
+ finally:
+ client.close()
+
+ # Whatever happens below the transport, the URL handed to
+ # httpcore must be the IP from the *first* getaddrinfo call.
+ assert captured["url"].host == "104.18.6.192", (
+ "pinned transport must dial the IP validated at construction, "
+ "not whatever DNS returns at request time"
+ )
+
+
+@pytest.mark.unit
+def test_pinned_httpx_client_rejects_blocked_dns_result():
+ """If the validation lookup itself returns a private IP, the
+ factory must refuse to construct a client at all."""
+
+ with mock.patch(
+ "socket.getaddrinfo", return_value=_addrinfo("169.254.169.254")
+ ):
+ with pytest.raises(UnsafeUserUrlError, match="link-local"):
+ pinned_httpx_client("https://api.attacker.example/v1")
+
+
+@pytest.mark.unit
+def test_pinned_httpx_client_rejects_loopback_literal():
+ """An IP literal in the supplied URL goes through the same guard
+ even when DNS isn't called."""
+
+ with pytest.raises(UnsafeUserUrlError):
+ pinned_httpx_client("http://127.0.0.1:8080/v1")
+
+
+@pytest.mark.unit
+def test_pinned_httpx_transport_refuses_unexpected_host(monkeypatch):
+ """Defense in depth: if the SDK ever rewrites the request URL to a
+ different host between Request construction and dial, the
+ transport refuses rather than silently dialing the validated IP
+ with a different host's credentials."""
+
+ import httpx
+
+ with mock.patch(
+ "socket.getaddrinfo", return_value=_addrinfo("104.18.6.192")
+ ):
+ client = pinned_httpx_client("https://api.example.com/v1")
+ try:
+ with pytest.raises(UnsafeUserUrlError, match="refused request"):
+ client.get("https://other.example.com/v1/test")
+ except httpx.RequestError:
+ # Some httpx versions may wrap the transport error; accept
+ # either path so long as the request didn't succeed.
+ pass
+ finally:
+ client.close()
diff --git a/tests/storage/db/repositories/test_user_custom_models.py b/tests/storage/db/repositories/test_user_custom_models.py
new file mode 100644
index 00000000..c8a5847b
--- /dev/null
+++ b/tests/storage/db/repositories/test_user_custom_models.py
@@ -0,0 +1,172 @@
+"""Tests for UserCustomModelsRepository against a real Postgres instance."""
+
+from __future__ import annotations
+
+from application.storage.db.repositories.user_custom_models import (
+ UserCustomModelsRepository,
+)
+
+
+def _repo(conn) -> UserCustomModelsRepository:
+ return UserCustomModelsRepository(conn)
+
+
+def _make(repo, user="user-1", upstream="mistral-large-latest", **kwargs):
+ return repo.create(
+ user_id=user,
+ upstream_model_id=upstream,
+ display_name=kwargs.pop("display_name", "My Mistral"),
+ base_url=kwargs.pop("base_url", "https://api.mistral.ai/v1"),
+ api_key_plaintext=kwargs.pop("api_key_plaintext", "sk-mistral-test"),
+ **kwargs,
+ )
+
+
+class TestCreate:
+ def test_creates_minimal(self, pg_conn):
+ repo = _repo(pg_conn)
+ row = _make(repo)
+ assert row["user_id"] == "user-1"
+ assert row["upstream_model_id"] == "mistral-large-latest"
+ assert row["display_name"] == "My Mistral"
+ assert row["base_url"] == "https://api.mistral.ai/v1"
+ assert row["enabled"] is True
+ assert row["id"] is not None
+ # Plaintext key never lands in the row
+ assert row["api_key_encrypted"] != "sk-mistral-test"
+ assert "sk-mistral-test" not in row["api_key_encrypted"]
+
+ def test_capabilities_normalized_drops_unknown_keys(self, pg_conn):
+ repo = _repo(pg_conn)
+ row = _make(
+ repo,
+ capabilities={
+ "supports_tools": True,
+ "context_window": 200_000,
+ "garbage_key": "should be dropped",
+ },
+ )
+ assert row["capabilities"] == {
+ "supports_tools": True,
+ "context_window": 200_000,
+ }
+
+
+class TestGet:
+ def test_get_by_id_returns_row(self, pg_conn):
+ repo = _repo(pg_conn)
+ created = _make(repo)
+ fetched = repo.get(created["id"], "user-1")
+ assert fetched is not None
+ assert fetched["id"] == created["id"]
+
+ def test_get_other_user_returns_none(self, pg_conn):
+ repo = _repo(pg_conn)
+ created = _make(repo, user="alice")
+ # Bob cannot fetch Alice's row even with the right id
+ assert repo.get(created["id"], "bob") is None
+
+ def test_get_missing_id_returns_none(self, pg_conn):
+ repo = _repo(pg_conn)
+ _make(repo)
+ # A different (but valid) UUID
+ assert repo.get("00000000-0000-0000-0000-000000000000", "user-1") is None
+
+
+class TestListForUser:
+ def test_lists_only_users_own(self, pg_conn):
+ repo = _repo(pg_conn)
+ _make(repo, user="alice", upstream="alice-1")
+ _make(repo, user="alice", upstream="alice-2")
+ _make(repo, user="bob", upstream="bob-1")
+ alice = repo.list_for_user("alice")
+ assert {r["upstream_model_id"] for r in alice} == {"alice-1", "alice-2"}
+ bob = repo.list_for_user("bob")
+ assert {r["upstream_model_id"] for r in bob} == {"bob-1"}
+
+
+class TestUpdate:
+ def test_update_partial(self, pg_conn):
+ repo = _repo(pg_conn)
+ created = _make(repo)
+ ok = repo.update(
+ created["id"],
+ "user-1",
+ {"display_name": "Renamed", "enabled": False},
+ )
+ assert ok is True
+ fetched = repo.get(created["id"], "user-1")
+ assert fetched["display_name"] == "Renamed"
+ assert fetched["enabled"] is False
+
+ def test_update_capabilities_normalizes(self, pg_conn):
+ repo = _repo(pg_conn)
+ created = _make(repo)
+ repo.update(
+ created["id"],
+ "user-1",
+ {"capabilities": {"supports_tools": False, "garbage": 1}},
+ )
+ fetched = repo.get(created["id"], "user-1")
+ assert fetched["capabilities"] == {"supports_tools": False}
+
+ def test_update_api_key_re_encrypts(self, pg_conn):
+ repo = _repo(pg_conn)
+ created = _make(repo)
+ before = created["api_key_encrypted"]
+ repo.update(
+ created["id"],
+ "user-1",
+ {"api_key_plaintext": "sk-mistral-new"},
+ )
+ fetched = repo.get(created["id"], "user-1")
+ # Ciphertext changed
+ assert fetched["api_key_encrypted"] != before
+ # Plaintext absent
+ assert "sk-mistral-new" not in fetched["api_key_encrypted"]
+ # And decrypts back to the new value
+ plaintext = repo.get_decrypted_api_key(created["id"], "user-1")
+ assert plaintext == "sk-mistral-new"
+
+ def test_update_other_user_returns_false(self, pg_conn):
+ repo = _repo(pg_conn)
+ created = _make(repo, user="alice")
+ # Bob can't update Alice's row
+ ok = repo.update(created["id"], "bob", {"display_name": "Hacked"})
+ assert ok is False
+ # And Alice's row is untouched
+ fetched = repo.get(created["id"], "alice")
+ assert fetched["display_name"] == "My Mistral"
+
+
+class TestDelete:
+ def test_delete_removes_row(self, pg_conn):
+ repo = _repo(pg_conn)
+ created = _make(repo)
+ assert repo.delete(created["id"], "user-1") is True
+ assert repo.get(created["id"], "user-1") is None
+
+ def test_delete_other_user_returns_false(self, pg_conn):
+ repo = _repo(pg_conn)
+ created = _make(repo, user="alice")
+ assert repo.delete(created["id"], "bob") is False
+ # Alice's row still there
+ assert repo.get(created["id"], "alice") is not None
+
+
+class TestEncryptionRoundtrip:
+ def test_decrypted_matches_original(self, pg_conn):
+ repo = _repo(pg_conn)
+ created = _make(repo, api_key_plaintext="my-very-secret-key-12345")
+ plaintext = repo.get_decrypted_api_key(created["id"], "user-1")
+ assert plaintext == "my-very-secret-key-12345"
+
+ def test_decryption_with_wrong_user_fails_silently(self, pg_conn):
+ repo = _repo(pg_conn)
+ # Per-user PBKDF2 salt: Alice's record can't be decrypted with
+ # Bob's user_id even if Bob somehow has the row.
+ created = _make(repo, user="alice", api_key_plaintext="alice-secret")
+ # Manually call decrypt with the wrong user_id (simulates the
+ # registry layer being given the wrong user context).
+ wrong = repo._decrypt_api_key(created["api_key_encrypted"], "bob")
+ assert wrong != "alice-secret" # either None or garbage; not the secret
diff --git a/tests/test_app_routes.py b/tests/test_app_routes.py
index 656b550b..cbbf52f4 100644
--- a/tests/test_app_routes.py
+++ b/tests/test_app_routes.py
@@ -112,7 +112,7 @@ class TestFlaskCors:
response = client.get("/api/health", headers={"Origin": "http://localhost:5173"})
assert response.headers["Access-Control-Allow-Origin"] == "*"
assert response.headers["Access-Control-Allow-Headers"] == "Content-Type, Authorization"
- assert response.headers["Access-Control-Allow-Methods"] == "GET, POST, PUT, DELETE, OPTIONS"
+ assert response.headers["Access-Control-Allow-Methods"] == "GET, POST, PUT, PATCH, DELETE, OPTIONS"
@pytest.mark.unit
def test_cors_headers_on_flask_preflight(self, client):
@@ -127,4 +127,4 @@ class TestFlaskCors:
assert response.status_code == 200
assert response.headers["Access-Control-Allow-Origin"] == "*"
assert response.headers["Access-Control-Allow-Headers"] == "Content-Type, Authorization"
- assert response.headers["Access-Control-Allow-Methods"] == "GET, POST, PUT, DELETE, OPTIONS"
+ assert response.headers["Access-Control-Allow-Methods"] == "GET, POST, PUT, PATCH, DELETE, OPTIONS"
diff --git a/tests/test_asgi.py b/tests/test_asgi.py
index 57fd4fc9..556a47a6 100644
--- a/tests/test_asgi.py
+++ b/tests/test_asgi.py
@@ -113,6 +113,28 @@ def test_cors_preflight_on_flask_route():
assert "GET" in r.headers.get("access-control-allow-methods", "")
+@pytest.mark.unit
+def test_cors_preflight_allows_patch():
+ """PATCH must be in Access-Control-Allow-Methods. The frontend's
+ apiClient.patch() (used to edit BYOM custom models via PATCH
+ /api/user/models/
) is otherwise blocked at preflight by browsers."""
+ from starlette.testclient import TestClient
+
+ from application.asgi import asgi_app
+
+ with TestClient(asgi_app) as client:
+ r = client.options(
+ "/api/health",
+ headers={
+ "Origin": "http://example.com",
+ "Access-Control-Request-Method": "PATCH",
+ "Access-Control-Request-Headers": "Content-Type, Authorization",
+ },
+ )
+ assert r.status_code in (200, 204)
+ assert "PATCH" in r.headers.get("access-control-allow-methods", "")
+
+
@pytest.mark.unit
def test_cors_preflight_on_mcp_route():
"""Browser clients hitting /mcp should be allowed to send session headers."""
diff --git a/tests/test_retriever.py b/tests/test_retriever.py
index d80a8ee0..ee05298c 100644
--- a/tests/test_retriever.py
+++ b/tests/test_retriever.py
@@ -212,6 +212,44 @@ class TestClassicRAGRephraseQuery:
assert rag.question == "original"
+@pytest.mark.unit
+class TestClassicRAGLLMCreatorWiring:
+ """ClassicRAG must forward model_id + model_user_id to LLMCreator so
+ the registry-resolution path runs (BYOM api_key/base_url overrides
+ and upstream_model_id translation). Without these the rephrase
+ client dispatches the registry UUID to the plugin's default endpoint
+ with the instance API key."""
+
+ def test_passes_model_id_and_user_id_to_llmcreator(self, mock_llm, monkeypatch):
+ captured = Mock(return_value=mock_llm)
+ monkeypatch.setattr(
+ "application.retriever.classic_rag.LLMCreator.create_llm", captured
+ )
+
+ _make_rag(
+ model_id="byom-uuid",
+ model_user_id="owner",
+ decoded_token={"sub": "caller"},
+ )
+
+ assert captured.call_count == 1
+ kwargs = captured.call_args.kwargs
+ assert kwargs["model_id"] == "byom-uuid"
+ assert kwargs["model_user_id"] == "owner"
+ # Caller identity still flows so non-BYOM paths keep working.
+ assert kwargs["decoded_token"] == {"sub": "caller"}
+
+ def test_default_model_user_id_is_none(self, mock_llm, monkeypatch):
+ captured = Mock(return_value=mock_llm)
+ monkeypatch.setattr(
+ "application.retriever.classic_rag.LLMCreator.create_llm", captured
+ )
+
+ _make_rag() # no model_user_id override
+
+ assert captured.call_args.kwargs["model_user_id"] is None
+
+
@pytest.mark.unit
class TestClassicRAGGetData:
def test_chunks_zero_returns_empty(self, _patch_llm_creator):
diff --git a/tests/worker/test_agent_workers.py b/tests/worker/test_agent_workers.py
index 082b20c6..6d6226de 100644
--- a/tests/worker/test_agent_workers.py
+++ b/tests/worker/test_agent_workers.py
@@ -99,11 +99,11 @@ class TestRunAgentLogic:
"application.core.model_utils.get_default_model_id", lambda: "gpt-4"
)
monkeypatch.setattr(
- "application.core.model_utils.validate_model_id", lambda m: True
+ "application.core.model_utils.validate_model_id", lambda m, **_kwargs: True
)
monkeypatch.setattr(
"application.core.model_utils.get_provider_from_model_id",
- lambda m: "openai",
+ lambda m, **_kwargs: "openai",
)
monkeypatch.setattr(
"application.core.model_utils.get_api_key_for_provider",
@@ -111,7 +111,7 @@ class TestRunAgentLogic:
)
monkeypatch.setattr(
"application.utils.calculate_doc_token_budget",
- lambda model_id=None: 1000,
+ lambda model_id=None, **_kwargs: 1000,
)
monkeypatch.setattr(
"application.api.answer.services.stream_processor.get_prompt",