Browse Source

feat(logs): add session reuse origin decision chain (#736) (#810)

* feat(i18n): add session origin chain translation keys

Add originDecisionTitle/Desc/Loading/Unavailable/Expand keys to
logs.details.logicTrace in all 5 language dashboard.json files.
Add originHint key to summary in all 5 language provider-chain.json files.

* fix(test): fix sqlToString helper to handle drizzle column references

* feat(repo): add findSessionOriginChain repository function

* feat(ui): add origin context hint to ProviderChainPopover

* feat(actions): add getSessionOriginChain server action

* feat(ui): add collapsible origin chain to LogicTraceTab

* test: add integration tests for session origin chain feature

* fix(ui): replace hardcoded labels with i18n keys in origin chain collapsible

* chore: format code (session-reuse-origin-chain-f1a2b5d)

* fix(pr810): address coderabbit review comments

- fix semantic mismatch: use providersCount key for enabledProviders display
- fix probability formatting: use formatProbability() instead of Math.round
- fix i18n: translate selectionMethod enum via selectionMethods namespace
- add selectionMethods translations to all 5 language files
- add JSDoc to findSessionOriginChain repository function
- fix test: null providerChain mock now returns row with null providerChain
- fix test: add assertion before trigger click in error-details-dialog test
- add 2 missing test cases: non-admin unauthorized + exception path

* chore: format code (session-reuse-origin-chain-152b428)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Ding 1 week ago
parent
commit
ff054a66ac

+ 14 - 9
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.",

+ 8 - 1
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"
   }
 }

+ 40 - 100
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": "更新に失敗しました。再試行してください"

+ 8 - 1
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": "フェイルオープンフォールバック"
   }
 }

+ 20 - 23
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.",

+ 8 - 1
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": "Резервный вариант при сбое"
   }
 }

+ 37 - 32
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。",

+ 8 - 1
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": "故障开放回退"
   }
 }

+ 14 - 15
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": "載入失敗",

+ 8 - 1
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": "故障開放回退"
   }
 }

+ 57 - 0
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<ActionResult<ProviderChainItem[] | null>> {
+  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: "获取会话来源链失败" };
+  }
+}

+ 148 - 2
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 }) => (
     <a href={href}>{children}</a>
@@ -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(
+      <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
+        {node}
+      </NextIntlClientProvider>
+    );
+  });
+
+  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(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={200}
+        errorMessage={null}
+        sessionId={"sess-origin-1"}
+        providerChain={
+          [
+            {
+              id: 1,
+              name: "p1",
+              reason: "session_reuse",
+            },
+          ] as any
+        }
+      />
+    );
+
+    expect(html).toContain("View original selection");
+  });
+
+  test("keeps origin chain content collapsed by default", () => {
+    const { container, unmount } = renderClientWithIntl(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={200}
+        errorMessage={null}
+        sessionId={"sess-origin-2"}
+        providerChain={
+          [
+            {
+              id: 1,
+              name: "p1",
+              reason: "session_reuse",
+            },
+          ] as any
+        }
+      />
+    );
+
+    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(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={200}
+        errorMessage={null}
+        sessionId={"sess-origin-3"}
+        providerChain={
+          [
+            {
+              id: 1,
+              name: "p1",
+              reason: "session_reuse",
+            },
+          ] as any
+        }
+      />
+    );
+
+    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();
+  });
+});

