diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index d95f954b6..1b21eca95 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -340,7 +340,12 @@ "sessionAge": "Session Age", "reusedProvider": "Reused Provider", "executeRequest": "Execute Request", - "cacheOptimizationHint": "Session reuse optimizes performance by maintaining provider affinity within the same conversation, reducing selection overhead and improving cache hit rates." + "cacheOptimizationHint": "Session reuse optimizes performance by maintaining provider affinity within the same conversation, reducing selection overhead and improving cache hit rates.", + "originDecisionTitle": "Original Selection Decision", + "originDecisionDesc": "How this provider was initially chosen for this session", + "originDecisionLoading": "Loading original decision...", + "originDecisionUnavailable": "Original decision record unavailable", + "originDecisionExpand": "View original selection" } }, "providerChain": { @@ -1870,14 +1875,14 @@ "descriptionEnabled": "When enabled, this key will access an independent personal usage page upon login. However, it cannot modify its own key's provider group.", "descriptionDisabled": "When disabled, the user cannot access the personal usage page UI. Instead, they will use the restricted Web UI." }, - "providerGroup": { - "label": "Provider Group", - "placeholder": "Default: default", - "selectHint": "Select the provider group(s) this key can use (default: default).", - "editHint": "Provider group cannot be changed for existing keys.", - "allGroups": "Use all groups", - "noGroupHint": "default includes providers without groupTag." - }, + "providerGroup": { + "label": "Provider Group", + "placeholder": "Default: default", + "selectHint": "Select the provider group(s) this key can use (default: default).", + "editHint": "Provider group cannot be changed for existing keys.", + "allGroups": "Use all groups", + "noGroupHint": "default includes providers without groupTag." + }, "cacheTtl": { "label": "Cache TTL Override", "description": "Force Anthropic prompt cache TTL for requests containing cache_control.", diff --git a/messages/en/provider-chain.json b/messages/en/provider-chain.json index bf9cb81a7..ba9d4c47f 100644 --- a/messages/en/provider-chain.json +++ b/messages/en/provider-chain.json @@ -19,7 +19,8 @@ }, "summary": { "singleSuccess": "{total} providers, {healthy} healthy → {provider} ✓", - "sessionReuse": "Session reuse → {provider} ✓" + "sessionReuse": "Session reuse → {provider} ✓", + "originHint": "Session reuse - originally selected via {method}" }, "description": { "noDecisionRecord": "No decision record", @@ -213,5 +214,11 @@ "strictBlockSelectorError": "Strict mode: endpoint selector encountered an error, provider skipped without fallback", "vendorTypeAllTimeout": "Vendor-Type All Endpoints Timeout (524)", "vendorTypeAllTimeoutNote": "All endpoints for this vendor-type timed out. Vendor-type circuit breaker triggered." + }, + "selectionMethods": { + "session_reuse": "Session Reuse", + "weighted_random": "Weighted Random", + "group_filtered": "Group Filtered", + "fail_open_fallback": "Fail-Open Fallback" } } diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index ff007d562..795ecc363 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -340,7 +340,12 @@ "sessionAge": "セッション経過時間", "reusedProvider": "再利用プロバイダー", "executeRequest": "リクエスト実行", - "cacheOptimizationHint": "セッション再利用は、同じ会話内でプロバイダーの親和性を維持することでパフォーマンスを最適化し、選択オーバーヘッドを削減してキャッシュヒット率を向上させます。" + "cacheOptimizationHint": "セッション再利用は、同じ会話内でプロバイダーの親和性を維持することでパフォーマンスを最適化し、選択オーバーヘッドを削減してキャッシュヒット率を向上させます。", + "originDecisionTitle": "元の選択決定", + "originDecisionDesc": "このセッションでプロバイダーが最初に選択された理由", + "originDecisionLoading": "元の決定を読み込み中...", + "originDecisionUnavailable": "元の決定記録は利用できません", + "originDecisionExpand": "元の選択を表示" } }, "providerChain": { @@ -1051,45 +1056,56 @@ "load": "負荷" }, "timeRange": { + "label": "時間範囲", "15min": "15分", "1h": "1時間", "6h": "6時間", "24h": "24時間", - "7d": "7日" + "7d": "7日間", + "last15min": "過去15分", + "last1h": "過去1時間", + "last6h": "過去6時間", + "last24h": "過去24時間", + "last7d": "過去7日間", + "custom": "カスタム" }, "laneChart": { "title": "プロバイダー可用性タイムライン", - "noData": "データがありません", + "noData": "データなし", "requests": "{count} リクエスト", - "availability": "{value}% 可用", - "noRequests": "リクエストなし" + "availability": "可用性 {value}%", + "noRequests": "リクエストなし", + "denseData": "高密度", + "sparseData": "低密度", + "latency": "レイテンシ" }, "latencyChart": { - "title": "遅延分布", + "title": "レイテンシ分布", "p50": "P50", "p95": "P95", "p99": "P99", - "noData": "遅延データがありません" + "noData": "レイテンシデータなし" }, "latencyCurve": { - "title": "遅延トレンド", - "noData": "遅延データがありません", + "title": "レイテンシトレンド", + "noData": "レイテンシデータなし", "avg": "平均", "min": "最小", "max": "最大", - "latency": "遅延" + "latency": "レイテンシ" }, "terminal": { "title": "プローブログ", - "live": "LIVE", + "live": "ライブ", "download": "ログをダウンロード", - "noLogs": "プローブログがありません", + "noLogs": "プローブログなし", "manual": "手動", "auto": "自動", "filterPlaceholder": "ログをフィルター..." }, "probeGrid": { - "noEndpoints": "エンドポイントが設定されていません", + "title": "エンドポイントステータス", + "noEndpoints": "エンドポイント未設定", "lastProbe": "最終プローブ", "status": { "unknown": "不明", @@ -1105,13 +1121,21 @@ "low": "低", "medium": "中", "high": "高", - "lowTooltip": "{count} 件未満のリクエスト。データが代表的でない可能性があります。", - "mediumTooltip": "中程度のリクエスト量。データは比較的信頼できます。", - "highTooltip": "高いリクエスト量。データは信頼できます。" + "lowTooltip": "リクエスト数が {count} 未満です。データが代表的でない可能性があります。", + "mediumTooltip": "リクエスト量は適度です。データは比較的信頼できます。", + "highTooltip": "リクエスト量が十分です。データは信頼できます。" }, "actions": { + "refresh": "更新", + "refreshing": "更新中...", + "autoRefresh": "自動更新", + "stopAutoRefresh": "自動更新を停止", + "viewDetails": "詳細を表示", + "testProvider": "プロバイダーをテスト", + "retry": "再試行", "probeNow": "今すぐプローブ", "probing": "プローブ中...", + "probeAll": "すべてプローブ", "probeSuccess": "プローブ成功", "probeFailed": "プローブ失敗" }, @@ -1136,20 +1160,6 @@ "lastRequest": "最終リクエスト", "requestCount": "リクエスト数" }, - "timeRange": { - "label": "時間範囲", - "15min": "15分", - "1h": "1時間", - "6h": "6時間", - "24h": "24時間", - "7d": "7日間", - "last15min": "過去15分", - "last1h": "過去1時間", - "last6h": "過去6時間", - "last24h": "過去24時間", - "last7d": "過去7日間", - "custom": "カスタム" - }, "filters": { "provider": "プロバイダー", "allProviders": "すべてのプロバイダー", @@ -1187,20 +1197,6 @@ "greenCount": "成功リクエスト", "redCount": "失敗リクエスト" }, - "actions": { - "refresh": "更新", - "refreshing": "更新中...", - "autoRefresh": "自動更新", - "stopAutoRefresh": "自動更新を停止", - "viewDetails": "詳細を表示", - "testProvider": "プロバイダーをテスト", - "retry": "再試行", - "probeNow": "今すぐプローブ", - "probing": "プローブ中...", - "probeAll": "すべてプローブ", - "probeSuccess": "プローブ成功", - "probeFailed": "プローブ失敗" - }, "states": { "loading": "読み込み中...", "error": "読み込み失敗", @@ -1251,62 +1247,6 @@ "probeSuccess": "プローブ成功", "probeFailed": "プローブ失敗" }, - "laneChart": { - "title": "プロバイダー可用性タイムライン", - "noData": "データなし", - "requests": "{count} リクエスト", - "availability": "可用性 {value}%", - "noRequests": "リクエストなし", - "denseData": "高密度", - "sparseData": "低密度", - "latency": "レイテンシ" - }, - "latencyChart": { - "title": "レイテンシ分布", - "p50": "P50", - "p95": "P95", - "p99": "P99", - "noData": "レイテンシデータなし" - }, - "latencyCurve": { - "title": "レイテンシトレンド", - "noData": "レイテンシデータなし", - "avg": "平均", - "min": "最小", - "max": "最大", - "latency": "レイテンシ" - }, - "terminal": { - "title": "プローブログ", - "live": "ライブ", - "download": "ログをダウンロード", - "noLogs": "プローブログなし", - "manual": "手動", - "auto": "自動", - "filterPlaceholder": "ログをフィルター..." - }, - "probeGrid": { - "title": "エンドポイントステータス", - "noEndpoints": "エンドポイント未設定", - "lastProbe": "最終プローブ", - "status": { - "unknown": "不明", - "healthy": "正常", - "unhealthy": "異常" - } - }, - "endpoint": { - "selectVendor": "ベンダーを選択", - "selectType": "タイプを選択" - }, - "confidence": { - "low": "低", - "medium": "中", - "high": "高", - "lowTooltip": "リクエスト数が {count} 未満です。データが代表的でない可能性があります。", - "mediumTooltip": "リクエスト量は適度です。データは比較的信頼できます。", - "highTooltip": "リクエスト量が十分です。データは信頼できます。" - }, "toast": { "refreshSuccess": "可用性データを更新しました", "refreshFailed": "更新に失敗しました。再試行してください" diff --git a/messages/ja/provider-chain.json b/messages/ja/provider-chain.json index d8e55285b..e8793c5fc 100644 --- a/messages/ja/provider-chain.json +++ b/messages/ja/provider-chain.json @@ -19,7 +19,8 @@ }, "summary": { "singleSuccess": "{total}個のプロバイダー、{healthy}個正常 → {provider} ✓", - "sessionReuse": "セッション再利用 → {provider} ✓" + "sessionReuse": "セッション再利用 → {provider} ✓", + "originHint": "セッション再利用 - 元は {method} で選択" }, "description": { "noDecisionRecord": "決定記録なし", @@ -213,5 +214,11 @@ "strictBlockSelectorError": "厳格モード:エンドポイントセレクターでエラーが発生したため、フォールバックなしでプロバイダーをスキップ", "vendorTypeAllTimeout": "ベンダータイプ全エンドポイントタイムアウト(524)", "vendorTypeAllTimeoutNote": "このベンダータイプの全エンドポイントがタイムアウトしました。ベンダータイプサーキットブレーカーが発動しました。" + }, + "selectionMethods": { + "session_reuse": "セッション再利用", + "weighted_random": "重み付きランダム", + "group_filtered": "グループフィルタ", + "fail_open_fallback": "フェイルオープンフォールバック" } } diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 3c2acb3a6..7634ae3fa 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -340,7 +340,12 @@ "sessionAge": "Возраст сессии", "reusedProvider": "Повторно используемый провайдер", "executeRequest": "Выполнить запрос", - "cacheOptimizationHint": "Повторное использование сессии оптимизирует производительность, поддерживая привязку к провайдеру в рамках одного разговора, снижая накладные расходы на выбор и повышая частоту попаданий в кэш." + "cacheOptimizationHint": "Повторное использование сессии оптимизирует производительность, поддерживая привязку к провайдеру в рамках одного разговора, снижая накладные расходы на выбор и повышая частоту попаданий в кэш.", + "originDecisionTitle": "Исходное решение выбора", + "originDecisionDesc": "Как провайдер был изначально выбран для этой сессии", + "originDecisionLoading": "Загрузка исходного решения...", + "originDecisionUnavailable": "Запись исходного решения недоступна", + "originDecisionExpand": "Просмотр исходного выбора" } }, "providerChain": { @@ -1123,12 +1128,12 @@ "highTooltip": "Высокий объём запросов. Данные надёжны." }, "actions": { - "retry": "Повторить", - "probeNow": "Проверить сейчас", - "probing": "Проверка...", - "probeAll": "Проверить все", - "probeSuccess": "Проверка успешна", - "probeFailed": "Проверка не удалась" + "refresh": "Обновить", + "refreshing": "Обновление...", + "autoRefresh": "Автообновление", + "stopAutoRefresh": "Остановить автообновление", + "viewDetails": "Подробнее", + "testProvider": "Тестировать провайдера" }, "status": { "green": "Здоров", @@ -1188,14 +1193,6 @@ "greenCount": "Успешные запросы", "redCount": "Неудачные запросы" }, - "actions": { - "refresh": "Обновить", - "refreshing": "Обновление...", - "autoRefresh": "Автообновление", - "stopAutoRefresh": "Остановить автообновление", - "viewDetails": "Подробнее", - "testProvider": "Тестировать провайдера" - }, "states": { "loading": "Загрузка...", "error": "Ошибка загрузки", @@ -1862,14 +1859,14 @@ "descriptionEnabled": "При включении этот ключ будет использовать независимую страницу личного использования при входе. Однако он не может изменять группу провайдеров собственного ключа.", "descriptionDisabled": "При отключении пользователь не сможет получить доступ к странице личного использования. Вместо этого будет использоваться ограниченный Web UI." }, - "providerGroup": { - "label": "Группа провайдеров", - "placeholder": "По умолчанию: default", - "selectHint": "Выберите группы провайдеров, доступные для этого ключа", - "editHint": "Группа провайдеров существующего ключа не может быть изменена", - "allGroups": "Использовать все группы", - "noGroupHint": "default включает провайдеров без groupTag." - }, + "providerGroup": { + "label": "Группа провайдеров", + "placeholder": "По умолчанию: default", + "selectHint": "Выберите группы провайдеров, доступные для этого ключа", + "editHint": "Группа провайдеров существующего ключа не может быть изменена", + "allGroups": "Использовать все группы", + "noGroupHint": "default включает провайдеров без groupTag." + }, "cacheTtl": { "label": "Переопределение Cache TTL", "description": "Принудительно установить Anthropic prompt cache TTL для запросов с cache_control.", diff --git a/messages/ru/provider-chain.json b/messages/ru/provider-chain.json index 10019665b..0e86f022f 100644 --- a/messages/ru/provider-chain.json +++ b/messages/ru/provider-chain.json @@ -19,7 +19,8 @@ }, "summary": { "singleSuccess": "{total} провайдеров, {healthy} работоспособных → {provider} ✓", - "sessionReuse": "Повторное использование сессии → {provider} ✓" + "sessionReuse": "Повторное использование сессии → {provider} ✓", + "originHint": "Повторное использование сессии - изначально выбрано через {method}" }, "description": { "noDecisionRecord": "Нет записей решений", @@ -213,5 +214,11 @@ "strictBlockSelectorError": "Строгий режим: ошибка селектора конечных точек, провайдер пропущен без отката", "vendorTypeAllTimeout": "Тайм-аут всех конечных точек типа поставщика (524)", "vendorTypeAllTimeoutNote": "Все конечные точки этого типа поставщика превысили тайм-аут. Активирован размыкатель типа поставщика." + }, + "selectionMethods": { + "session_reuse": "Повторное использование сессии", + "weighted_random": "Взвешенный случайный", + "group_filtered": "Фильтрация по группе", + "fail_open_fallback": "Резервный вариант при сбое" } } diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 80f74db54..a641a3aca 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -340,7 +340,12 @@ "sessionAge": "会话年龄", "reusedProvider": "复用的供应商", "executeRequest": "执行请求", - "cacheOptimizationHint": "会话复用通过在同一对话中保持供应商亲和性来优化性能,减少选择开销并提高缓存命中率。" + "cacheOptimizationHint": "会话复用通过在同一对话中保持供应商亲和性来优化性能,减少选择开销并提高缓存命中率。", + "originDecisionTitle": "原始选择决策", + "originDecisionDesc": "此会话中供应商最初被选择的原因", + "originDecisionLoading": "正在加载原始决策...", + "originDecisionUnavailable": "原始决策记录不可用", + "originDecisionExpand": "查看原始选择" } }, "providerChain": { @@ -388,13 +393,13 @@ "users": "用户排行", "keys": "密钥排行", "userRanking": "用户排行", - "providerRanking": "供应商排行", - "providerCacheHitRateRanking": "供应商缓存命中率排行", - "modelRanking": "模型排行", - "dailyRanking": "今日", - "weeklyRanking": "本周", - "monthlyRanking": "本月", - "allTimeRanking": "全部" + "providerRanking": "供应商排行", + "providerCacheHitRateRanking": "供应商缓存命中率排行", + "modelRanking": "模型排行", + "dailyRanking": "今日", + "weeklyRanking": "本周", + "monthlyRanking": "本月", + "allTimeRanking": "全部" }, "dateRange": { "to": "至", @@ -413,22 +418,22 @@ "requests": "请求数", "tokens": "Token 数", "consumedAmount": "消耗金额", - "provider": "供应商", - "model": "模型", - "cost": "成本", - "cacheHitRequests": "缓存触发请求数", - "cacheHitRate": "缓存命中率", - "cacheReadTokens": "缓存读取 Token 数", - "totalTokens": "总 Token 数", - "cacheCreationConsumedAmount": "缓存创建消耗金额", - "totalConsumedAmount": "总消耗金额", - "successRate": "成功率", - "avgResponseTime": "平均响应时间", - "avgTtfbMs": "平均 TTFB", - "avgTokensPerSecond": "平均输出速率", - "avgCostPerRequest": "平均单次请求成本", - "avgCostPerMillionTokens": "平均百万 Token 成本" - }, + "provider": "供应商", + "model": "模型", + "cost": "成本", + "cacheHitRequests": "缓存触发请求数", + "cacheHitRate": "缓存命中率", + "cacheReadTokens": "缓存读取 Token 数", + "totalTokens": "总 Token 数", + "cacheCreationConsumedAmount": "缓存创建消耗金额", + "totalConsumedAmount": "总消耗金额", + "successRate": "成功率", + "avgResponseTime": "平均响应时间", + "avgTtfbMs": "平均 TTFB", + "avgTokensPerSecond": "平均输出速率", + "avgCostPerRequest": "平均单次请求成本", + "avgCostPerMillionTokens": "平均百万 Token 成本" + }, "expandModelStats": "展开模型详情", "collapseModelStats": "收起模型详情", "states": { @@ -1829,14 +1834,14 @@ "descriptionEnabled": "启用后,此密钥在登录时将进入独立的个人用量页面。但不可修改自己密钥的供应商分组。", "descriptionDisabled": "关闭后,用户将无法进入个人独立用量页面 UI,而是进入受限的 Web UI。" }, - "providerGroup": { - "label": "供应商分组", - "placeholder": "默认:default", - "selectHint": "选择此 Key 可使用的供应商分组", - "editHint": "已有密钥的分组不可修改", - "allGroups": "使用全部分组", - "noGroupHint": "default 分组包含所有未设置 groupTag 的供应商" - }, + "providerGroup": { + "label": "供应商分组", + "placeholder": "默认:default", + "selectHint": "选择此 Key 可使用的供应商分组", + "editHint": "已有密钥的分组不可修改", + "allGroups": "使用全部分组", + "noGroupHint": "default 分组包含所有未设置 groupTag 的供应商" + }, "cacheTtl": { "label": "Cache TTL 覆写", "description": "强制为包含 cache_control 的请求设置 Anthropic prompt cache TTL。", diff --git a/messages/zh-CN/provider-chain.json b/messages/zh-CN/provider-chain.json index dfe3daad7..c1b287503 100644 --- a/messages/zh-CN/provider-chain.json +++ b/messages/zh-CN/provider-chain.json @@ -19,7 +19,8 @@ }, "summary": { "singleSuccess": "{total} 个供应商,{healthy} 个健康 → {provider} ✓", - "sessionReuse": "会话复用 → {provider} ✓" + "sessionReuse": "会话复用 → {provider} ✓", + "originHint": "会话复用 - 最初通过 {method} 选择" }, "description": { "noDecisionRecord": "无决策记录", @@ -213,5 +214,11 @@ "strictBlockSelectorError": "严格模式:端点选择器发生错误,跳过该供应商且不降级", "vendorTypeAllTimeout": "供应商类型全端点超时(524)", "vendorTypeAllTimeoutNote": "该供应商类型的所有端点均超时,已触发供应商类型临时熔断。" + }, + "selectionMethods": { + "session_reuse": "会话复用", + "weighted_random": "加权随机", + "group_filtered": "分组过滤", + "fail_open_fallback": "故障开放回退" } } diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 0d9008422..5bfccf874 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -340,7 +340,12 @@ "sessionAge": "會話年齡", "reusedProvider": "複用的供應商", "executeRequest": "執行請求", - "cacheOptimizationHint": "會話複用通過在同一對話中保持供應商親和性來優化效能,減少選擇開銷並提高快取命中率。" + "cacheOptimizationHint": "會話複用通過在同一對話中保持供應商親和性來優化效能,減少選擇開銷並提高快取命中率。", + "originDecisionTitle": "原始選擇決策", + "originDecisionDesc": "此會話中供應商最初被選擇的原因", + "originDecisionLoading": "正在載入原始決策...", + "originDecisionUnavailable": "原始決策記錄不可用", + "originDecisionExpand": "查看原始選擇" } }, "providerChain": { @@ -1120,8 +1125,16 @@ "highTooltip": "請求量充足,資料可靠。" }, "actions": { + "refresh": "重新整理", + "refreshing": "重新整理中...", + "autoRefresh": "自動重新整理", + "stopAutoRefresh": "停止自動重新整理", + "viewDetails": "檢視詳情", + "testProvider": "測試供應商", + "retry": "重試", "probeNow": "立即探測", "probing": "探測中...", + "probeAll": "探測全部", "probeSuccess": "探測成功", "probeFailed": "探測失敗" }, @@ -1183,20 +1196,6 @@ "greenCount": "成功請求", "redCount": "失敗請求" }, - "actions": { - "refresh": "重新整理", - "refreshing": "重新整理中...", - "autoRefresh": "自動重新整理", - "stopAutoRefresh": "停止自動重新整理", - "viewDetails": "檢視詳情", - "testProvider": "測試供應商", - "retry": "重試", - "probeNow": "立即探測", - "probing": "探測中...", - "probeAll": "探測全部", - "probeSuccess": "探測成功", - "probeFailed": "探測失敗" - }, "states": { "loading": "載入中...", "error": "載入失敗", diff --git a/messages/zh-TW/provider-chain.json b/messages/zh-TW/provider-chain.json index c9846479c..847d0bbd5 100644 --- a/messages/zh-TW/provider-chain.json +++ b/messages/zh-TW/provider-chain.json @@ -19,7 +19,8 @@ }, "summary": { "singleSuccess": "{total} 個供應商,{healthy} 個健康 → {provider} ✓", - "sessionReuse": "會話複用 → {provider} ✓" + "sessionReuse": "會話複用 → {provider} ✓", + "originHint": "會話複用 - 最初通過 {method} 選擇" }, "description": { "noDecisionRecord": "無決策記錄", @@ -213,5 +214,11 @@ "strictBlockSelectorError": "嚴格模式:端點選擇器發生錯誤,跳過該供應商且不降級", "vendorTypeAllTimeout": "供應商類型全端點逾時(524)", "vendorTypeAllTimeoutNote": "該供應商類型的所有端點均逾時,已觸發供應商類型臨時熔斷。" + }, + "selectionMethods": { + "session_reuse": "會話複用", + "weighted_random": "加權隨機", + "group_filtered": "分組過濾", + "fail_open_fallback": "故障開放回退" } } diff --git a/src/actions/session-origin-chain.ts b/src/actions/session-origin-chain.ts new file mode 100644 index 000000000..904b7b23d --- /dev/null +++ b/src/actions/session-origin-chain.ts @@ -0,0 +1,57 @@ +"use server"; + +import { and, eq, inArray, isNull, or } from "drizzle-orm"; +import { db } from "@/drizzle/db"; +import { messageRequest } from "@/drizzle/schema"; +import { getSession } from "@/lib/auth"; +import { logger } from "@/lib/logger"; +import { findKeyList } from "@/repository/key"; +import { findSessionOriginChain } from "@/repository/message"; +import type { ProviderChainItem } from "@/types/message"; +import type { ActionResult } from "./types"; + +export async function getSessionOriginChain( + sessionId: string +): Promise> { + try { + const session = await getSession(); + if (!session) { + return { ok: false, error: "未登录" }; + } + + if (session.user.role !== "admin") { + const userKeys = await findKeyList(session.user.id); + const userKeyValues = userKeys.map((key) => key.key); + + const ownershipCondition = + userKeyValues.length > 0 + ? or( + eq(messageRequest.userId, session.user.id), + inArray(messageRequest.key, userKeyValues) + ) + : eq(messageRequest.userId, session.user.id); + + const [ownedSession] = await db + .select({ id: messageRequest.id }) + .from(messageRequest) + .where( + and( + eq(messageRequest.sessionId, sessionId), + isNull(messageRequest.deletedAt), + ownershipCondition + ) + ) + .limit(1); + + if (!ownedSession) { + return { ok: false, error: "无权访问该 Session" }; + } + } + + const chain = await findSessionOriginChain(sessionId); + return { ok: true, data: chain ?? null }; + } catch (error) { + logger.error("获取会话来源链失败:", error); + return { ok: false, error: "获取会话来源链失败" }; + } +} diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx index 8fdcd5f7b..6a7ff6892 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx @@ -4,12 +4,26 @@ import { createRoot } from "react-dom/client"; import { act } from "react"; import { NextIntlClientProvider } from "next-intl"; import { Window } from "happy-dom"; -import { describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const hasSessionMessagesMock = vi.fn(); vi.mock("@/actions/active-sessions", () => ({ - hasSessionMessages: vi.fn().mockResolvedValue({ ok: true, data: false }), + hasSessionMessages: (...args: [string, number | undefined]) => hasSessionMessagesMock(...args), })); +const getSessionOriginChainMock = vi.fn(); + +vi.mock("@/actions/session-origin-chain", () => ({ + getSessionOriginChain: (...args: [string]) => getSessionOriginChainMock(...args), +})); + +beforeEach(() => { + hasSessionMessagesMock.mockResolvedValue({ ok: true, data: false }); + getSessionOriginChainMock.mockReset(); + getSessionOriginChainMock.mockResolvedValue({ ok: false, error: "mock" }); +}); + vi.mock("@/i18n/routing", () => ({ Link: ({ href, children }: { href: string; children: ReactNode }) => ( {children} @@ -246,6 +260,22 @@ const messages = { attemptProvider: "Attempt: {provider}", retryAttempt: "Retry #{number}", httpStatus: "HTTP {code}{inferredSuffix}", + sessionReuse: "Session Reuse", + sessionReuseSelection: "Session Reuse Selection", + sessionReuseSelectionDesc: "Provider selected from session cache", + sessionInfo: "Session Information", + sessionIdLabel: "Session ID", + requestSequence: "Request Sequence", + sessionAge: "Session Age", + reusedProvider: "Reused Provider", + executeRequest: "Execute Request", + cacheOptimizationHint: + "Session reuse optimizes performance by maintaining provider affinity within the same conversation, reducing selection overhead and improving cache hit rates.", + originDecisionTitle: "Original Selection Decision", + originDecisionDesc: "How this provider was initially chosen for this session", + originDecisionLoading: "Loading original decision...", + originDecisionUnavailable: "Original decision record unavailable", + originDecisionExpand: "View original selection", }, noError: { processing: "No error (processing)", @@ -335,6 +365,37 @@ function parseHtml(html: string) { return window.document; } +function renderClientWithIntl(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + {node} + + ); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function click(element: Element | null) { + if (!element) return; + act(() => { + element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + describe("error-details-dialog layout", () => { test("renders fake-200 forwarded notice when errorMessage is a FAKE_200_* code", () => { const html = renderWithIntl( @@ -1028,3 +1089,88 @@ describe("error-details-dialog tabs", () => { expect(html).toContain("#5"); }); }); + +describe("error-details-dialog origin decision chain", () => { + test("shows origin chain trigger for session reuse flow with sessionId", () => { + const html = renderWithIntl( + + ); + + expect(html).toContain("View original selection"); + }); + + test("keeps origin chain content collapsed by default", () => { + const { container, unmount } = renderClientWithIntl( + + ); + + expect(container.textContent).not.toContain("Original decision record unavailable"); + unmount(); + }); + + test("shows unavailable text after expand when origin decision is null", async () => { + getSessionOriginChainMock.mockResolvedValue({ ok: true, data: null }); + + const { container, unmount } = renderClientWithIntl( + + ); + + const trigger = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("View original selection") + ); + + expect(trigger).toBeTruthy(); + click(trigger!); + + await act(async () => { + await Promise.resolve(); + }); + + expect(getSessionOriginChainMock).toHaveBeenCalledWith("sess-origin-3"); + expect(getSessionOriginChainMock).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain("Original decision record unavailable"); + unmount(); + }); +}); diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx index b928e8504..dd51b7ec8 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; +import { getSessionOriginChain } from "@/actions/session-origin-chain"; import { Badge } from "@/components/ui/badge"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; @@ -53,6 +54,7 @@ function getRequestStatus(item: ProviderChainItem): StepStatus { export function LogicTraceTab({ statusCode: _statusCode, providerChain, + sessionId, blockedBy, blockedReason, requestSequence, @@ -61,6 +63,9 @@ export function LogicTraceTab({ const t = useTranslations("dashboard.logs.details"); const tChain = useTranslations("provider-chain"); const [timelineCopied, setTimelineCopied] = useState(false); + const [originOpen, setOriginOpen] = useState(false); + const [originChain, setOriginChain] = useState(undefined); + const [originLoading, setOriginLoading] = useState(false); const handleCopyTimeline = async () => { if (!providerChain) return; @@ -295,6 +300,111 @@ export function LogicTraceTab({ /> )} + {isSessionReuseFlow && sessionId && ( + { + setOriginOpen(open); + if (open && originChain === undefined && !originLoading) { + setOriginLoading(true); + getSessionOriginChain(sessionId) + .then((result) => { + setOriginChain(result.ok ? result.data : null); + }) + .catch(() => { + setOriginChain(null); + }) + .finally(() => { + setOriginLoading(false); + }); + } + }} + > + + {t("logicTrace.originDecisionExpand")} + + + {originLoading && ( +
+ {t("logicTrace.originDecisionLoading")} +
+ )} + {!originLoading && originChain === null && ( +
+ {t("logicTrace.originDecisionUnavailable")} +
+ )} + {!originLoading && + originChain && + originChain.length > 0 && + (() => { + const originItem = + originChain.find((item) => item.reason === "initial_selection") ?? + originChain[0]; + const ctx = originItem?.decisionContext; + return ( +
+
+ {t("logicTrace.originDecisionTitle")} +
+ {ctx && ( +
+
+ + {t("logicTrace.providersCount", { count: ctx.totalProviders })} + +
+ {ctx.enabledProviders !== undefined && ( +
+ + {t("logicTrace.providersCount", { count: ctx.enabledProviders })} + +
+ )} + {ctx.afterHealthCheck !== undefined && ( +
+ + {tChain("details.afterHealthCheck")}: + {" "} + {ctx.afterHealthCheck} +
+ )} + {ctx.selectedPriority !== undefined && ( +
+ + {tChain("details.priority")}: + {" "} + P{ctx.selectedPriority} +
+ )} + {ctx.candidatesAtPriority && ctx.candidatesAtPriority.length > 0 && ( +
+ + {tChain("details.candidates")}: + {" "} + {ctx.candidatesAtPriority.map((c, i) => ( + + {i > 0 && ", "} + {c.name} + {c.probability !== undefined && ( + + {" "} + {formatProbability(c.probability)} + + )} + + ))} +
+ )} +
+ )} +
+ ); + })()} +
+
+ )} + {/* Step 1: Initial Selection (only for non-session-reuse flow) */} {decisionContext && ( { expect(truncateNode).not.toBeNull(); }); + test("session_reuse item with selectionMethod shows origin hint text", () => { + const html = renderWithIntl( + + ); + expect(html).toContain("weighted_random"); + expect(html).toContain("Session reuse - originally selected via"); + }); + + test("non-session-reuse item does NOT show origin hint", () => { + const html = renderWithIntl( + + ); + expect(html).not.toContain("Session reuse - originally selected via"); + }); + test("requestCount>1 branch uses w-full/min-w-0 button and flex-1 name container", () => { const html = renderWithIntl( )} + {sessionReuseItem?.selectionMethod && ( +
+ {tChain("summary.originHint", { + method: tChain(`selectionMethods.${sessionReuseItem.selectionMethod}`), + })} +
+ )} )} diff --git a/src/repository/message.ts b/src/repository/message.ts index c22ace55a..5a8acf82f 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -5,7 +5,7 @@ import { db } from "@/drizzle/db"; import { keys as keysTable, messageRequest, providers, users } from "@/drizzle/schema"; import { getEnvConfig } from "@/lib/config/env.schema"; import { formatCostForStorage } from "@/lib/utils/currency"; -import type { CreateMessageRequestData, MessageRequest } from "@/types/message"; +import type { CreateMessageRequestData, MessageRequest, ProviderChainItem } from "@/types/message"; import type { SpecialSetting } from "@/types/special-settings"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; import { toMessageRequest } from "./_shared/transformers"; @@ -277,6 +277,33 @@ export async function findMessageRequestBySessionId( return toMessageRequest(result); } +/** + * 根据 sessionId 查询该 session 首条非 warmup 请求的 providerChain + * 用于展示会话来源链(原始选择决策) + */ +export async function findSessionOriginChain( + sessionId: string +): Promise { + const [row] = await db + .select({ + providerChain: messageRequest.providerChain, + }) + .from(messageRequest) + .where( + and( + eq(messageRequest.sessionId, sessionId), + isNull(messageRequest.deletedAt), + EXCLUDE_WARMUP_CONDITION, + sql`${messageRequest.providerChain} IS NOT NULL` + ) + ) + .orderBy(asc(messageRequest.requestSequence)) + .limit(1); + + if (!row?.providerChain) return null; + return row.providerChain as ProviderChainItem[]; +} + /** * 按 (sessionId, requestSequence) 获取请求的审计字段(用于 Session 详情页补齐特殊设置展示) */ diff --git a/tests/unit/actions/session-origin-chain-integration.test.ts b/tests/unit/actions/session-origin-chain-integration.test.ts new file mode 100644 index 000000000..a692405f6 --- /dev/null +++ b/tests/unit/actions/session-origin-chain-integration.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test, vi } from "vitest"; +import type { ProviderChainItem } from "../../../src/types/message"; + +type SessionRequestRow = { + requestSequence: number; + providerChain: ProviderChainItem[]; +}; + +describe("getSessionOriginChain integration", () => { + test("returns the first request origin chain for a multi-request session", async () => { + vi.resetModules(); + + const firstRequestChain: ProviderChainItem[] = [ + { + id: 101, + name: "provider-a", + reason: "initial_selection", + selectionMethod: "weighted_random", + }, + ]; + + const secondRequestChain: ProviderChainItem[] = [ + { + id: 101, + name: "provider-a", + reason: "session_reuse", + selectionMethod: "session_reuse", + }, + ]; + + const sessionRequests: SessionRequestRow[] = [ + { requestSequence: 1, providerChain: firstRequestChain }, + { requestSequence: 2, providerChain: secondRequestChain }, + ]; + + const limitMock = vi.fn((limit: number) => + Promise.resolve( + [...sessionRequests] + .sort((a, b) => a.requestSequence - b.requestSequence) + .slice(0, limit) + .map((row) => ({ providerChain: row.providerChain })) + ) + ); + const orderByMock = vi.fn(() => ({ limit: limitMock })); + const whereMock = vi.fn(() => ({ orderBy: orderByMock })); + const fromMock = vi.fn(() => ({ where: whereMock })); + const selectMock = vi.fn(() => ({ from: fromMock })); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + }, + })); + + vi.doMock("@/lib/auth", () => ({ + getSession: vi.fn().mockResolvedValue({ user: { id: 1, role: "admin" } }), + })); + + vi.doMock("@/repository/key", () => ({ + findKeyList: vi.fn(), + })); + + vi.doMock("@/lib/logger", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + }, + })); + + const { getSessionOriginChain } = await import("../../../src/actions/session-origin-chain"); + const result = await getSessionOriginChain("test-session"); + + expect(result).toEqual({ ok: true, data: firstRequestChain }); + expect(result.ok).toBe(true); + if (!result.ok || !result.data) { + throw new Error("Expected action to return origin chain data"); + } + + expect(result.data[0]?.reason).toBe("initial_selection"); + expect(result.data).not.toEqual(secondRequestChain); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(limitMock).toHaveBeenCalledWith(1); + }); +}); diff --git a/tests/unit/actions/session-origin-chain.test.ts b/tests/unit/actions/session-origin-chain.test.ts new file mode 100644 index 000000000..3344e1bde --- /dev/null +++ b/tests/unit/actions/session-origin-chain.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { ProviderChainItem } from "@/types/message"; + +const getSessionMock = vi.fn(); +const findSessionOriginChainMock = vi.fn(); +const findKeyListMock = vi.fn(); + +const dbSelectMock = vi.fn(); +const dbFromMock = vi.fn(); +const dbWhereMock = vi.fn(); +const dbLimitMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("@/repository/message", () => ({ + findSessionOriginChain: findSessionOriginChainMock, +})); + +vi.mock("@/repository/key", () => ({ + findKeyList: findKeyListMock, +})); + +vi.mock("@/drizzle/db", () => ({ + db: { + select: dbSelectMock, + }, +})); + +describe("getSessionOriginChain", () => { + beforeEach(() => { + vi.clearAllMocks(); + + dbSelectMock.mockReturnValue({ from: dbFromMock }); + dbFromMock.mockReturnValue({ where: dbWhereMock }); + dbWhereMock.mockReturnValue({ limit: dbLimitMock }); + dbLimitMock.mockResolvedValue([{ id: 1 }]); + + findKeyListMock.mockResolvedValue([{ key: "user-key-1" }]); + }); + + test("admin happy path: returns provider chain", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + + const chain: ProviderChainItem[] = [ + { + id: 11, + name: "provider-a", + reason: "initial_selection", + }, + ]; + findSessionOriginChainMock.mockResolvedValue(chain); + + const { getSessionOriginChain } = await import("@/actions/session-origin-chain"); + const result = await getSessionOriginChain("sess-admin"); + + expect(result).toEqual({ ok: true, data: chain }); + expect(findSessionOriginChainMock).toHaveBeenCalledWith("sess-admin"); + expect(findKeyListMock).not.toHaveBeenCalled(); + expect(dbSelectMock).not.toHaveBeenCalled(); + }); + + test("non-admin happy path: returns provider chain after ownership check", async () => { + getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } }); + + const chain: ProviderChainItem[] = [ + { + id: 22, + name: "provider-b", + reason: "session_reuse", + }, + ]; + findSessionOriginChainMock.mockResolvedValue(chain); + + const { getSessionOriginChain } = await import("@/actions/session-origin-chain"); + const result = await getSessionOriginChain("sess-user"); + + expect(result).toEqual({ ok: true, data: chain }); + expect(findKeyListMock).toHaveBeenCalledWith(2); + expect(dbSelectMock).toHaveBeenCalledTimes(1); + expect(findSessionOriginChainMock).toHaveBeenCalledWith("sess-user"); + }); + + test("unauthenticated: returns not logged in", async () => { + getSessionMock.mockResolvedValue(null); + + const { getSessionOriginChain } = await import("@/actions/session-origin-chain"); + const result = await getSessionOriginChain("sess-no-auth"); + + expect(result).toEqual({ ok: false, error: "未登录" }); + expect(findSessionOriginChainMock).not.toHaveBeenCalled(); + expect(findKeyListMock).not.toHaveBeenCalled(); + expect(dbSelectMock).not.toHaveBeenCalled(); + }); + + test("non-admin without access: returns unauthorized error", async () => { + getSessionMock.mockResolvedValue({ user: { id: 3, role: "user" } }); + findKeyListMock.mockResolvedValue([{ key: "user-key-3" }]); + dbLimitMock.mockResolvedValue([]); + + const { getSessionOriginChain } = await import("@/actions/session-origin-chain"); + const result = await getSessionOriginChain("sess-other-user"); + + expect(result).toEqual({ ok: false, error: "无权访问该 Session" }); + expect(findSessionOriginChainMock).not.toHaveBeenCalled(); + }); + + test("exception path: returns error on unexpected throw", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findSessionOriginChainMock.mockRejectedValue(new Error("db error")); + + const { getSessionOriginChain } = await import("@/actions/session-origin-chain"); + const result = await getSessionOriginChain("sess-throws"); + + expect(result).toEqual({ ok: false, error: "获取会话来源链失败" }); + }); + + test("not found: returns ok with null data", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findSessionOriginChainMock.mockResolvedValue(null); + + const { getSessionOriginChain } = await import("@/actions/session-origin-chain"); + const result = await getSessionOriginChain("sess-not-found"); + + expect(result).toEqual({ ok: true, data: null }); + expect(findSessionOriginChainMock).toHaveBeenCalledWith("sess-not-found"); + expect(findKeyListMock).not.toHaveBeenCalled(); + expect(dbSelectMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/repository/message-origin-chain.test.ts b/tests/unit/repository/message-origin-chain.test.ts new file mode 100644 index 000000000..646efbbd7 --- /dev/null +++ b/tests/unit/repository/message-origin-chain.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test, vi } from "vitest"; +import type { ProviderChainItem } from "@/types/message"; + +function sqlToString(sqlObj: unknown): string { + const visited = new Set(); + + const walk = (node: unknown): string => { + if (!node || visited.has(node)) return ""; + visited.add(node); + + if (typeof node === "string") return node; + + if (typeof node === "object") { + const anyNode = node as any; + if (Array.isArray(anyNode)) { + return anyNode.map(walk).join(""); + } + + if (anyNode.name && typeof anyNode.name === "string") { + return anyNode.name; + } + + if (anyNode.value) { + if (Array.isArray(anyNode.value)) { + return anyNode.value.map(String).join(""); + } + return String(anyNode.value); + } + + if (anyNode.queryChunks) { + return walk(anyNode.queryChunks); + } + } + + return ""; + }; + + return walk(sqlObj); +} + +function createThenableQuery( + result: T, + opts?: { + whereArgs?: unknown[]; + orderByArgs?: unknown[]; + limitArgs?: unknown[]; + } +) { + const query: any = Promise.resolve(result); + + query.from = vi.fn(() => query); + query.where = vi.fn((arg: unknown) => { + opts?.whereArgs?.push(arg); + return query; + }); + query.orderBy = vi.fn((...args: unknown[]) => { + opts?.orderByArgs?.push(args); + return query; + }); + query.limit = vi.fn((arg: unknown) => { + opts?.limitArgs?.push(arg); + return query; + }); + + return query; +} + +describe("repository/message findSessionOriginChain", () => { + test("happy path: 返回 session 首条非 warmup 的完整 providerChain", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + const orderByArgs: unknown[] = []; + const limitArgs: unknown[] = []; + + const chain: ProviderChainItem[] = [ + { + id: 101, + name: "provider-a", + reason: "initial_selection", + selectionMethod: "weighted_random", + attemptNumber: 1, + }, + ]; + + const selectMock = vi.fn(() => + createThenableQuery([{ providerChain: chain }], { whereArgs, orderByArgs, limitArgs }) + ); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findSessionOriginChain } = await import("@/repository/message"); + const result = await findSessionOriginChain("session-happy"); + + expect(result).toEqual(chain); + expect(whereArgs.length).toBeGreaterThan(0); + + const whereSql = sqlToString(whereArgs[0]).toLowerCase(); + expect(whereSql).toContain("warmup"); + expect(whereSql).toContain("is not null"); + + expect(orderByArgs.length).toBeGreaterThan(0); + const orderSql = sqlToString(orderByArgs[0]).toLowerCase(); + expect(orderSql).toContain("request_sequence"); + expect(orderSql).toContain("asc"); + + expect(limitArgs).toEqual([1]); + }); + + test("warmup skip: 第一条为 warmup 时应返回后续首条非 warmup 的 chain", async () => { + vi.resetModules(); + + const chain: ProviderChainItem[] = [ + { + id: 202, + name: "provider-b", + reason: "session_reuse", + selectionMethod: "session_reuse", + attemptNumber: 2, + }, + ]; + + const selectMock = vi.fn(() => createThenableQuery([{ providerChain: chain }])); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findSessionOriginChain } = await import("@/repository/message"); + const result = await findSessionOriginChain("session-warmup-first"); + + expect(result).toEqual(chain); + }); + + test("no data: session 不存在时返回 null", async () => { + vi.resetModules(); + + const selectMock = vi.fn(() => createThenableQuery([])); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findSessionOriginChain } = await import("@/repository/message"); + const result = await findSessionOriginChain("session-not-found"); + + expect(result).toBeNull(); + }); + + test("all warmup: 全部请求都被 warmup 拦截时返回 null", async () => { + vi.resetModules(); + + const selectMock = vi.fn(() => createThenableQuery([])); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findSessionOriginChain } = await import("@/repository/message"); + const result = await findSessionOriginChain("session-all-warmup"); + + expect(result).toBeNull(); + }); + + test("null providerChain: 首条非 warmup 记录 providerChain 为空时返回 null", async () => { + vi.resetModules(); + + const selectMock = vi.fn(() => createThenableQuery([{ providerChain: null }])); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findSessionOriginChain } = await import("@/repository/message"); + const result = await findSessionOriginChain("session-null-provider-chain"); + + expect(result).toBeNull(); + }); +});