+ 110 - 0
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<ProviderChainItem[] | null | undefined>(undefined);
+  const [originLoading, setOriginLoading] = useState(false);
 
   const handleCopyTimeline = async () => {
     if (!providerChain) return;
@@ -295,6 +300,111 @@ export function LogicTraceTab({
             />
           )}
 
+          {isSessionReuseFlow && sessionId && (
+            <Collapsible
+              open={originOpen}
+              onOpenChange={(open) => {
+                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);
+                    });
+                }
+              }}
+            >
+              <CollapsibleTrigger className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground w-full py-1 px-2">
+                <span>{t("logicTrace.originDecisionExpand")}</span>
+              </CollapsibleTrigger>
+              <CollapsibleContent>
+                {originLoading && (
+                  <div className="text-xs text-muted-foreground px-2 py-1">
+                    {t("logicTrace.originDecisionLoading")}
+                  </div>
+                )}
+                {!originLoading && originChain === null && (
+                  <div className="text-xs text-muted-foreground px-2 py-1">
+                    {t("logicTrace.originDecisionUnavailable")}
+                  </div>
+                )}
+                {!originLoading &&
+                  originChain &&
+                  originChain.length > 0 &&
+                  (() => {
+                    const originItem =
+                      originChain.find((item) => item.reason === "initial_selection") ??
+                      originChain[0];
+                    const ctx = originItem?.decisionContext;
+                    return (
+                      <div className="space-y-2 px-2 py-1 text-xs">
+                        <div className="font-medium text-muted-foreground">
+                          {t("logicTrace.originDecisionTitle")}
+                        </div>
+                        {ctx && (
+                          <div className="grid grid-cols-2 gap-1.5 pl-2 min-w-0">
+                            <div>
+                              <span className="text-muted-foreground">
+                                {t("logicTrace.providersCount", { count: ctx.totalProviders })}
+                              </span>
+                            </div>
+                            {ctx.enabledProviders !== undefined && (
+                              <div>
+                                <span className="text-muted-foreground">
+                                  {t("logicTrace.providersCount", { count: ctx.enabledProviders })}
+                                </span>
+                              </div>
+                            )}
+                            {ctx.afterHealthCheck !== undefined && (
+                              <div>
+                                <span className="text-muted-foreground">
+                                  {tChain("details.afterHealthCheck")}:
+                                </span>{" "}
+                                <span className="font-mono">{ctx.afterHealthCheck}</span>
+                              </div>
+                            )}
+                            {ctx.selectedPriority !== undefined && (
+                              <div>
+                                <span className="text-muted-foreground">
+                                  {tChain("details.priority")}:
+                                </span>{" "}
+                                <span className="font-mono">P{ctx.selectedPriority}</span>
+                              </div>
+                            )}
+                            {ctx.candidatesAtPriority && ctx.candidatesAtPriority.length > 0 && (
+                              <div className="col-span-2">
+                                <span className="text-muted-foreground">
+                                  {tChain("details.candidates")}:
+                                </span>{" "}
+                                {ctx.candidatesAtPriority.map((c, i) => (
+                                  <span key={c.id}>
+                                    {i > 0 && ", "}
+                                    {c.name}
+                                    {c.probability !== undefined && (
+                                      <span className="text-muted-foreground">
+                                        {" "}
+                                        {formatProbability(c.probability)}
+                                      </span>
+                                    )}
+                                  </span>
+                                ))}
+                              </div>
+                            )}
+                          </div>
+                        )}
+                      </div>
+                    );
+                  })()}
+              </CollapsibleContent>
+            </Collapsible>
+          )}
+
           {/* Step 1: Initial Selection (only for non-session-reuse flow) */}
           {decisionContext && (
             <StepCard

+ 52 - 1
src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx

@@ -101,7 +101,9 @@ const messages = {
       },
     },
   },
-  "provider-chain": {},
+  "provider-chain": {
+    summary: { originHint: "Session reuse - originally selected via {method}" },
+  },
 };
 
 function renderWithIntl(node: ReactNode) {
@@ -325,6 +327,55 @@ describe("provider-chain-popover layout", () => {
     expect(truncateNode).not.toBeNull();
   });
 
+  test("session_reuse item with selectionMethod shows origin hint text", () => {
+    const html = renderWithIntl(
+      <ProviderChainPopover
+        chain={[
+          {
+            id: 1,
+            name: "p1",
+            reason: "session_reuse",
+            selectionMethod: "weighted_random",
+          },
+          { id: 1, name: "p1", reason: "request_success", statusCode: 200 },
+        ]}
+        finalProvider="p1"
+      />
+    );
+    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(
+      <ProviderChainPopover
+        chain={[
+          {
+            id: 1,
+            name: "p1",
+            reason: "initial_selection",
+            decisionContext: {
+              totalProviders: 1,
+              enabledProviders: 1,
+              targetType: "claude",
+              groupFilterApplied: false,
+              beforeHealthCheck: 1,
+              afterHealthCheck: 1,
+              priorityLevels: [1],
+              selectedPriority: 1,
+              candidatesAtPriority: [
+                { id: 1, name: "p1", weight: 100, costMultiplier: 1, probability: 1 },
+              ],
+            },
+          },
+          { id: 1, name: "p1", reason: "request_success", statusCode: 200 },
+        ]}
+        finalProvider="p1"
+      />
+    );
+    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(
       <ProviderChainPopover

+ 7 - 0
src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx

@@ -258,6 +258,13 @@ export function ProviderChainPopover({
                         </div>
                       )}
                     </div>
+                    {sessionReuseItem?.selectionMethod && (
+                      <div className="text-[10px] text-zinc-400 dark:text-zinc-500 pt-0.5">
+                        {tChain("summary.originHint", {
+                          method: tChain(`selectionMethods.${sessionReuseItem.selectionMethod}`),
+                        })}
+                      </div>
+                    )}
                   </div>
                 )}
 

+ 28 - 1
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<ProviderChainItem[] | null> {
+  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 详情页补齐特殊设置展示)
  */

+ 87 - 0
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);
+  });
+});

+ 131 - 0
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();
+  });
+});

+ 196 - 0
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<unknown>();
+
+  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<T>(
+  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();
+  });
+});