From 57756b46729c08668165376f9a8974b9aaba675b Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Thu, 28 May 2026 18:55:49 +0800 Subject: [PATCH 1/5] =?UTF-8?q?chore:=20=E5=B0=86=20Codex=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=A8=A1=E5=9E=8B=E5=8D=87=E7=BA=A7=E5=88=B0=20GPT-5.?= =?UTF-8?q?5=20(#1221)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: tesgth032 --- CHANGELOG.md | 4 +- messages/en/settings/prices.json | 4 +- .../settings/providers/form/modelSelect.json | 2 +- .../en/settings/providers/form/strings.json | 2 +- messages/en/usage.json | 4 +- messages/ja/settings/prices.json | 4 +- .../settings/providers/form/modelSelect.json | 2 +- .../ja/settings/providers/form/strings.json | 2 +- messages/ja/usage.json | 4 +- messages/providers-i18n-additions.json | 4 +- messages/ru/settings/prices.json | 4 +- .../settings/providers/form/modelSelect.json | 2 +- .../ru/settings/providers/form/strings.json | 2 +- messages/ru/usage.json | 4 +- messages/zh-CN/settings/prices.json | 4 +- .../settings/providers/form/modelSelect.json | 2 +- .../settings/providers/form/strings.json | 2 +- messages/zh-CN/usage.json | 4 +- messages/zh-TW/settings/prices.json | 4 +- .../settings/providers/form/modelSelect.json | 2 +- .../settings/providers/form/strings.json | 2 +- messages/zh-TW/usage.json | 4 +- src/actions/providers.ts | 4 +- .../_components/usage-logs-table.test.tsx | 4 +- .../session-messages-client-actions.test.tsx | 10 +-- .../session-messages-client.test.tsx | 6 +- .../_components/forms/api-test-button.tsx | 2 +- src/app/[locale]/usage-doc/page.tsx | 22 +++--- .../__tests__/server-helpers.test.ts | 2 +- .../__tests__/upstream-adapter.test.ts | 54 +++++++------- src/lib/model-vendor-icons.test.ts | 2 +- src/lib/provider-testing/data/cx_base.json | 2 +- .../provider-testing/data/cx_codex_basic.json | 2 +- .../provider-testing/data/cx_gpt_basic.json | 2 +- src/lib/provider-testing/presets.ts | 4 +- src/lib/provider-testing/test-service.test.ts | 52 ++++++------- .../provider-testing/utils/test-prompts.ts | 4 +- .../session-manager-detail-snapshots.test.ts | 12 +-- src/types/model-price.ts | 4 +- .../api/v1/model-prices/model-prices.test.ts | 34 ++++----- tests/api/v1/providers/providers.read.test.ts | 4 +- .../responses-ws-codex-cli-transport.test.ts | 2 +- .../integration/billing-model-source.test.ts | 74 +++++++++---------- ...at-endpoint-fallback-observability.test.ts | 2 +- .../active-sessions-detail-snapshots.test.ts | 20 ++--- tests/unit/actions/model-prices.test.ts | 26 +++---- tests/unit/codex/session-completer.test.ts | 2 +- .../unit/lib/utils/pricing-resolution.test.ts | 26 +++---- .../unit/proxy/actual-response-model.test.ts | 4 +- .../proxy/codex-provider-overrides.test.ts | 24 +++--- .../proxy/non-chat-endpoint-fallback.test.ts | 8 +- .../non-chat-endpoint-session-context.test.ts | 4 +- ...y-forwarder-large-chunked-response.test.ts | 4 +- .../proxy-forwarder-nonok-body-hang.test.ts | 4 +- ...rwarder-raw-passthrough-regression.test.ts | 8 +- ...nse-handler-abort-listener-cleanup.test.ts | 10 +-- tests/unit/server-ws-close-handshake.test.ts | 22 +++--- .../price-list-multi-provider-ui.test.tsx | 4 +- .../providers/api-test-button.test.tsx | 2 +- .../usage-doc/opencode-usage-doc.test.tsx | 8 +- 60 files changed, 274 insertions(+), 274 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 502282f1d..8f14e8f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -473,7 +473,7 @@ ### 优化 - i18n 设置模块拆分优化,引入翻译质量门禁机制 (#588) [@YangQing-Lin](https://github.com/YangQing-Lin) -- OpenCode 使用文档更新,添加 GPT-5.2 和 Gemini v1beta 配置示例 (#597) +- OpenCode 使用文档更新,添加 GPT-5.5 和 Gemini v1beta 配置示例 (#597) ### 修复 @@ -908,7 +908,7 @@ ### 其他 -- 更新使用文档:Codex 推荐模型更新为 gpt-5.2 with xhigh reasoning +- 更新使用文档:Codex 推荐模型更新为 gpt-5.5 with xhigh reasoning - 更新 LiteLLM 模型价格数据 --- diff --git a/messages/en/settings/prices.json b/messages/en/settings/prices.json index cb206b446..fb6e970a5 100644 --- a/messages/en/settings/prices.json +++ b/messages/en/settings/prices.json @@ -172,10 +172,10 @@ "deleteConfirm": "Are you sure you want to delete model {name}? This action cannot be undone.", "form": { "modelName": "Model ID", - "modelNamePlaceholder": "e.g., gpt-5.4", + "modelNamePlaceholder": "e.g., gpt-5.5", "modelNameRequired": "Model ID is required", "displayName": "Display Name (Optional)", - "displayNamePlaceholder": "e.g., GPT-5.4 Codex", + "displayNamePlaceholder": "e.g., GPT-5.5 Codex", "type": "Type", "provider": "Provider", "providerPlaceholder": "e.g., openai", diff --git a/messages/en/settings/providers/form/modelSelect.json b/messages/en/settings/providers/form/modelSelect.json index 23f77b387..bc8b02c1e 100644 --- a/messages/en/settings/providers/form/modelSelect.json +++ b/messages/en/settings/providers/form/modelSelect.json @@ -10,7 +10,7 @@ "loading": "Loading...", "manualAdd": "Manually Add Model", "manualDesc": "Support adding any model name (not limited to price table)", - "manualPlaceholder": "Enter model name (e.g. gpt-5.4)", + "manualPlaceholder": "Enter model name (e.g. gpt-5.5)", "notFound": "Model not found", "openai": "OpenAI", "providerFilterAll": "All Providers", diff --git a/messages/en/settings/providers/form/strings.json b/messages/en/settings/providers/form/strings.json index 49a84318e..c29aa9f60 100644 --- a/messages/en/settings/providers/form/strings.json +++ b/messages/en/settings/providers/form/strings.json @@ -82,7 +82,7 @@ "modelWhitelistLoading": "Loading...", "modelWhitelistManualAdd": "Manually Add Model", "modelWhitelistManualDesc": "Support adding any model name (not limited to price table)", - "modelWhitelistManualPlaceholder": "Enter model name (e.g. gpt-5.4-pro)", + "modelWhitelistManualPlaceholder": "Enter model name (e.g. gpt-5.5-pro)", "modelWhitelistNotFound": "Model not found", "modelWhitelistSearchPlaceholder": "Search model name...", "modelWhitelistSelectAll": "Select All ({count})", diff --git a/messages/en/usage.json b/messages/en/usage.json index 419669bc0..7c8a4f18a 100644 --- a/messages/en/usage.json +++ b/messages/en/usage.json @@ -544,7 +544,7 @@ "importantPoints": [ "Create an API key in the cch console and set the CCH_API_KEY environment variable", "cchClaude/openai use ${resolvedOrigin}/v1; cchGemini uses ${resolvedOrigin}/v1beta", - "When selecting models, use provider_id/model_id (e.g. openai/gpt-5.4 or cchClaude/claude-sonnet-4-5-20250929)" + "When selecting models, use provider_id/model_id (e.g. openai/gpt-5.5 or cchClaude/claude-sonnet-4-5-20250929)" ] }, @@ -622,7 +622,7 @@ "steps": [ "Restart Droid", "Enter the /model command", - "Select GPT-5.4 [cch] or Sonnet 4.5 [cch]", + "Select GPT-5.5 [cch] or Sonnet 4.5 [cch]", "Start using!" ] } diff --git a/messages/ja/settings/prices.json b/messages/ja/settings/prices.json index 82a9a94d9..2ee467dd5 100644 --- a/messages/ja/settings/prices.json +++ b/messages/ja/settings/prices.json @@ -172,10 +172,10 @@ "deleteConfirm": "モデル {name} を削除してもよろしいですか?この操作は元に戻せません。", "form": { "modelName": "モデルID", - "modelNamePlaceholder": "例: gpt-5.4", + "modelNamePlaceholder": "例: gpt-5.5", "modelNameRequired": "モデルIDは必須です", "displayName": "表示名 (任意)", - "displayNamePlaceholder": "例: GPT-5.4 Codex", + "displayNamePlaceholder": "例: GPT-5.5 Codex", "type": "タイプ", "provider": "プロバイダー", "providerPlaceholder": "例: openai", diff --git a/messages/ja/settings/providers/form/modelSelect.json b/messages/ja/settings/providers/form/modelSelect.json index 801a9e6a0..483b3473b 100644 --- a/messages/ja/settings/providers/form/modelSelect.json +++ b/messages/ja/settings/providers/form/modelSelect.json @@ -10,7 +10,7 @@ "loading": "読み込み中...", "manualAdd": "手動でモデルを追加", "manualDesc": "任意のモデル名を追加できます(価格表のモデルに限定されません)", - "manualPlaceholder": "モデル名を入力(例:gpt-5.4)", + "manualPlaceholder": "モデル名を入力(例:gpt-5.5)", "notFound": "モデルが見つかりません", "openai": "OpenAI", "providerFilterAll": "すべてのプロバイダー", diff --git a/messages/ja/settings/providers/form/strings.json b/messages/ja/settings/providers/form/strings.json index a5b92cd23..a9a056281 100644 --- a/messages/ja/settings/providers/form/strings.json +++ b/messages/ja/settings/providers/form/strings.json @@ -82,7 +82,7 @@ "modelWhitelistLoading": "読み込み中...", "modelWhitelistManualAdd": "モデルを手動追加", "modelWhitelistManualDesc": "価格表に限定せず、任意のモデル名を追加できます", - "modelWhitelistManualPlaceholder": "モデル名を入力 (例: gpt-5.4)", + "modelWhitelistManualPlaceholder": "モデル名を入力 (例: gpt-5.5)", "modelWhitelistNotFound": "モデルが見つかりません", "modelWhitelistSearchPlaceholder": "モデル名を検索...", "modelWhitelistSelectAll": "すべて選択 ({count})", diff --git a/messages/ja/usage.json b/messages/ja/usage.json index 871f163e5..9030d23e4 100644 --- a/messages/ja/usage.json +++ b/messages/ja/usage.json @@ -544,7 +544,7 @@ "importantPoints": [ "cch の管理画面で API Key を作成し、環境変数 CCH_API_KEY を設定してください", "cchClaude/openai は ${resolvedOrigin}/v1、cchGemini は ${resolvedOrigin}/v1beta を baseURL に使用します", - "モデル選択は provider_id/model_id 形式(例:openai/gpt-5.4 または cchClaude/claude-sonnet-4-5-20250929)" + "モデル選択は provider_id/model_id 形式(例:openai/gpt-5.5 または cchClaude/claude-sonnet-4-5-20250929)" ] }, @@ -622,7 +622,7 @@ "steps": [ "Droid を再起動", "/model コマンドを入力", - "GPT-5.4 [cch] または Sonnet 4.5 [cch] を選択", + "GPT-5.5 [cch] または Sonnet 4.5 [cch] を選択", "使用開始!" ] } diff --git a/messages/providers-i18n-additions.json b/messages/providers-i18n-additions.json index d86d5b86e..b4d096362 100644 --- a/messages/providers-i18n-additions.json +++ b/messages/providers-i18n-additions.json @@ -78,7 +78,7 @@ "modelWhitelistSelectAll": "全选 ({count})", "modelWhitelistClear": "清空", "modelWhitelistManualAdd": "手动添加模型", - "modelWhitelistManualPlaceholder": "输入模型名称(如 gpt-5.4-pro)", + "modelWhitelistManualPlaceholder": "输入模型名称(如 gpt-5.5-pro)", "modelWhitelistManualDesc": "支持添加任意模型名称(不限于价格表中的模型)", "modelWhitelistAllowAll": "允许所有 {type} 模型", "modelWhitelistAllowAllClause": "允许所有 Claude 模型", @@ -319,7 +319,7 @@ "modelWhitelistSelectAll": "Select All ({count})", "modelWhitelistClear": "Clear", "modelWhitelistManualAdd": "Manually Add Model", - "modelWhitelistManualPlaceholder": "Enter model name (e.g. gpt-5.4-pro)", + "modelWhitelistManualPlaceholder": "Enter model name (e.g. gpt-5.5-pro)", "modelWhitelistManualDesc": "Support adding any model name (not limited to price table)", "modelWhitelistAllowAll": "Allow all {type} models", "modelWhitelistAllowAllClause": "Allow all Claude models", diff --git a/messages/ru/settings/prices.json b/messages/ru/settings/prices.json index cd9c9ef55..69a438b79 100644 --- a/messages/ru/settings/prices.json +++ b/messages/ru/settings/prices.json @@ -172,10 +172,10 @@ "deleteConfirm": "Удалить модель {name}? Это действие необратимо.", "form": { "modelName": "ID модели", - "modelNamePlaceholder": "например: gpt-5.4", + "modelNamePlaceholder": "например: gpt-5.5", "modelNameRequired": "ID модели обязателен", "displayName": "Отображаемое имя (необязательно)", - "displayNamePlaceholder": "например: GPT-5.4 Codex", + "displayNamePlaceholder": "например: GPT-5.5 Codex", "type": "Тип", "provider": "Поставщик", "providerPlaceholder": "например: openai", diff --git a/messages/ru/settings/providers/form/modelSelect.json b/messages/ru/settings/providers/form/modelSelect.json index 3bb7e58fe..05ef177f2 100644 --- a/messages/ru/settings/providers/form/modelSelect.json +++ b/messages/ru/settings/providers/form/modelSelect.json @@ -10,7 +10,7 @@ "loading": "Загрузка...", "manualAdd": "Добавить модель вручную", "manualDesc": "Поддержка добавления любого названия модели (не ограничено прайс-листом)", - "manualPlaceholder": "Введите название модели (например, gpt-5.4)", + "manualPlaceholder": "Введите название модели (например, gpt-5.5)", "notFound": "Модели не найдены", "openai": "OpenAI", "providerFilterAll": "Все провайдеры", diff --git a/messages/ru/settings/providers/form/strings.json b/messages/ru/settings/providers/form/strings.json index 7f0c9a113..79cd48aa2 100644 --- a/messages/ru/settings/providers/form/strings.json +++ b/messages/ru/settings/providers/form/strings.json @@ -82,7 +82,7 @@ "modelWhitelistLoading": "Загрузка...", "modelWhitelistManualAdd": "Добавить модель вручную", "modelWhitelistManualDesc": "Поддерживает добавление любого имени модели (не ограничено прайс-листом)", - "modelWhitelistManualPlaceholder": "Введите имя модели (например, gpt-5.4)", + "modelWhitelistManualPlaceholder": "Введите имя модели (например, gpt-5.5)", "modelWhitelistNotFound": "Модели не найдены", "modelWhitelistSearchPlaceholder": "Поиск по имени модели...", "modelWhitelistSelectAll": "Выбрать все ({count})", diff --git a/messages/ru/usage.json b/messages/ru/usage.json index 9e6971559..daf293e31 100644 --- a/messages/ru/usage.json +++ b/messages/ru/usage.json @@ -544,7 +544,7 @@ "importantPoints": [ "Создайте API key в панели cch и задайте переменную окружения CCH_API_KEY", "cchClaude/openai используют ${resolvedOrigin}/v1; cchGemini использует ${resolvedOrigin}/v1beta", - "При выборе модели используйте provider_id/model_id (например, openai/gpt-5.4 или cchClaude/claude-sonnet-4-5-20250929)" + "При выборе модели используйте provider_id/model_id (например, openai/gpt-5.5 или cchClaude/claude-sonnet-4-5-20250929)" ] }, @@ -622,7 +622,7 @@ "steps": [ "Перезагрузите Droid", "Введите команду /model", - "Выберите GPT-5.4 [cch] или Sonnet 4.5 [cch]", + "Выберите GPT-5.5 [cch] или Sonnet 4.5 [cch]", "Начните использовать!" ] } diff --git a/messages/zh-CN/settings/prices.json b/messages/zh-CN/settings/prices.json index 4af7bbf8d..9171804e4 100644 --- a/messages/zh-CN/settings/prices.json +++ b/messages/zh-CN/settings/prices.json @@ -172,10 +172,10 @@ "deleteConfirm": "确定要删除模型 {name} 吗?此操作不可撤销。", "form": { "modelName": "模型 ID", - "modelNamePlaceholder": "例如: gpt-5.4", + "modelNamePlaceholder": "例如: gpt-5.5", "modelNameRequired": "模型 ID 不能为空", "displayName": "展示名称(可选)", - "displayNamePlaceholder": "例如: GPT-5.4 Codex", + "displayNamePlaceholder": "例如: GPT-5.5 Codex", "type": "类型", "provider": "供应商", "providerPlaceholder": "例如: openai", diff --git a/messages/zh-CN/settings/providers/form/modelSelect.json b/messages/zh-CN/settings/providers/form/modelSelect.json index c58aa6ea6..5b1176996 100644 --- a/messages/zh-CN/settings/providers/form/modelSelect.json +++ b/messages/zh-CN/settings/providers/form/modelSelect.json @@ -13,7 +13,7 @@ "exactMatchHint": "这里选中的模型会作为精确匹配规则加入白名单;前缀、后缀、关键词和正则规则仍在下方高级编辑区维护。", "fallbackNotice": "当前无法获取上游模型列表,已自动切换到本地价格表目录。", "manualAdd": "手动添加模型", - "manualPlaceholder": "输入模型名称(如 gpt-5.4)", + "manualPlaceholder": "输入模型名称(如 gpt-5.5)", "manualDesc": "支持添加任意模型名称(不限于价格表中的模型)", "claude": "Claude", "openai": "OpenAI", diff --git a/messages/zh-CN/settings/providers/form/strings.json b/messages/zh-CN/settings/providers/form/strings.json index 154f1ae09..122dffab2 100644 --- a/messages/zh-CN/settings/providers/form/strings.json +++ b/messages/zh-CN/settings/providers/form/strings.json @@ -105,7 +105,7 @@ "modelWhitelistSelectAll": "全选 ({count})", "modelWhitelistClear": "清空", "modelWhitelistManualAdd": "手动添加模型", - "modelWhitelistManualPlaceholder": "输入模型名称(如 gpt-5.4)", + "modelWhitelistManualPlaceholder": "输入模型名称(如 gpt-5.5)", "modelWhitelistManualDesc": "支持添加任意模型名称(不限于价格表中的模型)", "modelWhitelistAllowAll": "允许所有 {type} 模型", "modelWhitelistAllowAllClause": "允许所有 Claude 模型", diff --git a/messages/zh-CN/usage.json b/messages/zh-CN/usage.json index 6bdb3829f..8bbd02ee3 100644 --- a/messages/zh-CN/usage.json +++ b/messages/zh-CN/usage.json @@ -540,7 +540,7 @@ "importantPoints": [ "请先在 cch 后台创建 API Key,并设置环境变量 CCH_API_KEY", "cchClaude/openai 使用 ${resolvedOrigin}/v1,cchGemini 使用 ${resolvedOrigin}/v1beta", - "模型选择时使用 provider_id/model_id 格式(例如 openai/gpt-5.4 或 cchClaude/claude-sonnet-4-5-20250929)" + "模型选择时使用 provider_id/model_id 格式(例如 openai/gpt-5.5 或 cchClaude/claude-sonnet-4-5-20250929)" ] }, @@ -618,7 +618,7 @@ "steps": [ "重启 Droid", "输入 /model 命令", - "选择 GPT-5.4 [cch] 或 Sonnet 4.5 [cch]", + "选择 GPT-5.5 [cch] 或 Sonnet 4.5 [cch]", "开始使用!" ] } diff --git a/messages/zh-TW/settings/prices.json b/messages/zh-TW/settings/prices.json index 68022a36d..73e689ab9 100644 --- a/messages/zh-TW/settings/prices.json +++ b/messages/zh-TW/settings/prices.json @@ -172,10 +172,10 @@ "deleteConfirm": "確定要刪除模型 {name} 嗎?此操作無法復原。", "form": { "modelName": "模型識別碼", - "modelNamePlaceholder": "例如:gpt-5.4", + "modelNamePlaceholder": "例如:gpt-5.5", "modelNameRequired": "模型 ID 為必填", "displayName": "顯示名稱(選填)", - "displayNamePlaceholder": "例如:GPT-5.4 Codex", + "displayNamePlaceholder": "例如:GPT-5.5 Codex", "type": "類型", "provider": "供應商", "providerPlaceholder": "例如:openai", diff --git a/messages/zh-TW/settings/providers/form/modelSelect.json b/messages/zh-TW/settings/providers/form/modelSelect.json index cfb705451..c7dacd62e 100644 --- a/messages/zh-TW/settings/providers/form/modelSelect.json +++ b/messages/zh-TW/settings/providers/form/modelSelect.json @@ -10,7 +10,7 @@ "loading": "載入中...", "manualAdd": "手動新增模型", "manualDesc": "支援新增任意模型名稱(不限於價格表中的模型)", - "manualPlaceholder": "輸入模型名稱(例如 gpt-5.4)", + "manualPlaceholder": "輸入模型名稱(例如 gpt-5.5)", "notFound": "找不到模型", "openai": "OpenAI", "providerFilterAll": "全部供應商", diff --git a/messages/zh-TW/settings/providers/form/strings.json b/messages/zh-TW/settings/providers/form/strings.json index 3b6f56a8f..dd404df0e 100644 --- a/messages/zh-TW/settings/providers/form/strings.json +++ b/messages/zh-TW/settings/providers/form/strings.json @@ -82,7 +82,7 @@ "modelWhitelistLoading": "載入中...", "modelWhitelistManualAdd": "手動新增模型", "modelWhitelistManualDesc": "支援新增任意模型名稱(不限於價格表中的模型)", - "modelWhitelistManualPlaceholder": "輸入模型名稱(例如 gpt-5.4)", + "modelWhitelistManualPlaceholder": "輸入模型名稱(例如 gpt-5.5)", "modelWhitelistNotFound": "未找到模型", "modelWhitelistSearchPlaceholder": "搜尋模型名稱...", "modelWhitelistSelectAll": "全選({count})", diff --git a/messages/zh-TW/usage.json b/messages/zh-TW/usage.json index e2ea4ea04..bb274fc27 100644 --- a/messages/zh-TW/usage.json +++ b/messages/zh-TW/usage.json @@ -540,7 +540,7 @@ "importantPoints": [ "請先在 cch 後台創建 API Key,並設置環境變量 CCH_API_KEY", "cchClaude/openai 使用 ${resolvedOrigin}/v1,cchGemini 使用 ${resolvedOrigin}/v1beta", - "模型選擇時使用 provider_id/model_id 格式(例如 openai/gpt-5.4 或 cchClaude/claude-sonnet-4-5-20250929)" + "模型選擇時使用 provider_id/model_id 格式(例如 openai/gpt-5.5 或 cchClaude/claude-sonnet-4-5-20250929)" ] }, @@ -618,7 +618,7 @@ "steps": [ "重啟 Droid", "輸入 /model 命令", - "選擇 GPT-5.4 [cch] 或 Sonnet 4.5 [cch]", + "選擇 GPT-5.5 [cch] 或 Sonnet 4.5 [cch]", "開始使用!" ] } diff --git a/src/actions/providers.ts b/src/actions/providers.ts index cdca252cc..e5992862e 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -4309,7 +4309,7 @@ export async function testProviderOpenAIChatCompletions( ): Promise { return executeProviderApiTest(data, { path: "/v1/chat/completions", - defaultModel: "gpt-5.4", + defaultModel: "gpt-5.5", headers: (apiKey, context) => { void context; return { @@ -4343,7 +4343,7 @@ export async function testProviderOpenAIResponses( ): Promise { return executeProviderApiTest(data, { path: "/v1/responses", - defaultModel: "gpt-5.4", + defaultModel: "gpt-5.5", headers: (apiKey, context) => { void context; return { diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx index f85575430..79cf3c46d 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx @@ -365,8 +365,8 @@ describe("usage-logs-table pricing resolution", () => { type: "pricing_resolution", scope: "billing", hit: true, - modelName: "gpt-5.4", - resolvedModelName: "gpt-5.4", + modelName: "gpt-5.5", + resolvedModelName: "gpt-5.5", resolvedPricingProviderKey: "openai", source: "priority_fallback", }, diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx index 2b40f8de6..bac6e637b 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx @@ -123,7 +123,7 @@ function createSnapshots(): SessionDetailSnapshots { defaultView: DEFAULT_SESSION_DETAIL_VIEW_MODE, request: { before: { - body: { model: "gpt-5.4", input: "before" }, + body: { model: "gpt-5.5", input: "before" }, messages: { role: "user", content: "before" }, headers: { "x-before": "1" }, meta: { @@ -133,7 +133,7 @@ function createSnapshots(): SessionDetailSnapshots { }, }, after: { - body: { model: "gpt-5.4", input: "after" }, + body: { model: "gpt-5.5", input: "after" }, messages: { role: "user", content: "after" }, headers: { "x-after": "1" }, meta: { @@ -174,7 +174,7 @@ function buildDetailsData( }> = {} ) { return { - requestBody: { model: "gpt-5.4", input: "legacy" }, + requestBody: { model: "gpt-5.5", input: "legacy" }, messages: { role: "user", content: "legacy" }, response: '{"legacy":true}', requestHeaders: { "x-legacy": "1" }, @@ -322,7 +322,7 @@ describe("SessionMessagesClient (request export actions)", () => { lastRequestAt: "2026-01-01T00:01:00.000Z", totalDurationMs: 1500, providers: [{ id: 1, name: "p1" }], - models: ["gpt-5.4"], + models: ["gpt-5.5"], totalInputTokens: 10, totalOutputTokens: 20, totalCacheCreationTokens: 30, @@ -589,7 +589,7 @@ describe("SessionMessagesClient (request export actions)", () => { lastRequestAt: "2026-01-01T00:01:00.000Z", totalDurationMs: 1500, providers: [{ id: 1, name: "p1" }], - models: ["gpt-5.4"], + models: ["gpt-5.5"], totalInputTokens: 10, totalOutputTokens: 20, totalCacheCreationTokens: 30, diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx index edbda27c7..7c2c55b00 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx @@ -55,7 +55,7 @@ function createSnapshots(): SessionDetailSnapshots { defaultView: DEFAULT_SESSION_DETAIL_VIEW_MODE, request: { before: { - body: { model: "gpt-5.4", instructions: "before body" }, + body: { model: "gpt-5.5", instructions: "before body" }, messages: { role: "user", content: "before hi" }, headers: { "x-before-request": "1" }, meta: { @@ -65,7 +65,7 @@ function createSnapshots(): SessionDetailSnapshots { }, }, after: { - body: { model: "gpt-5.4", instructions: "after body" }, + body: { model: "gpt-5.5", instructions: "after body" }, messages: { role: "user", content: "after hi" }, headers: { "x-after-request": "1" }, meta: { @@ -197,7 +197,7 @@ describe("SessionMessagesDetailsTabs", () => { throw new Error("after snapshot missing"); } snapshots.request.after.body = { - model: "gpt-5.4", + model: "gpt-5.5", input: [{ role: "user", content: "hi" }], }; snapshots.request.after.messages = null; diff --git a/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx b/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx index 7bbc578eb..4a134da37 100644 --- a/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx @@ -33,7 +33,7 @@ const API_TEST_UI_CONFIG = { const DEFAULT_MODELS: Record = { claude: "claude-haiku-4-5-20251001", "claude-auth": "claude-haiku-4-5-20251001", - codex: "gpt-5.4", + codex: "gpt-5.5", "openai-compatible": "gpt-4.1-mini", gemini: "gemini-2.5-flash", "gemini-cli": "gemini-2.5-flash", diff --git a/src/app/[locale]/usage-doc/page.tsx b/src/app/[locale]/usage-doc/page.tsx index 73262ae0b..92be7a6fe 100644 --- a/src/app/[locale]/usage-doc/page.tsx +++ b/src/app/[locale]/usage-doc/page.tsx @@ -663,7 +663,7 @@ sk_xxxxxxxxxxxxxxxxxx`} { }); it("preserves the model query parameter (allow-listed)", () => { - expect(sanitizedRequestPath("/v1/responses?model=gpt-5.4")).toBe("/v1/responses?model=gpt-5.4"); + expect(sanitizedRequestPath("/v1/responses?model=gpt-5.5")).toBe("/v1/responses?model=gpt-5.5"); }); it("masks unknown / sensitive query parameters", () => { diff --git a/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts b/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts index 97e3951ed..e66c1c409 100644 --- a/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts +++ b/src/app/v1/_lib/responses-ws/__tests__/upstream-adapter.test.ts @@ -115,7 +115,7 @@ describe("tryResponsesWebsocketUpstream", () => { provider: codexProvider(), upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), - body: { model: "gpt-5.4", input: [{ role: "user", content: "hi" }] }, + body: { model: "gpt-5.5", input: [{ role: "user", content: "hi" }] }, }); expect("response" in result).toBe(true); @@ -146,7 +146,7 @@ describe("tryResponsesWebsocketUpstream", () => { provider: codexProvider(), upstreamUrl: `http://127.0.0.1:${addr.port}/v1/responses`, upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), - body: { model: "gpt-5.4", input: "hi" }, + body: { model: "gpt-5.5", input: "hi" }, }); expect("failed" in result).toBe(true); @@ -168,7 +168,7 @@ describe("tryResponsesWebsocketUpstream", () => { provider: codexProvider(), upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), - body: { model: "gpt-5.4", input: "hi" }, + body: { model: "gpt-5.5", input: "hi" }, }); expect("failed" in result).toBe(true); @@ -202,7 +202,7 @@ describe("tryResponsesWebsocketUpstream", () => { upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), body: { - model: "gpt-5.4", + model: "gpt-5.5", input: "hi", stream: true, background: false, @@ -251,7 +251,7 @@ describe("tryResponsesWebsocketUpstream", () => { provider: codexProvider(), upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, upstreamHeaders: plainHeaders, - body: { model: "gpt-5.4", input: "hi" }, + body: { model: "gpt-5.5", input: "hi" }, }); expect("response" in result).toBe(true); @@ -303,7 +303,7 @@ describe("tryResponsesWebsocketUpstream", () => { upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), body: { - model: "gpt-5.4", + model: "gpt-5.5", store: false, prompt_cache_key: "tenantA:s1", input: [{ role: "user", content: "hello" }], @@ -321,7 +321,7 @@ describe("tryResponsesWebsocketUpstream", () => { upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), body: { - model: "gpt-5.4", + model: "gpt-5.5", store: false, prompt_cache_key: "tenantA:s1", previous_response_id: "resp_1", @@ -382,7 +382,7 @@ describe("tryResponsesWebsocketUpstream", () => { const turn1 = await tryResponsesWebsocketUpstream({ ...common, - body: { model: "gpt-5.4", store: false, input: "first" }, + body: { model: "gpt-5.5", store: false, input: "first" }, }); expect("response" in turn1).toBe(true); if (!("response" in turn1)) return; @@ -392,7 +392,7 @@ describe("tryResponsesWebsocketUpstream", () => { const turn2 = await tryResponsesWebsocketUpstream({ ...common, body: { - model: "gpt-5.4", + model: "gpt-5.5", store: false, previous_response_id: "resp_1", input: [{ type: "function_call_output", call_id: "call_1", output: "ok" }], @@ -433,7 +433,7 @@ describe("tryResponsesWebsocketUpstream", () => { const warmup = await tryResponsesWebsocketUpstream({ ...common, body: { - model: "gpt-5.4", + model: "gpt-5.5", store: false, generate: false, input: "warm up", @@ -446,7 +446,7 @@ describe("tryResponsesWebsocketUpstream", () => { const generated = await tryResponsesWebsocketUpstream({ ...common, body: { - model: "gpt-5.4", + model: "gpt-5.5", store: false, previous_response_id: "resp_warmup", input: "continue", @@ -494,7 +494,7 @@ describe("tryResponsesWebsocketUpstream", () => { const first = await tryResponsesWebsocketUpstream({ ...common, - body: { model: "gpt-5.4", input: "first" }, + body: { model: "gpt-5.5", input: "first" }, }); expect("response" in first).toBe(true); if (!("response" in first)) return; @@ -502,7 +502,7 @@ describe("tryResponsesWebsocketUpstream", () => { const second = await tryResponsesWebsocketUpstream({ ...common, - body: { model: "gpt-5.4", input: "after reconnect" }, + body: { model: "gpt-5.5", input: "after reconnect" }, }); expect("response" in second).toBe(true); if (!("response" in second)) return; @@ -533,7 +533,7 @@ describe("tryResponsesWebsocketUpstream", () => { upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), sessionId: "client-ws-session-cleanup", - body: { model: "gpt-5.4", input: "hi" }, + body: { model: "gpt-5.5", input: "hi" }, }); expect("response" in result).toBe(true); if (!("response" in result)) return; @@ -567,7 +567,7 @@ describe("tryResponsesWebsocketUpstream", () => { upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), sessionId, - body: { model: "gpt-5.4", input: "hi" }, + body: { model: "gpt-5.5", input: "hi" }, }); expect("response" in result).toBe(true); if (!("response" in result)) return; @@ -638,7 +638,7 @@ describe("tryResponsesWebsocketUpstream", () => { const first = await tryResponsesWebsocketUpstream({ ...common, - body: { model: "gpt-5.4", input: "first" }, + body: { model: "gpt-5.5", input: "first" }, }); expect("response" in first).toBe(true); if (!("response" in first)) return; @@ -646,7 +646,7 @@ describe("tryResponsesWebsocketUpstream", () => { const second = await tryResponsesWebsocketUpstream({ ...common, - body: { model: "gpt-5.4", input: "second" }, + body: { model: "gpt-5.5", input: "second" }, }); expect("response" in second).toBe(true); if (!("response" in second)) return; @@ -735,14 +735,14 @@ describe("tryResponsesWebsocketUpstream", () => { const first = await tryResponsesWebsocketUpstream({ ...common, - body: { model: "gpt-5.4", input: "first" }, + body: { model: "gpt-5.5", input: "first" }, }); expect("response" in first).toBe(true); if (!("response" in first)) return; const second = await tryResponsesWebsocketUpstream({ ...common, - body: { model: "gpt-5.4", input: "second" }, + body: { model: "gpt-5.5", input: "second" }, }); expect("response" in second).toBe(true); if (!("response" in second)) return; @@ -800,7 +800,7 @@ describe("tryResponsesWebsocketUpstream", () => { upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), sessionId: "client-ws-session-aborted-before-first-event", abortSignal: abortController.signal, - body: { model: "gpt-5.4", input: "hi" }, + body: { model: "gpt-5.5", input: "hi" }, }); await withTimeout(messageReceived, 1_000, "upstream did not receive the WS request frame"); @@ -852,7 +852,7 @@ describe("tryResponsesWebsocketUpstream", () => { upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), sessionId: "client-ws-session-cap-active-1", - body: { model: "gpt-5.4", input: "first" }, + body: { model: "gpt-5.5", input: "first" }, }); expect("response" in first).toBe(true); if (!("response" in first)) return; @@ -863,7 +863,7 @@ describe("tryResponsesWebsocketUpstream", () => { upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), sessionId: "client-ws-session-cap-active-2", - body: { model: "gpt-5.4", input: "second" }, + body: { model: "gpt-5.5", input: "second" }, }); expect("response" in second).toBe(true); if (!("response" in second)) return; @@ -888,7 +888,7 @@ describe("tryResponsesWebsocketUpstream", () => { provider: codexProvider(), upstreamUrl: `http://127.0.0.1:${addr.port}/v1/responses`, upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), - body: { model: "gpt-5.4", input: "hi" }, + body: { model: "gpt-5.5", input: "hi" }, }); expect("failed" in result).toBe(true); if (!("failed" in result)) continue; @@ -913,7 +913,7 @@ describe("tryResponsesWebsocketUpstream", () => { provider: codexProvider(), upstreamUrl: `http://127.0.0.1:${addr.port}/v1/responses`, upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), - body: { model: "gpt-5.4", input: "hi" }, + body: { model: "gpt-5.5", input: "hi" }, }); expect("failed" in result).toBe(true); if (!("failed" in result)) continue; @@ -943,7 +943,7 @@ describe("tryResponsesWebsocketUpstream", () => { provider: codexProvider(), upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), - body: { model: "gpt-5.4", input: "hi" }, + body: { model: "gpt-5.5", input: "hi" }, }); expect("response" in result).toBe(true); @@ -985,7 +985,7 @@ describe("tryResponsesWebsocketUpstream", () => { provider: codexProvider(), upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), - body: { model: "gpt-5.4", input: "hi" }, + body: { model: "gpt-5.5", input: "hi" }, }); expect("response" in result).toBe(true); @@ -1018,7 +1018,7 @@ describe("tryResponsesWebsocketUpstream", () => { provider: codexProvider(), upstreamUrl: `http://127.0.0.1:${server.port}/v1/responses`, upstreamHeaders: new Headers({ authorization: "Bearer sk-mock" }), - body: { model: "gpt-5.4", input: "hi" }, + body: { model: "gpt-5.5", input: "hi" }, }); expect("response" in result).toBe(true); diff --git a/src/lib/model-vendor-icons.test.ts b/src/lib/model-vendor-icons.test.ts index 327f32997..874c0af6f 100644 --- a/src/lib/model-vendor-icons.test.ts +++ b/src/lib/model-vendor-icons.test.ts @@ -8,7 +8,7 @@ describe("getModelVendor", () => { { modelId: "claude-3-opus-20240229", expectedKey: "anthropic" }, // OpenAI - gpt prefix { modelId: "gpt-4o-mini", expectedKey: "openai" }, - { modelId: "gpt-5.4", expectedKey: "openai" }, + { modelId: "gpt-5.5", expectedKey: "openai" }, // OpenAI - chatgpt prefix { modelId: "chatgpt-4o-latest", expectedKey: "openai" }, // OpenAI - o1/o3/o4 prefix diff --git a/src/lib/provider-testing/data/cx_base.json b/src/lib/provider-testing/data/cx_base.json index 87bf27867..6e0a43779 100644 --- a/src/lib/provider-testing/data/cx_base.json +++ b/src/lib/provider-testing/data/cx_base.json @@ -1,5 +1,5 @@ { - "model": "gpt-5.4", + "model": "gpt-5.5", "instructions": "You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.\n\n## General\n\n- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with [\"bash\", \"-lc\"].\n- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary.\n- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)\n\n## Editing constraints\n\n- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.\n- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like \"Assigns the value to the variable\", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.\n- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).\n- You may be in a dirty git worktree.\n * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.\n * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.\n * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.\n * If the changes are in unrelated files, just ignore them and don't revert them.\n- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.\n- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.", "input": [ { "role": "system", "content": "You are a echo bot. Always say 'pong'." }, diff --git a/src/lib/provider-testing/data/cx_codex_basic.json b/src/lib/provider-testing/data/cx_codex_basic.json index 3a1d3043b..695af3b03 100644 --- a/src/lib/provider-testing/data/cx_codex_basic.json +++ b/src/lib/provider-testing/data/cx_codex_basic.json @@ -1,5 +1,5 @@ { - "model": "gpt-5.4", + "model": "gpt-5.5", "instructions": "You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.", "input": [ { diff --git a/src/lib/provider-testing/data/cx_gpt_basic.json b/src/lib/provider-testing/data/cx_gpt_basic.json index 3a1d3043b..695af3b03 100644 --- a/src/lib/provider-testing/data/cx_gpt_basic.json +++ b/src/lib/provider-testing/data/cx_gpt_basic.json @@ -1,5 +1,5 @@ { - "model": "gpt-5.4", + "model": "gpt-5.5", "instructions": "You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.", "input": [ { diff --git a/src/lib/provider-testing/presets.ts b/src/lib/provider-testing/presets.ts index 0866d4a5f..a9ed4d68c 100644 --- a/src/lib/provider-testing/presets.ts +++ b/src/lib/provider-testing/presets.ts @@ -96,7 +96,7 @@ export const PRESETS: Record = { providerTypes: ["codex"], payload: cxCodexBasic, defaultSuccessContains: "pong", - defaultModel: "gpt-5.4", + defaultModel: "gpt-5.5", path: "/v1/responses", userAgent: "Codex-CLI/1.0", extraHeaders: { @@ -111,7 +111,7 @@ export const PRESETS: Record = { providerTypes: ["codex"], payload: cxGptBasic, defaultSuccessContains: "pong", - defaultModel: "gpt-5.4", + defaultModel: "gpt-5.5", path: "/v1/responses", userAgent: "Codex-CLI/1.0", extraHeaders: { diff --git a/src/lib/provider-testing/test-service.test.ts b/src/lib/provider-testing/test-service.test.ts index 11861fd89..49fa29ef6 100644 --- a/src/lib/provider-testing/test-service.test.ts +++ b/src/lib/provider-testing/test-service.test.ts @@ -95,7 +95,7 @@ describe("executeProviderTest", () => { const assistantText = `pong-${"x".repeat(7000)}`; const responseBody = mockJsonResponse({ id: "resp_test", - model: "gpt-5.4", + model: "gpt-5.5", output: [ { type: "message", @@ -114,7 +114,7 @@ describe("executeProviderTest", () => { providerUrl: "https://api.example.com", apiKey: "sk-test-codex", providerType: "codex", - model: "gpt-5.4", + model: "gpt-5.5", }); expect(result.success).toBe(true); @@ -148,7 +148,7 @@ describe("executeProviderTest", () => { test("codex full-path baseUrl 不应重复拼接 /v1/responses", async () => { mockJsonResponse({ id: "resp_test", - model: "gpt-5.4", + model: "gpt-5.5", output: [ { type: "message", @@ -162,7 +162,7 @@ describe("executeProviderTest", () => { providerUrl: "https://relay.example.com/openai/v1/responses", apiKey: "sk-test-codex", providerType: "codex", - model: "gpt-5.4", + model: "gpt-5.5", }); expect(result.success).toBe(true); @@ -175,7 +175,7 @@ describe("executeProviderTest", () => { ])("codex bare /openai base preserves absolute versioned request url: %s", async (providerUrl) => { mockJsonResponse({ id: "resp_test", - model: "gpt-5.4", + model: "gpt-5.5", output: [ { type: "message", @@ -189,7 +189,7 @@ describe("executeProviderTest", () => { providerUrl, apiKey: "sk-test-codex", providerType: "codex", - model: "gpt-5.4", + model: "gpt-5.5", }); expect(result.success).toBe(true); @@ -328,7 +328,7 @@ describe("executeProviderTest", () => { test("无版本 endpoint 根路径在 provider testing 中应与 runtime URL 语义一致", async () => { mockJsonResponse({ id: "resp_test", - model: "gpt-5.4", + model: "gpt-5.5", output: [ { type: "message", @@ -342,7 +342,7 @@ describe("executeProviderTest", () => { providerUrl: "https://relay.example.com/openai/responses", apiKey: "sk-test-codex", providerType: "codex", - model: "gpt-5.4", + model: "gpt-5.5", }); expect(result.success).toBe(true); @@ -352,7 +352,7 @@ describe("executeProviderTest", () => { test("非标准相似路径在 provider testing 中不应被错误折叠", async () => { mockJsonResponse({ id: "resp_test", - model: "gpt-5.4", + model: "gpt-5.5", output: [ { type: "message", @@ -366,7 +366,7 @@ describe("executeProviderTest", () => { providerUrl: "https://relay.example.com/openai/responses-archive", apiKey: "sk-test-codex", providerType: "codex", - model: "gpt-5.4", + model: "gpt-5.5", }); expect(result.success).toBe(true); @@ -437,7 +437,7 @@ describe("executeProviderTest", () => { }); const okBody = JSON.stringify({ id: "resp_test", - model: "gpt-5.4", + model: "gpt-5.5", output: [ { type: "message", @@ -461,7 +461,7 @@ describe("executeProviderTest", () => { providerUrl: "https://api.gptclubapi.xyz/openai", apiKey: "sk-test-codex", providerType: "codex", - model: "gpt-5.4", + model: "gpt-5.5", preset: "cx_codex_basic", }); @@ -481,7 +481,7 @@ describe("executeProviderTest", () => { }); const okBody = JSON.stringify({ id: "resp_test", - model: "gpt-5.4", + model: "gpt-5.5", output: [ { type: "message", @@ -505,7 +505,7 @@ describe("executeProviderTest", () => { providerUrl: "https://api.gptclubapi.xyz/openai", apiKey: "sk-test-codex", providerType: "codex", - model: "gpt-5.4", + model: "gpt-5.5", }); expect(fetchMock).toHaveBeenCalledTimes(2); @@ -530,7 +530,7 @@ describe("executeProviderTest", () => { }); const okBody = JSON.stringify({ id: "resp_test", - model: "gpt-5.4", + model: "gpt-5.5", output: [ { type: "message", @@ -561,7 +561,7 @@ describe("executeProviderTest", () => { providerUrl: "https://api.gptclubapi.xyz/openai", apiKey: "sk-test-codex", providerType: "codex", - model: "gpt-5.4", + model: "gpt-5.5", }); expect(fetchMock).toHaveBeenCalledTimes(3); @@ -683,7 +683,7 @@ describe("executeProviderTest", () => { }); const okBody = JSON.stringify({ id: "resp_test", - model: "gpt-5.4", + model: "gpt-5.5", output: [ { type: "message", @@ -708,7 +708,7 @@ describe("executeProviderTest", () => { providerUrl: "https://api.gptclubapi.xyz/openai", apiKey: "sk-test-codex", providerType: "codex", - model: "gpt-5.4", + model: "gpt-5.5", preset: "cx_codex_basic", }); @@ -740,7 +740,7 @@ describe("executeProviderTest", () => { providerUrl: "https://api.gptclubapi.xyz/openai", apiKey: "sk-test-codex", providerType: "codex", - model: "gpt-5.4", + model: "gpt-5.5", preset: "cx_codex_basic", }); @@ -752,13 +752,13 @@ describe("executeProviderTest", () => { test("codex 新版 SSE 事件流应正确提取 output_text delta,避免误判为内容不匹配", async () => { const responseBody = `event: response.created -data: {"type":"response.created","response":{"model":"gpt-5.4","usage":null},"sequence_number":0} +data: {"type":"response.created","response":{"model":"gpt-5.5","usage":null},"sequence_number":0} event: response.output_text.delta data: {"type":"response.output_text.delta","delta":"pong","item_id":"msg_123","output_index":0,"sequence_number":1} event: response.completed -data: {"type":"response.completed","response":{"model":"gpt-5.4","usage":{"input_tokens":39,"output_tokens":5,"total_tokens":44}},"sequence_number":2} +data: {"type":"response.completed","response":{"model":"gpt-5.5","usage":{"input_tokens":39,"output_tokens":5,"total_tokens":44}},"sequence_number":2} `; mockSseResponse(responseBody); @@ -767,13 +767,13 @@ data: {"type":"response.completed","response":{"model":"gpt-5.4","usage":{"input providerUrl: "https://sub.fkcodex.com", apiKey: "sk-test-codex", providerType: "codex", - model: "gpt-5.4", + model: "gpt-5.5", }); expect(result.success).toBe(true); expect(result.subStatus).toBe("success"); expect(result.content).toBe("pong"); - expect(result.model).toBe("gpt-5.4"); + expect(result.model).toBe("gpt-5.5"); expect(result.usage).toEqual({ inputTokens: 39, outputTokens: 5, @@ -785,7 +785,7 @@ data: {"type":"response.completed","response":{"model":"gpt-5.4","usage":{"input data: {"type":"response.output_text.done","text":"pong","item_id":"msg_123","output_index":0,"content_index":0,"sequence_number":1} event: response.completed -data: {"type":"response.completed","response":{"model":"gpt-5.4","usage":{"input_tokens":39,"output_tokens":5,"total_tokens":44},"output":[{"type":"message","content":[{"type":"output_text","text":"pong"}]}]},"sequence_number":2} +data: {"type":"response.completed","response":{"model":"gpt-5.5","usage":{"input_tokens":39,"output_tokens":5,"total_tokens":44},"output":[{"type":"message","content":[{"type":"output_text","text":"pong"}]}]},"sequence_number":2} `; mockSseResponse(responseBody); @@ -794,12 +794,12 @@ data: {"type":"response.completed","response":{"model":"gpt-5.4","usage":{"input providerUrl: "https://sub.fkcodex.com", apiKey: "sk-test-codex", providerType: "codex", - model: "gpt-5.4", + model: "gpt-5.5", }); expect(result.success).toBe(true); expect(result.content).toBe("pong"); - expect(result.model).toBe("gpt-5.4"); + expect(result.model).toBe("gpt-5.5"); }); test("内容校验应优先使用解析后的文本,不能被原始 JSON 字段名误判为成功", async () => { diff --git a/src/lib/provider-testing/utils/test-prompts.ts b/src/lib/provider-testing/utils/test-prompts.ts index b27a95a95..ba6ce56c2 100644 --- a/src/lib/provider-testing/utils/test-prompts.ts +++ b/src/lib/provider-testing/utils/test-prompts.ts @@ -45,7 +45,7 @@ export const CLAUDE_TEST_BODY: ClaudeTestBody = { }; export const CODEX_TEST_BODY: CodexTestBody = { - model: "gpt-5.4", + model: "gpt-5.5", instructions: "You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.", input: [ @@ -111,7 +111,7 @@ export const GEMINI_TEST_HEADERS = { export const DEFAULT_MODELS: Record = { claude: "claude-haiku-4-5-20251001", "claude-auth": "claude-haiku-4-5-20251001", - codex: "gpt-5.4", + codex: "gpt-5.5", "openai-compatible": "gpt-4.1-mini", gemini: "gemini-2.5-flash", "gemini-cli": "gemini-2.5-flash", diff --git a/src/lib/session-manager-detail-snapshots.test.ts b/src/lib/session-manager-detail-snapshots.test.ts index 0691581fa..c03e150d2 100644 --- a/src/lib/session-manager-detail-snapshots.test.ts +++ b/src/lib/session-manager-detail-snapshots.test.ts @@ -76,7 +76,7 @@ describe("SessionManager detail snapshots", () => { "before", { body: { - model: "gpt-5.4", + model: "gpt-5.5", messages: [{ role: "user", content: "top secret request" }], }, messages: [{ role: "user", content: "top secret request" }], @@ -98,7 +98,7 @@ describe("SessionManager detail snapshots", () => { "after", { body: JSON.stringify({ - model: "gpt-5.4", + model: "gpt-5.5", messages: [{ role: "user", content: "processed request body" }], }), headers: new Headers({ @@ -175,7 +175,7 @@ describe("SessionManager detail snapshots", () => { expect(requestBefore).toEqual({ body: { - model: "gpt-5.4", + model: "gpt-5.5", messages: [{ role: "user", content: "[REDACTED]" }], }, messages: [{ role: "user", content: "[REDACTED]" }], @@ -192,7 +192,7 @@ describe("SessionManager detail snapshots", () => { expect(requestAfter).toEqual({ body: { - model: "gpt-5.4", + model: "gpt-5.5", messages: [{ role: "user", content: "[REDACTED]" }], }, messages: null, @@ -312,7 +312,7 @@ describe("SessionManager detail snapshots", () => { "sess_empty_headers", "after", { - body: { model: "gpt-5.4" }, + body: { model: "gpt-5.5" }, headers: new Headers(), meta: { clientUrl: null, @@ -326,7 +326,7 @@ describe("SessionManager detail snapshots", () => { expect( await SessionManager.getSessionRequestPhaseSnapshot("sess_empty_headers", "after", 1) ).toEqual({ - body: { model: "gpt-5.4" }, + body: { model: "gpt-5.5" }, messages: null, headers: null, meta: { diff --git a/src/types/model-price.ts b/src/types/model-price.ts index 734897aac..da3c5dd4a 100644 --- a/src/types/model-price.ts +++ b/src/types/model-price.ts @@ -39,7 +39,7 @@ export interface ModelPriceData { output_cost_per_token_above_200k_tokens_priority?: number; cache_read_input_token_cost_above_200k_tokens_priority?: number; - // 272K 分层价格(GPT-5.4 等模型保留扩展) + // 272K 分层价格(GPT-5.5 等模型保留扩展) input_cost_per_token_above_272k_tokens?: number; output_cost_per_token_above_272k_tokens?: number; cache_creation_input_token_cost_above_272k_tokens?: number; @@ -70,7 +70,7 @@ export interface ModelPriceData { search_context_size_medium?: number; }; - // 长上下文价格(例如 GPT-5.4 超过 272K 后的 premium 费率) + // 长上下文价格(例如 GPT-5.5 超过 272K 后的 premium 费率) long_context_pricing?: LongContextPricing; // 模型能力信息 diff --git a/tests/api/v1/model-prices/model-prices.test.ts b/tests/api/v1/model-prices/model-prices.test.ts index 7284697ab..cfe15b04b 100644 --- a/tests/api/v1/model-prices/model-prices.test.ts +++ b/tests/api/v1/model-prices/model-prices.test.ts @@ -38,7 +38,7 @@ const adminSession = { const price = { id: 1, - modelName: "gpt-5.4", + modelName: "gpt-5.5", priceData: { mode: "chat", input_cost_per_token: 0.000001 }, source: "manual", createdAt: new Date("2026-04-28T00:00:00.000Z"), @@ -46,7 +46,7 @@ const price = { }; const updateResult = { - added: ["gpt-5.4"], + added: ["gpt-5.5"], updated: [], unchanged: [], failed: [], @@ -63,7 +63,7 @@ describe("v1 model price endpoints", () => { data: { data: [price], total: 1, page: 1, pageSize: 10, totalPages: 1 }, }); getAvailableModelCatalogMock.mockResolvedValue([ - { modelName: "gpt-5.4", litellmProvider: "openai", updatedAt: "2026-04-28T00:00:00.000Z" }, + { modelName: "gpt-5.5", litellmProvider: "openai", updatedAt: "2026-04-28T00:00:00.000Z" }, ]); hasPriceTableMock.mockResolvedValue(true); uploadPriceTableMock.mockResolvedValue({ ok: true, data: updateResult }); @@ -86,7 +86,7 @@ describe("v1 model price endpoints", () => { }); expect(list.response.status).toBe(200); expect(list.json).toMatchObject({ - items: [{ modelName: "gpt-5.4", updatedAt: "2026-04-28T00:00:00.000Z" }], + items: [{ modelName: "gpt-5.5", updatedAt: "2026-04-28T00:00:00.000Z" }], total: 1, }); expect(getModelPricesPaginatedMock).toHaveBeenCalledWith({ @@ -103,7 +103,7 @@ describe("v1 model price endpoints", () => { headers, }); expect(catalog.response.status).toBe(200); - expect(catalog.json).toMatchObject({ items: [{ modelName: "gpt-5.4" }] }); + expect(catalog.json).toMatchObject({ items: [{ modelName: "gpt-5.5" }] }); expect(getAvailableModelCatalogMock).toHaveBeenCalledWith({ scope: "all" }); const exists = await callV1Route({ @@ -121,10 +121,10 @@ describe("v1 model price endpoints", () => { method: "POST", pathname: "/api/v1/model-prices:upload", headers, - body: { content: "{}", overwriteManual: ["gpt-5.4"] }, + body: { content: "{}", overwriteManual: ["gpt-5.5"] }, }); expect(upload.response.status).toBe(200); - expect(uploadPriceTableMock).toHaveBeenCalledWith("{}", ["gpt-5.4"]); + expect(uploadPriceTableMock).toHaveBeenCalledWith("{}", ["gpt-5.5"]); const check = await callV1Route({ method: "POST", @@ -138,23 +138,23 @@ describe("v1 model price endpoints", () => { method: "POST", pathname: "/api/v1/model-prices:syncLitellm", headers, - body: { overwriteManual: ["gpt-5.4"] }, + body: { overwriteManual: ["gpt-5.5"] }, }); expect(sync.response.status).toBe(200); - expect(syncLiteLLMPricesMock).toHaveBeenCalledWith(["gpt-5.4"]); + expect(syncLiteLLMPricesMock).toHaveBeenCalledWith(["gpt-5.5"]); }); test("upserts deletes and pins one model price", async () => { const headers = { Authorization: "Bearer admin-token" }; const body = { - modelName: "gpt-5.4", + modelName: "gpt-5.5", mode: "chat", litellmProvider: "openai", inputCostPerToken: 0.000001, }; const upsert = await callV1Route({ method: "PUT", - pathname: "/api/v1/model-prices/gpt-5.4", + pathname: "/api/v1/model-prices/gpt-5.5", headers, body, }); @@ -163,31 +163,31 @@ describe("v1 model price endpoints", () => { const pin = await callV1Route({ method: "POST", - pathname: "/api/v1/model-prices/gpt-5.4/pricing:pinManual", + pathname: "/api/v1/model-prices/gpt-5.5/pricing:pinManual", headers, body: { pricingProviderKey: "openai" }, }); expect(pin.response.status).toBe(200); expect(pinModelPricingProviderAsManualMock).toHaveBeenCalledWith({ - modelName: "gpt-5.4", + modelName: "gpt-5.5", pricingProviderKey: "openai", }); const deleted = await callV1Route({ method: "DELETE", - pathname: "/api/v1/model-prices/gpt-5.4", + pathname: "/api/v1/model-prices/gpt-5.5", headers, }); expect(deleted.response.status).toBe(204); - expect(deleteSingleModelPriceMock).toHaveBeenCalledWith("gpt-5.4"); + expect(deleteSingleModelPriceMock).toHaveBeenCalledWith("gpt-5.5"); }); test("returns problem+json for invalid writes and action failures", async () => { const invalid = await callV1Route({ method: "PUT", - pathname: "/api/v1/model-prices/gpt-5.4", + pathname: "/api/v1/model-prices/gpt-5.5", headers: { Authorization: "Bearer admin-token" }, - body: { modelName: "gpt-5.4", mode: "chat", inputCostPerToken: -1 }, + body: { modelName: "gpt-5.5", mode: "chat", inputCostPerToken: -1 }, }); expect(invalid.response.status).toBe(400); expect(invalid.response.headers.get("content-type")).toContain("application/problem+json"); diff --git a/tests/api/v1/providers/providers.read.test.ts b/tests/api/v1/providers/providers.read.test.ts index ffe266796..07c5b43cb 100644 --- a/tests/api/v1/providers/providers.read.test.ts +++ b/tests/api/v1/providers/providers.read.test.ts @@ -241,13 +241,13 @@ describe("v1 providers read endpoints", () => { id: "cc_base", description: "Codex", defaultSuccessContains: "Hello", - defaultModel: "gpt-5.4", + defaultModel: "gpt-5.5", }, ], }); fetchUpstreamModelsMock.mockResolvedValue({ ok: true, - data: { models: ["gpt-5.4"], source: "upstream" }, + data: { models: ["gpt-5.5"], source: "upstream" }, }); getModelSuggestionsByProviderGroupMock.mockResolvedValue({ ok: true, diff --git a/tests/e2e/responses-ws-codex-cli-transport.test.ts b/tests/e2e/responses-ws-codex-cli-transport.test.ts index ba15323f6..384b46ee6 100644 --- a/tests/e2e/responses-ws-codex-cli-transport.test.ts +++ b/tests/e2e/responses-ws-codex-cli-transport.test.ts @@ -79,7 +79,7 @@ const run = shouldRunCodexE2e ? describe : describe.skip; const shouldRunFaultE2e = shouldRunCodexE2e && process.env.CCH_CODEX_E2E_FAULTS === "1"; const faultRun = shouldRunFaultE2e ? describe : describe.skip; const providerName = "local-cch-ws-e2e"; -const model = process.env.CCH_CODEX_E2E_MODEL || "gpt-5.4"; +const model = process.env.CCH_CODEX_E2E_MODEL || "gpt-5.5"; const responseText = "E2E_TRANSPORT_OK"; const defaultFeatures = "responses_websockets,responses_websockets_v2"; const requireFromHere = createRequire(import.meta.url); diff --git a/tests/integration/billing-model-source.test.ts b/tests/integration/billing-model-source.test.ts index 2088d55c1..1caab13b7 100644 --- a/tests/integration/billing-model-source.test.ts +++ b/tests/integration/billing-model-source.test.ts @@ -492,7 +492,7 @@ describe("Billing model source - Redis session cost vs DB cost", () => { expect(vi.mocked(SessionManager.updateSessionProvider)).not.toHaveBeenCalled(); }); - it("nested pricing: gpt-5.4 alias model should bill from pricing.openai when provider is chatgpt", async () => { + it("nested pricing: gpt-5.5 alias model should bill from pricing.openai when provider is chatgpt", async () => { vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); vi.mocked(updateMessageRequestDetails).mockResolvedValue(undefined); vi.mocked(updateMessageRequestDuration).mockResolvedValue(undefined); @@ -501,7 +501,7 @@ describe("Billing model source - Redis session cost vs DB cost", () => { vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { - if (modelName === "gpt-5.4") { + if (modelName === "gpt-5.5") { return makePriceRecord(modelName, { mode: "responses", model_family: "gpt", @@ -535,9 +535,9 @@ describe("Billing model source - Redis session cost vs DB cost", () => { ); const session = createSession({ - originalModel: "gpt-5.4", - redirectedModel: "gpt-5.4", - sessionId: "sess-gpt54-chatgpt", + originalModel: "gpt-5.5", + redirectedModel: "gpt-5.5", + sessionId: "sess-gpt55-chatgpt", messageId: 3100, providerOverrides: { name: "ChatGPT", @@ -563,7 +563,7 @@ describe("Billing model source - Redis session cost vs DB cost", () => { vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { - if (modelName === "gpt-5.4") { + if (modelName === "gpt-5.5") { return makePriceRecord(modelName, { mode: "responses", model_family: "gpt", @@ -599,9 +599,9 @@ describe("Billing model source - Redis session cost vs DB cost", () => { ); const session = createSession({ - originalModel: "gpt-5.4", - redirectedModel: "gpt-5.4", - sessionId: "sess-gpt54-priority-actual", + originalModel: "gpt-5.5", + redirectedModel: "gpt-5.5", + sessionId: "sess-gpt55-priority-actual", messageId: 3200, providerOverrides: { name: "ChatGPT", @@ -632,7 +632,7 @@ describe("Billing model source - Redis session cost vs DB cost", () => { vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { - if (modelName === "gpt-5.4") { + if (modelName === "gpt-5.5") { return makePriceRecord(modelName, { mode: "responses", model_family: "gpt", @@ -659,9 +659,9 @@ describe("Billing model source - Redis session cost vs DB cost", () => { const rateLimitCosts = captureRateLimitCosts(); const session = createSession({ - originalModel: "gpt-5.4", - redirectedModel: "gpt-5.4", - sessionId: "sess-gpt54-priority-requested", + originalModel: "gpt-5.5", + redirectedModel: "gpt-5.5", + sessionId: "sess-gpt55-priority-requested", messageId: 3201, providerOverrides: { name: "ChatGPT", @@ -688,7 +688,7 @@ describe("Billing model source - Redis session cost vs DB cost", () => { vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { - if (modelName === "gpt-5.4") { + if (modelName === "gpt-5.5") { return makePriceRecord(modelName, { mode: "responses", model_family: "gpt", @@ -728,9 +728,9 @@ describe("Billing model source - Redis session cost vs DB cost", () => { ); const session = createSession({ - originalModel: "gpt-5.4", - redirectedModel: "gpt-5.4", - sessionId: "sess-gpt54-priority-requested-long-context", + originalModel: "gpt-5.5", + redirectedModel: "gpt-5.5", + sessionId: "sess-gpt55-priority-requested-long-context", messageId: 3203, providerOverrides: { name: "ChatGPT", @@ -758,7 +758,7 @@ describe("Billing model source - Redis session cost vs DB cost", () => { vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { - if (modelName === "gpt-5.4") { + if (modelName === "gpt-5.5") { return makePriceRecord(modelName, { mode: "responses", model_family: "gpt", @@ -785,9 +785,9 @@ describe("Billing model source - Redis session cost vs DB cost", () => { const rateLimitCosts = captureRateLimitCosts(); const session = createSession({ - originalModel: "gpt-5.4", - redirectedModel: "gpt-5.4", - sessionId: "sess-gpt54-priority-downgraded", + originalModel: "gpt-5.5", + redirectedModel: "gpt-5.5", + sessionId: "sess-gpt55-priority-downgraded", messageId: 3202, providerOverrides: { name: "ChatGPT", @@ -817,7 +817,7 @@ describe("Billing model source - Redis session cost vs DB cost", () => { vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { - if (modelName === "gpt-5.4") { + if (modelName === "gpt-5.5") { return makePriceRecord(modelName, { mode: "responses", model_family: "gpt", @@ -844,9 +844,9 @@ describe("Billing model source - Redis session cost vs DB cost", () => { const rateLimitCosts = captureRateLimitCosts(); const session = createSession({ - originalModel: "gpt-5.4", - redirectedModel: "gpt-5.4", - sessionId: "sess-gpt54-priority-actual-mode-upgrade", + originalModel: "gpt-5.5", + redirectedModel: "gpt-5.5", + sessionId: "sess-gpt55-priority-actual-mode-upgrade", messageId: 3204, providerOverrides: { name: "ChatGPT", @@ -876,7 +876,7 @@ describe("Billing model source - Redis session cost vs DB cost", () => { vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { - if (modelName === "gpt-5.4") { + if (modelName === "gpt-5.5") { return makePriceRecord(modelName, { mode: "responses", model_family: "gpt", @@ -903,9 +903,9 @@ describe("Billing model source - Redis session cost vs DB cost", () => { const rateLimitCosts = captureRateLimitCosts(); const session = createSession({ - originalModel: "gpt-5.4", - redirectedModel: "gpt-5.4", - sessionId: "sess-gpt54-priority-actual-mode-downgrade", + originalModel: "gpt-5.5", + redirectedModel: "gpt-5.5", + sessionId: "sess-gpt55-priority-actual-mode-downgrade", messageId: 3205, providerOverrides: { name: "ChatGPT", @@ -935,7 +935,7 @@ describe("Billing model source - Redis session cost vs DB cost", () => { vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { - if (modelName === "gpt-5.4") { + if (modelName === "gpt-5.5") { return makePriceRecord(modelName, { mode: "responses", model_family: "gpt", @@ -962,9 +962,9 @@ describe("Billing model source - Redis session cost vs DB cost", () => { const rateLimitCosts = captureRateLimitCosts(); const session = createSession({ - originalModel: "gpt-5.4", - redirectedModel: "gpt-5.4", - sessionId: "sess-gpt54-priority-actual-mode-fallback", + originalModel: "gpt-5.5", + redirectedModel: "gpt-5.5", + sessionId: "sess-gpt55-priority-actual-mode-fallback", messageId: 3206, providerOverrides: { name: "ChatGPT", @@ -994,7 +994,7 @@ describe("Billing model source - Redis session cost vs DB cost", () => { vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { - if (modelName === "gpt-5.4") { + if (modelName === "gpt-5.5") { return makePriceRecord(modelName, { mode: "responses", model_family: "gpt", @@ -1021,9 +1021,9 @@ describe("Billing model source - Redis session cost vs DB cost", () => { const rateLimitCosts = captureRateLimitCosts(); const session = createSession({ - originalModel: "gpt-5.4", - redirectedModel: "gpt-5.4", - sessionId: "sess-gpt54-priority-actual-mode-cached-settings", + originalModel: "gpt-5.5", + redirectedModel: "gpt-5.5", + sessionId: "sess-gpt55-priority-actual-mode-cached-settings", messageId: 3207, providerOverrides: { name: "ChatGPT", diff --git a/tests/integration/non-chat-endpoint-fallback-observability.test.ts b/tests/integration/non-chat-endpoint-fallback-observability.test.ts index 70370b5df..6d0eb302c 100644 --- a/tests/integration/non-chat-endpoint-fallback-observability.test.ts +++ b/tests/integration/non-chat-endpoint-fallback-observability.test.ts @@ -164,7 +164,7 @@ run("non-chat endpoint fallback observability", () => { key: key.key, endpoint: "/v1/responses/compact", sessionId: `${KEY_PREFIX}-session-compact`, - model: "gpt-5.4", + model: "gpt-5.5", providerChain: [ { id: 21, name: "provider-c", reason: "retry_failed", statusCode: 500 }, { id: 22, name: "provider-d", reason: "retry_success", statusCode: 200 }, diff --git a/tests/unit/actions/active-sessions-detail-snapshots.test.ts b/tests/unit/actions/active-sessions-detail-snapshots.test.ts index cf02705f7..3075202ed 100644 --- a/tests/unit/actions/active-sessions-detail-snapshots.test.ts +++ b/tests/unit/actions/active-sessions-detail-snapshots.test.ts @@ -103,7 +103,7 @@ describe("getSessionDetails - additive detail snapshots contract", () => { findMessageRequestAuditBySessionIdAndSequenceMock.mockResolvedValue(null); getSessionRequestCountMock.mockResolvedValue(1); - getSessionRequestBodyMock.mockResolvedValue({ model: "gpt-5.4", input: "hi" }); + getSessionRequestBodyMock.mockResolvedValue({ model: "gpt-5.5", input: "hi" }); getSessionMessagesMock.mockResolvedValue([{ role: "user", content: "hi" }]); getSessionResponseMock.mockResolvedValue('{"ok":true}'); getSessionRequestHeadersMock.mockResolvedValue({ "content-type": "application/json" }); @@ -132,7 +132,7 @@ describe("getSessionDetails - additive detail snapshots contract", () => { expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.data.requestBody).toEqual({ model: "gpt-5.4", input: "hi" }); + expect(result.data.requestBody).toEqual({ model: "gpt-5.5", input: "hi" }); expect(result.data.messages).toEqual([{ role: "user", content: "hi" }]); expect(result.data.response).toBe('{"ok":true}'); expect(result.data.requestHeaders).toEqual({ "content-type": "application/json" }); @@ -152,7 +152,7 @@ describe("getSessionDetails - additive detail snapshots contract", () => { request: { before: null, after: { - body: { model: "gpt-5.4", input: "hi" }, + body: { model: "gpt-5.5", input: "hi" }, messages: [{ role: "user", content: "hi" }], headers: { "content-type": "application/json" }, meta: { @@ -179,7 +179,7 @@ describe("getSessionDetails - additive detail snapshots contract", () => { test("builds before-after snapshots from new snapshot getters", async () => { getSessionRequestPhaseSnapshotMock .mockResolvedValueOnce({ - body: { model: "gpt-5.4", messages: [{ role: "user", content: "before body" }] }, + body: { model: "gpt-5.5", messages: [{ role: "user", content: "before body" }] }, messages: [{ role: "user", content: "before messages" }], headers: { "x-before": "1" }, meta: { @@ -190,7 +190,7 @@ describe("getSessionDetails - additive detail snapshots contract", () => { }) .mockResolvedValueOnce({ body: JSON.stringify({ - model: "gpt-5.4", + model: "gpt-5.5", messages: [{ role: "user", content: "after body messages" }], }), messages: null, @@ -229,7 +229,7 @@ describe("getSessionDetails - additive detail snapshots contract", () => { defaultView: DEFAULT_SESSION_DETAIL_VIEW_MODE, request: { before: { - body: { model: "gpt-5.4", messages: [{ role: "user", content: "before body" }] }, + body: { model: "gpt-5.5", messages: [{ role: "user", content: "before body" }] }, messages: [{ role: "user", content: "before messages" }], headers: { "x-before": "1" }, meta: { @@ -240,7 +240,7 @@ describe("getSessionDetails - additive detail snapshots contract", () => { }, after: { body: { - model: "gpt-5.4", + model: "gpt-5.5", messages: [{ role: "user", content: "after body messages" }], }, messages: [{ role: "user", content: "after body messages" }], @@ -276,7 +276,7 @@ describe("getSessionDetails - additive detail snapshots contract", () => { test("returns null after request messages when processed body has no messages field", async () => { getSessionRequestPhaseSnapshotMock.mockResolvedValueOnce(null).mockResolvedValueOnce({ body: JSON.stringify({ - model: "gpt-5.4", + model: "gpt-5.5", input: [{ role: "user", content: "no messages field here" }], }), messages: null, @@ -297,7 +297,7 @@ describe("getSessionDetails - additive detail snapshots contract", () => { expect(result.data.snapshots.request.after).toEqual({ body: { - model: "gpt-5.4", + model: "gpt-5.5", input: [{ role: "user", content: "no messages field here" }], }, messages: null, @@ -314,7 +314,7 @@ describe("getSessionDetails - additive detail snapshots contract", () => { getSessionRequestCountMock.mockResolvedValue(3); findAdjacentRequestSequencesMock.mockResolvedValue({ prevSequence: 2, nextSequence: null }); getSessionRequestPhaseSnapshotMock.mockResolvedValueOnce(null).mockResolvedValueOnce({ - body: JSON.stringify({ model: "gpt-5.4", messages: [] }), + body: JSON.stringify({ model: "gpt-5.5", messages: [] }), messages: null, headers: { "x-after": "3" }, meta: { diff --git a/tests/unit/actions/model-prices.test.ts b/tests/unit/actions/model-prices.test.ts index 9bc3f7a54..c99973e63 100644 --- a/tests/unit/actions/model-prices.test.ts +++ b/tests/unit/actions/model-prices.test.ts @@ -136,7 +136,7 @@ describe("Model Price Actions", () => { describe("upsertSingleModelPrice", () => { it("should create a new model price for admin", async () => { - const mockResult = makeMockPrice("gpt-5.4", { + const mockResult = makeMockPrice("gpt-5.5", { mode: "chat", input_cost_per_token: 0.000015, output_cost_per_token: 0.00006, @@ -145,7 +145,7 @@ describe("Model Price Actions", () => { const { upsertSingleModelPrice } = await import("@/actions/model-prices"); const result = await upsertSingleModelPrice({ - modelName: "gpt-5.4", + modelName: "gpt-5.5", mode: "chat", litellmProvider: "openai", inputCostPerToken: 0.000015, @@ -153,9 +153,9 @@ describe("Model Price Actions", () => { }); expect(result.ok).toBe(true); - expect(result.data?.modelName).toBe("gpt-5.4"); + expect(result.data?.modelName).toBe("gpt-5.5"); expect(upsertModelPriceMock).toHaveBeenCalledWith( - "gpt-5.4", + "gpt-5.5", expect.objectContaining({ mode: "chat", litellm_provider: "openai", @@ -309,10 +309,10 @@ describe("Model Price Actions", () => { deleteModelPriceByNameMock.mockResolvedValue(undefined); const { deleteSingleModelPrice } = await import("@/actions/model-prices"); - const result = await deleteSingleModelPrice("gpt-5.4"); + const result = await deleteSingleModelPrice("gpt-5.5"); expect(result.ok).toBe(true); - expect(deleteModelPriceByNameMock).toHaveBeenCalledWith("gpt-5.4"); + expect(deleteModelPriceByNameMock).toHaveBeenCalledWith("gpt-5.5"); }); it("should reject empty model name", async () => { @@ -614,10 +614,10 @@ describe("Model Price Actions", () => { it("should pin a cloud provider pricing node as a local manual model price", async () => { findLatestPriceByModelAndSourceMock.mockResolvedValue( makeMockPrice( - "gpt-5.4", + "gpt-5.5", { mode: "responses", - display_name: "GPT-5.4", + display_name: "GPT-5.5", model_family: "gpt", pricing: { openrouter: { @@ -632,7 +632,7 @@ describe("Model Price Actions", () => { ); upsertModelPriceMock.mockResolvedValue( makeMockPrice( - "gpt-5.4", + "gpt-5.5", { mode: "responses", input_cost_per_token: 0.0000025, @@ -646,21 +646,21 @@ describe("Model Price Actions", () => { const { pinModelPricingProviderAsManual } = await import("@/actions/model-prices"); const result = await pinModelPricingProviderAsManual({ - modelName: "gpt-5.4", + modelName: "gpt-5.5", pricingProviderKey: "openrouter", }); expect(result.ok).toBe(true); - expect(findLatestPriceByModelAndSourceMock).toHaveBeenCalledWith("gpt-5.4", "litellm"); + expect(findLatestPriceByModelAndSourceMock).toHaveBeenCalledWith("gpt-5.5", "litellm"); expect(upsertModelPriceMock).toHaveBeenCalledWith( - "gpt-5.4", + "gpt-5.5", expect.objectContaining({ mode: "responses", input_cost_per_token: 0.0000025, output_cost_per_token: 0.000015, cache_read_input_token_cost: 2.5e-7, selected_pricing_provider: "openrouter", - selected_pricing_source_model: "gpt-5.4", + selected_pricing_source_model: "gpt-5.5", selected_pricing_resolution: "manual_pin", }) ); diff --git a/tests/unit/codex/session-completer.test.ts b/tests/unit/codex/session-completer.test.ts index e82c110b3..564487289 100644 --- a/tests/unit/codex/session-completer.test.ts +++ b/tests/unit/codex/session-completer.test.ts @@ -22,7 +22,7 @@ vi.mock("@/lib/redis", () => ({ function makeCodexRequestBody(overrides?: Record): Record { return { - model: "gpt-5.4", + model: "gpt-5.5", input: [ { type: "message", diff --git a/tests/unit/lib/utils/pricing-resolution.test.ts b/tests/unit/lib/utils/pricing-resolution.test.ts index d9196449e..aa4ec1753 100644 --- a/tests/unit/lib/utils/pricing-resolution.test.ts +++ b/tests/unit/lib/utils/pricing-resolution.test.ts @@ -19,8 +19,8 @@ function makeRecord( } describe("resolvePricingForModelRecords", () => { - test("falls back from chatgpt to openai pricing for gpt-5.4 alias models", () => { - const aliasRecord = makeRecord("gpt-5.4", { + test("falls back from chatgpt to openai pricing for gpt-5.5 alias models", () => { + const aliasRecord = makeRecord("gpt-5.5", { mode: "responses", model_family: "gpt", litellm_provider: "chatgpt", @@ -44,7 +44,7 @@ describe("resolvePricingForModelRecords", () => { name: "ChatGPT", url: "https://chatgpt.com/backend-api/codex", } as never, - primaryModelName: "gpt-5.4", + primaryModelName: "gpt-5.5", fallbackModelName: null, primaryRecord: aliasRecord, fallbackRecord: null, @@ -57,7 +57,7 @@ describe("resolvePricingForModelRecords", () => { }); test("falls back from redirected date model to alias model for provider-specific pricing", () => { - const datedRecord = makeRecord("gpt-5.4-2026-03-05", { + const datedRecord = makeRecord("gpt-5.5-2026-06-02", { mode: "responses", model_family: "gpt", litellm_provider: "openai", @@ -73,7 +73,7 @@ describe("resolvePricingForModelRecords", () => { }, }); - const aliasRecord = makeRecord("gpt-5.4", { + const aliasRecord = makeRecord("gpt-5.5", { mode: "responses", model_family: "gpt", litellm_provider: "chatgpt", @@ -92,21 +92,21 @@ describe("resolvePricingForModelRecords", () => { name: "OpenRouter", url: "https://openrouter.ai/api/v1", } as never, - primaryModelName: "gpt-5.4-2026-03-05", - fallbackModelName: "gpt-5.4", + primaryModelName: "gpt-5.5-2026-06-02", + fallbackModelName: "gpt-5.5", primaryRecord: datedRecord, fallbackRecord: aliasRecord, }); expect(resolved).not.toBeNull(); - expect(resolved?.resolvedModelName).toBe("gpt-5.4"); + expect(resolved?.resolvedModelName).toBe("gpt-5.5"); expect(resolved?.resolvedPricingProviderKey).toBe("openrouter"); expect(resolved?.source).toBe("cloud_model_fallback"); }); test("prefers local manual prices over cloud multi-provider pricing", () => { const manualRecord = makeRecord( - "gpt-5.4", + "gpt-5.5", { mode: "responses", input_cost_per_token: 0.0000099, @@ -116,7 +116,7 @@ describe("resolvePricingForModelRecords", () => { "manual" ); - const cloudRecord = makeRecord("gpt-5.4", { + const cloudRecord = makeRecord("gpt-5.5", { mode: "responses", pricing: { openai: { @@ -132,7 +132,7 @@ describe("resolvePricingForModelRecords", () => { name: "ChatGPT", url: "https://chatgpt.com/backend-api/codex", } as never, - primaryModelName: "gpt-5.4", + primaryModelName: "gpt-5.5", fallbackModelName: null, primaryRecord: manualRecord, fallbackRecord: cloudRecord, @@ -184,7 +184,7 @@ describe("resolvePricingForModelRecords", () => { }); test("provider merge keeps shared top-level request fees and long_context_pricing", () => { - const cloudRecord = makeRecord("gpt-5.4", { + const cloudRecord = makeRecord("gpt-5.5", { mode: "responses", model_family: "gpt", litellm_provider: "azure", @@ -211,7 +211,7 @@ describe("resolvePricingForModelRecords", () => { name: "OpenAI", url: "https://api.openai.com/v1/responses", } as never, - primaryModelName: "gpt-5.4", + primaryModelName: "gpt-5.5", fallbackModelName: null, primaryRecord: cloudRecord, fallbackRecord: null, diff --git a/tests/unit/proxy/actual-response-model.test.ts b/tests/unit/proxy/actual-response-model.test.ts index c3625bc7d..442401d71 100644 --- a/tests/unit/proxy/actual-response-model.test.ts +++ b/tests/unit/proxy/actual-response-model.test.ts @@ -45,10 +45,10 @@ describe("extractActualResponseModel - 8 happy-path cases", () => { id: "chatcmpl-abc", object: "chat.completion", created: 1710000000, - model: "gpt-5.4", + model: "gpt-5.5", choices: [{ index: 0, message: { role: "assistant", content: "Hi" }, finish_reason: "stop" }], }); - expect(extractActualResponseModel("openai-chat/non-stream", body)).toBe("gpt-5.4"); + expect(extractActualResponseModel("openai-chat/non-stream", body)).toBe("gpt-5.5"); }); it("openai-chat/stream: reads first chunk $.model", () => { diff --git a/tests/unit/proxy/codex-provider-overrides.test.ts b/tests/unit/proxy/codex-provider-overrides.test.ts index 646e16057..27b4cda53 100644 --- a/tests/unit/proxy/codex-provider-overrides.test.ts +++ b/tests/unit/proxy/codex-provider-overrides.test.ts @@ -13,7 +13,7 @@ describe("Codex 供应商级参数覆写", () => { }; const input: Record = { - model: "gpt-5.4", + model: "gpt-5.5", input: [], parallel_tool_calls: true, reasoning: { effort: "low", summary: "auto" }, @@ -34,7 +34,7 @@ describe("Codex 供应商级参数覆写", () => { }; const input: Record = { - model: "gpt-5.4", + model: "gpt-5.5", input: [], parallel_tool_calls: false, reasoning: { effort: "low", summary: "auto" }, @@ -58,7 +58,7 @@ describe("Codex 供应商级参数覆写", () => { }; const input: Record = { - model: "gpt-5.4", + model: "gpt-5.5", input: [], parallel_tool_calls: false, reasoning: { effort: "low", summary: "auto" }, @@ -79,7 +79,7 @@ describe("Codex 供应商级参数覆写", () => { }; const input: Record = { - model: "gpt-5.4", + model: "gpt-5.5", input: [], parallel_tool_calls: true, }; @@ -97,7 +97,7 @@ describe("Codex 供应商级参数覆写", () => { }; const input: Record = { - model: "gpt-5.4", + model: "gpt-5.5", input: [], reasoning: { effort: "low", summary: "auto", extra: "keep" }, }; @@ -116,7 +116,7 @@ describe("Codex 供应商级参数覆写", () => { }; const input: Record = { - model: "gpt-5.4", + model: "gpt-5.5", input: [], }; @@ -132,7 +132,7 @@ describe("Codex 供应商级参数覆写", () => { }; const input: Record = { - model: "gpt-5.4", + model: "gpt-5.5", input: [], service_tier: "default", }; @@ -152,7 +152,7 @@ describe("Codex 供应商级参数覆写", () => { }; const input: Record = { - model: "gpt-5.4", + model: "gpt-5.5", input: [], parallel_tool_calls: true, }; @@ -172,7 +172,7 @@ describe("Codex 供应商级参数覆写", () => { }; const input: Record = { - model: "gpt-5.4", + model: "gpt-5.5", input: [], parallel_tool_calls: false, reasoning: { effort: "low", summary: "auto" }, @@ -193,7 +193,7 @@ describe("Codex 供应商级参数覆写", () => { }; const input: Record = { - model: "gpt-5.4", + model: "gpt-5.5", input: [], parallel_tool_calls: false, }; @@ -226,7 +226,7 @@ describe("Codex 供应商级参数覆写", () => { }; const input: Record = { - model: "gpt-5.4", + model: "gpt-5.5", input: [], parallel_tool_calls: false, reasoning: { effort: "low", summary: "auto" }, @@ -260,7 +260,7 @@ describe("Codex 供应商级参数覆写", () => { }; const input: Record = { - model: "gpt-5.4", + model: "gpt-5.5", input: [], service_tier: "priority", }; diff --git a/tests/unit/proxy/non-chat-endpoint-fallback.test.ts b/tests/unit/proxy/non-chat-endpoint-fallback.test.ts index 4f0f3f00b..9ad737f3c 100644 --- a/tests/unit/proxy/non-chat-endpoint-fallback.test.ts +++ b/tests/unit/proxy/non-chat-endpoint-fallback.test.ts @@ -303,7 +303,7 @@ describe("non-chat endpoint fallback", () => { session.originalFormat = "response"; session.setRawCrossProviderFallbackEnabled(false); session.request.message = { - model: "gpt-5.4", + model: "gpt-5.5", input: [{ role: "user", content: "compact me" }], }; session.setProvider(providerA); @@ -329,7 +329,7 @@ describe("non-chat endpoint fallback", () => { session.originalFormat = "response"; session.setRawCrossProviderFallbackEnabled(false); session.request.message = { - model: "gpt-5.4", + model: "gpt-5.5", input: [{ role: "user", content: "compact me" }], }; session.setProvider(providerA); @@ -356,7 +356,7 @@ describe("non-chat endpoint fallback", () => { session.originalFormat = "response"; session.setRawCrossProviderFallbackEnabled(false); session.request.message = { - model: "gpt-5.4", + model: "gpt-5.5", input: [{ role: "user", content: "compact me" }], }; session.setProvider(providerA); @@ -383,7 +383,7 @@ describe("non-chat endpoint fallback", () => { session.originalFormat = "response"; session.setRawCrossProviderFallbackEnabled(false); session.request.message = { - model: "gpt-5.4", + model: "gpt-5.5", input: [{ role: "user", content: "compact me" }], }; session.setProvider(providerA); diff --git a/tests/unit/proxy/non-chat-endpoint-session-context.test.ts b/tests/unit/proxy/non-chat-endpoint-session-context.test.ts index f2ffb9d6f..6cdce0b0e 100644 --- a/tests/unit/proxy/non-chat-endpoint-session-context.test.ts +++ b/tests/unit/proxy/non-chat-endpoint-session-context.test.ts @@ -348,7 +348,7 @@ describe("non-chat endpoint session context", () => { const rawCompactSession = createProxySession(V1_ENDPOINT_PATHS.RESPONSES_COMPACT); rawCompactSession.originalFormat = "response"; rawCompactSession.request.message = { - model: "gpt-5.4", + model: "gpt-5.5", input: [{ role: "user", content: "compact me" }], }; rawCompactSession.sessionId = "sess_compact"; @@ -391,7 +391,7 @@ describe("non-chat endpoint session context", () => { const compactSession = createProxySession(V1_ENDPOINT_PATHS.RESPONSES_COMPACT); compactSession.originalFormat = "response"; compactSession.request.message = { - model: "gpt-5.4", + model: "gpt-5.5", input: [{ role: "user", content: "compact me" }], }; const compactBefore = structuredClone(compactSession.request.message); diff --git a/tests/unit/proxy/proxy-forwarder-large-chunked-response.test.ts b/tests/unit/proxy/proxy-forwarder-large-chunked-response.test.ts index 2ec83c17d..baf66fd6b 100644 --- a/tests/unit/proxy/proxy-forwarder-large-chunked-response.test.ts +++ b/tests/unit/proxy/proxy-forwarder-large-chunked-response.test.ts @@ -103,10 +103,10 @@ function createSession(params?: { clientAbortSignal?: AbortSignal | null }): Pro originalHeaders: new Headers(headers), headerLog: JSON.stringify(Object.fromEntries(headers.entries())), request: { - model: "gpt-5.4", + model: "gpt-5.5", log: "(test)", message: { - model: "gpt-5.4", + model: "gpt-5.5", messages: [{ role: "user", content: "hi" }], }, }, diff --git a/tests/unit/proxy/proxy-forwarder-nonok-body-hang.test.ts b/tests/unit/proxy/proxy-forwarder-nonok-body-hang.test.ts index 01be0b640..fc92ca793 100644 --- a/tests/unit/proxy/proxy-forwarder-nonok-body-hang.test.ts +++ b/tests/unit/proxy/proxy-forwarder-nonok-body-hang.test.ts @@ -103,10 +103,10 @@ function createSession(params?: { clientAbortSignal?: AbortSignal | null }): Pro originalHeaders: new Headers(headers), headerLog: JSON.stringify(Object.fromEntries(headers.entries())), request: { - model: "gpt-5.4", + model: "gpt-5.5", log: "(test)", message: { - model: "gpt-5.4", + model: "gpt-5.5", messages: [{ role: "user", content: "hi" }], }, }, diff --git a/tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts b/tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts index 8bb55808b..cf331202f 100644 --- a/tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts +++ b/tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts @@ -65,7 +65,7 @@ function createRawPassthroughSession(bodyText: string, extraHeaders?: HeadersIni originalHeaders, headerLog: JSON.stringify(Object.fromEntries(headers.entries())), request: { - model: "gpt-5.4", + model: "gpt-5.5", log: bodyText, message: JSON.parse(bodyText) as Record, buffer: new TextEncoder().encode(bodyText).buffer, @@ -92,7 +92,7 @@ function createRawPassthroughSession(bodyText: string, extraHeaders?: HeadersIni endpointPolicy: resolveEndpointPolicy("/v1/responses/compact"), setCacheTtlResolved: vi.fn(), getCacheTtlResolved: vi.fn(() => null), - getCurrentModel: vi.fn(() => "gpt-5.4"), + getCurrentModel: vi.fn(() => "gpt-5.5"), clientRequestsContext1m: vi.fn(() => false), setContext1mApplied: vi.fn(), getContext1mApplied: vi.fn(() => false), @@ -126,7 +126,7 @@ describe("ProxyForwarder raw passthrough regression", () => { }); it("raw passthrough 应优先保留原始请求体字节,而不是重新 JSON.stringify", async () => { - const originalBody = '{\n "model": "gpt-5.4",\n "input": [1, 2, 3]\n}\n'; + const originalBody = '{\n "model": "gpt-5.5",\n "input": [1, 2, 3]\n}\n'; const session = createRawPassthroughSession(originalBody); const provider = createProvider(); @@ -150,7 +150,7 @@ describe("ProxyForwarder raw passthrough regression", () => { }); it("raw passthrough 出站请求不得继续携带 transfer-encoding 这类 hop-by-hop 头", async () => { - const body = '{"model":"gpt-5.4","input":[]}'; + const body = '{"model":"gpt-5.5","input":[]}'; const session = createRawPassthroughSession(body, { connection: "keep-alive", "transfer-encoding": "chunked", diff --git a/tests/unit/proxy/response-handler-abort-listener-cleanup.test.ts b/tests/unit/proxy/response-handler-abort-listener-cleanup.test.ts index c5dda43b9..350c33d06 100644 --- a/tests/unit/proxy/response-handler-abort-listener-cleanup.test.ts +++ b/tests/unit/proxy/response-handler-abort-listener-cleanup.test.ts @@ -130,10 +130,10 @@ function makeSession(clientAbortSignal: AbortSignal | null, stream: boolean): Pr const provider = makeProvider(); const session = { request: { - model: "gpt-5.4", + model: "gpt-5.5", log: "", message: { - model: "gpt-5.4", + model: "gpt-5.5", stream, messages: [{ role: "user", content: "hello" }], }, @@ -167,7 +167,7 @@ function makeSession(clientAbortSignal: AbortSignal | null, stream: boolean): Pr requestSequence: 1, originalFormat: "openai", providerType: "openai", - originalModelName: "gpt-5.4", + originalModelName: "gpt-5.5", originalUrlPathname: "/v1/chat/completions", providerChain: [], cacheTtlResolved: null, @@ -180,8 +180,8 @@ function makeSession(clientAbortSignal: AbortSignal | null, stream: boolean): Pr getEndpointPolicy: () => endpointPolicy, getContext1mApplied: () => false, getGroupCostMultiplier: () => 1, - getOriginalModel: () => "gpt-5.4", - getCurrentModel: () => "gpt-5.4", + getOriginalModel: () => "gpt-5.5", + getCurrentModel: () => "gpt-5.5", getProviderChain: () => [], getSpecialSettings: () => [], shouldPersistSessionDebugArtifacts: () => false, diff --git a/tests/unit/server-ws-close-handshake.test.ts b/tests/unit/server-ws-close-handshake.test.ts index 3462c9bfe..806b18824 100644 --- a/tests/unit/server-ws-close-handshake.test.ts +++ b/tests/unit/server-ws-close-handshake.test.ts @@ -287,7 +287,7 @@ describe("server.js WebSocket close-handshake (issue #1150)", () => { const client = connectClient(harness.port); await client.opened; - client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.4", input: "hi" })); + client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.5", input: "hi" })); await waitForMessageCount( client.messages, @@ -320,7 +320,7 @@ describe("server.js WebSocket close-handshake (issue #1150)", () => { const client = connectClient(harness.port); await client.opened; - client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.4", input: "hi" })); + client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.5", input: "hi" })); const close = await client.closeEvent; expect(close.code).toBe(1011); @@ -348,7 +348,7 @@ describe("server.js WebSocket close-handshake (issue #1150)", () => { const client = connectClient(harness.port); await client.opened; - client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.4", input: "hi" })); + client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.5", input: "hi" })); const close = await client.closeEvent; expect(close.code).toBe(1011); @@ -373,7 +373,7 @@ describe("server.js WebSocket close-handshake (issue #1150)", () => { const client = connectClient(harness.port); await client.opened; - client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.4", input: "hi" })); + client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.5", input: "hi" })); await waitForMessageCount(client.messages, 1, 3000, "HTTP error was not forwarded"); expect(client.ws.readyState).toBe(WebSocket.OPEN); @@ -403,7 +403,7 @@ describe("server.js WebSocket close-handshake (issue #1150)", () => { const client = connectClient(harness.port); await client.opened; - client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.4", input: "hi" })); + client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.5", input: "hi" })); await waitForMessageCount(client.messages, 1, 3000, "terminal error was not forwarded"); expect(client.ws.readyState).toBe(WebSocket.OPEN); @@ -431,7 +431,7 @@ describe("server.js WebSocket close-handshake (issue #1150)", () => { // caused tungstenite to surface "Connection reset without closing // handshake". const bigInput = "x".repeat(4 * 1024 * 1024); - client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.4", input: bigInput })); + client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.5", input: bigInput })); await waitForMessageCount(client.messages, 1, 3000, "large response was not forwarded"); expect(client.ws.readyState).toBe(WebSocket.OPEN); @@ -466,8 +466,8 @@ describe("server.js WebSocket close-handshake (issue #1150)", () => { // Pipeline two frames before the first response completes. A compliant // Responses WS bridge keeps the client socket open and drains them // sequentially. - client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.4", input: "first" })); - client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.4", input: "second" })); + client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.5", input: "first" })); + client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.5", input: "second" })); await waitForMessageCount(client.messages, 2, 3000, "both queued responses were not forwarded"); expect(client.ws.readyState).toBe(WebSocket.OPEN); @@ -503,7 +503,7 @@ describe("server.js WebSocket close-handshake (issue #1150)", () => { await client.opened; const queuedFrameObserved = serverConnection.waitForMessageCount(2); client.ws.send(Buffer.from("not a text frame"), { binary: true }); - client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.4", input: "queued" })); + client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.5", input: "queued" })); const close = await client.closeEvent; expect(close.code).toBe(1003); @@ -556,7 +556,7 @@ describe("server.js WebSocket close-handshake (issue #1150)", () => { "server WebSocket did not accept the overflow test connection" ); await client.opened; - client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.4", input: "first" })); + client.ws.send(JSON.stringify({ type: "response.create", model: "gpt-5.5", input: "first" })); await withTimeout( firstRequestStarted.promise, 3000, @@ -564,7 +564,7 @@ describe("server.js WebSocket close-handshake (issue #1150)", () => { ); for (let i = 0; i < 70; i += 1) { client.ws.send( - JSON.stringify({ type: "response.create", model: "gpt-5.4", input: `queued-${i}` }) + JSON.stringify({ type: "response.create", model: "gpt-5.5", input: `queued-${i}` }) ); } diff --git a/tests/unit/settings/prices/price-list-multi-provider-ui.test.tsx b/tests/unit/settings/prices/price-list-multi-provider-ui.test.tsx index 7c92a1bd6..dc4efcc12 100644 --- a/tests/unit/settings/prices/price-list-multi-provider-ui.test.tsx +++ b/tests/unit/settings/prices/price-list-multi-provider-ui.test.tsx @@ -36,10 +36,10 @@ describe("PriceList multi-provider pricing", () => { const prices: ModelPrice[] = [ { id: 1, - modelName: "gpt-5.4", + modelName: "gpt-5.5", priceData: { mode: "responses", - display_name: "GPT-5.4", + display_name: "GPT-5.5", model_family: "gpt", litellm_provider: "chatgpt", pricing: { diff --git a/tests/unit/settings/providers/api-test-button.test.tsx b/tests/unit/settings/providers/api-test-button.test.tsx index 4920185a7..96756f600 100644 --- a/tests/unit/settings/providers/api-test-button.test.tsx +++ b/tests/unit/settings/providers/api-test-button.test.tsx @@ -90,7 +90,7 @@ describe("ApiTestButton", () => { id: "cx_base", description: "legacy preset", defaultSuccessContains: "pong", - defaultModel: "gpt-5.4", + defaultModel: "gpt-5.5", }, ], }); diff --git a/tests/unit/usage-doc/opencode-usage-doc.test.tsx b/tests/unit/usage-doc/opencode-usage-doc.test.tsx index ebff173ff..0b4c35079 100644 --- a/tests/unit/usage-doc/opencode-usage-doc.test.tsx +++ b/tests/unit/usage-doc/opencode-usage-doc.test.tsx @@ -77,11 +77,11 @@ describe("UsageDoc - OpenCode 配置教程", () => { expect(text).toContain("claude-sonnet-4-5-20250929"); expect(text).toContain("claude-opus-4-5-20251101"); - expect(text).toContain('"model": "openai/gpt-5.4"'); - expect(text).toContain('"small_model": "openai/gpt-5.4-small"'); + expect(text).toContain('"model": "openai/gpt-5.5"'); + expect(text).toContain('"small_model": "openai/gpt-5.5-small"'); - expect(text).toContain("gpt-5.4"); - expect(text).toContain("gpt-5.4-small"); + expect(text).toContain("gpt-5.5"); + expect(text).toContain("gpt-5.5-small"); expect(text).toContain('"reasoningEffort": "xhigh"'); expect(text).toContain('"reasoningEffort": "medium"'); expect(text).toContain('"store": false'); From d0fe34df2023d054063d3223c22cfc9e48ad4fbd Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Thu, 28 May 2026 19:46:02 +0800 Subject: [PATCH 2/5] feat(anthropic): detect actual model from thinking signature in stream (#1223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(anthropic): detect model from thinking signature in stream Use the thinking signature protobuf embedded in Anthropic stream content_block_delta events to extract the actual model name. This is more accurate than the message_start plain text when thinking is on. Three-state detection: - signature found → use model from signature - no signature with thinking on → fall back to plain model, warn via UI - no signature without thinking → plain model (normal) Add a UI badge and i18n labels for five locales when thinking was enabled but no signature arrived. * fix(proxy): drop claude- prefix gate and validate thinking signature model Remove the requestedModel prefix check so the detection works with model-redirect, Bedrock, and Vertex providers. Validate signature-decoded model names for length (≤128 chars) and presence of "claude" to reject spoofed payloads. Also fix several related issues: - Accept both standard and URL-safe base64 alphabets, and correct the length validation to only reject remainder 1. - Show the no-signature badge in the UI even when no model field is present. - Return fallback_no_thinking for empty streams to avoid false no-signature alerts. - Record the truthful thinkingEnabled value in audit logs instead of a source-derived heuristic. - Fix SSE chunk joining in tests to ensure proper event boundary blank lines. - Correct full-width punctuation in zh-CN and zh-TW tooltip strings. * fix(proxy): strip base64 padding and use @/ imports - Strip trailing padding from base64 input before checking invalid length, so that padded strings like "xxxxx=" are correctly rejected. - Convert relative imports to @/ path aliases for consistency. - Update test fixture to encode protobuf length fields with varint. * fix(proxy): skip thinking-signature detection for non-Anthropic models Restores a guard on the requested model family (claude- or anthropic/ prefix) so that providers using the Anthropic-compatible API path but serving non-Anthropic models (e.g. GLM) are not incorrectly classified. Without this gate, those providers could produce a fallback_no_signature_with_thinking source even though they never emit thinking signatures. Drops the requirement that the signature-decoded model name contain "claude", leaving only the length check (<= 128 chars). This allows future Anthropic model families that may drop the "claude-" prefix. Fixes import order in response-handler.ts after lint:fix. --- messages/en/dashboard.json | 4 +- messages/ja/dashboard.json | 4 +- messages/ru/dashboard.json | 4 +- messages/zh-CN/dashboard.json | 4 +- messages/zh-TW/dashboard.json | 4 +- .../components/SummaryTab.tsx | 26 +- .../v1/_lib/proxy/actual-response-model.ts | 6 +- .../proxy/anthropic-actual-response-model.ts | 125 +++++++ src/app/v1/_lib/proxy/response-handler.ts | 37 ++- .../v1/_lib/proxy/thinking-signature-model.ts | 220 +++++++++++++ src/lib/utils/special-settings.ts | 26 ++ src/types/special-settings.ts | 30 +- .../anthropic-actual-response-model.test.ts | 305 ++++++++++++++++++ .../proxy/thinking-signature-model.test.ts | 233 +++++++++++++ 14 files changed, 1013 insertions(+), 15 deletions(-) create mode 100644 src/app/v1/_lib/proxy/anthropic-actual-response-model.ts create mode 100644 src/app/v1/_lib/proxy/thinking-signature-model.ts create mode 100644 tests/unit/proxy/anthropic-actual-response-model.test.ts create mode 100644 tests/unit/proxy/thinking-signature-model.test.ts diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index eddce290d..48fee963a 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -253,7 +253,9 @@ "responseModelLabel": "Actual Response Model", "mismatchTooltip": "The upstream provider returned a different model than the one requested. Billing is still based on the requested model.", "secondaryLineAriaLabel": "Actual response model: {model}", - "arrowPrefix": "\u21b3" + "arrowPrefix": "\u21b3", + "noSignatureBadge": "No thinking signature", + "noSignatureTooltip": "Thinking was enabled but no thinking signature was returned in the stream; the actual response model falls back to the plain model field from message_start." }, "errorMessage": "Error Message", "fake200ForwardedNotice": "Note: For streaming requests, this failure may be detected only after the stream ends; the response content may already have been forwarded to the client.", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 6fec7c32a..74a9ba3c8 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -253,7 +253,9 @@ "responseModelLabel": "実際の応答モデル", "mismatchTooltip": "上流プロバイダが返したモデルがリクエストされたモデルと異なります。課金は引き続きリクエストモデルを基準とします。", "secondaryLineAriaLabel": "実際の応答モデル: {model}", - "arrowPrefix": "\u21b3" + "arrowPrefix": "↳", + "noSignatureBadge": "思考署名なし", + "noSignatureTooltip": "思考が有効ですが、応答ストリームに thinking signature が含まれていないため、実際の応答モデルは message_start の平文モデル名にフォールバックしました。" }, "errorMessage": "エラーメッセージ", "fake200ForwardedNotice": "注意:ストリーミング要求では、失敗判定がストリーム終了後になる場合があります。応答内容は既にクライアントへ転送されている可能性があります。", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index a8da0de62..680a6d01e 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -253,7 +253,9 @@ "responseModelLabel": "Фактическая модель ответа", "mismatchTooltip": "Провайдер вернул модель, отличную от запрошенной. Тарификация по-прежнему ведётся по запрошенной модели.", "secondaryLineAriaLabel": "Фактическая модель ответа: {model}", - "arrowPrefix": "\u21b3" + "arrowPrefix": "↳", + "noSignatureBadge": "Нет подписи мышления", + "noSignatureTooltip": "Мышление было включено, но в потоке ответа не была возвращена подпись мышления; фактическая модель ответа берётся из обычного поля model в message_start." }, "errorMessage": "Сообщение об ошибке", "fake200ForwardedNotice": "Примечание: для потоковых запросов эта ошибка может быть обнаружена только после завершения потока; содержимое ответа могло уже быть передано клиенту.", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 245a15335..7a173ce43 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -253,7 +253,9 @@ "responseModelLabel": "实际响应模型", "mismatchTooltip": "上游供应商实际返回的模型与请求的模型不一致。计费仍然基于请求模型。", "secondaryLineAriaLabel": "实际响应模型:{model}", - "arrowPrefix": "\u21b3" + "arrowPrefix": "↳", + "noSignatureBadge": "无思考签名", + "noSignatureTooltip": "请求开启了思考但响应流中没有 thinking signature,实际响应模型回退到 message_start 明文字段。" }, "errorMessage": "错误信息", "fake200ForwardedNotice": "提示:对于流式请求,该失败可能在流结束后才被识别;响应内容可能已原样透传给客户端。", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index f1a3b63fb..bafb784d9 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -253,7 +253,9 @@ "responseModelLabel": "實際回應模型", "mismatchTooltip": "上游供應商實際回傳的模型與請求的模型不一致。計費仍然依據請求模型。", "secondaryLineAriaLabel": "實際回應模型:{model}", - "arrowPrefix": "\u21b3" + "arrowPrefix": "↳", + "noSignatureBadge": "無思考簽名", + "noSignatureTooltip": "請求啟用了思考但回應流中沒有 thinking signature,實際回應模型回退到 message_start 明文欄位。" }, "errorMessage": "錯誤訊息", "fake200ForwardedNotice": "提示:對於串流請求,此失敗可能在串流結束後才被識別;回應內容可能已原樣透傳給用戶端。", diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx index 3dcc8a959..dd2f826db 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx @@ -29,6 +29,7 @@ import { formatCurrency } from "@/lib/utils/currency"; import { resolveModelAuditDisplay } from "@/lib/utils/model-audit-display"; import { getPricingResolutionSpecialSetting, + getThinkingSignatureModelDetectionSpecialSetting, hasPriorityServiceTierSpecialSetting, } from "@/lib/utils/special-settings"; import { getFake200ReasonKey } from "../../fake200-reason"; @@ -97,6 +98,10 @@ export function SummaryTab({ ? t(`billingDetails.pricingSource.${pricingResolution.source}`) : null; const hasPriorityServiceTier = hasPriorityServiceTierSpecialSetting(specialSettings); + const thinkingSignatureDetection = + getThinkingSignatureModelDetectionSpecialSetting(specialSettings); + const showNoSignatureBadge = + thinkingSignatureDetection?.source === "fallback_no_signature_with_thinking"; const effortInfo = extractAnthropicEffortInfo(specialSettings); const isFake200PostStreamFailure = typeof errorMessage === "string" && errorMessage.startsWith("FAKE_200_"); @@ -627,9 +632,9 @@ export function SummaryTab({ )} {/* Request / Actual Response Model (audit) */} - {hasAnyModel && ( + {(hasAnyModel || showNoSignatureBadge) && (
-

+

{t("modelAudit.unifiedLabel")} {modelAudit.hasActualMismatch && ( @@ -647,6 +652,23 @@ export function SummaryTab({ )} + {showNoSignatureBadge && ( + + + + + {t("modelAudit.noSignatureBadge")} + + + +

{t("modelAudit.noSignatureTooltip")}

+
+
+
+ )}

{modelAudit.dialogShowsSplitFields ? ( diff --git a/src/app/v1/_lib/proxy/actual-response-model.ts b/src/app/v1/_lib/proxy/actual-response-model.ts index 127e0d9b2..8310f7849 100644 --- a/src/app/v1/_lib/proxy/actual-response-model.ts +++ b/src/app/v1/_lib/proxy/actual-response-model.ts @@ -188,7 +188,11 @@ function scanStream(text: string, reader: (obj: unknown) => string | null): stri return null; } -function* extractJsonChunks(text: string): Generator { +/** + * 内部导出:供 `thinking-signature-model.ts` 等同包模块复用 SSE/NDJSON 解析能力, + * 避免重复实现多 `data:` 行合并、`[DONE]`、`event:` 边界等细节。 + */ +export function* extractJsonChunks(text: string): Generator { // SSE 多行 `data:` 合并规则(W3C EventSource):同一事件内多个 `data:` 行 // 按 `\n` 连接;事件边界是空行或新事件开始。因此这里把 `data:` 行累积到 // sseDataBuffer,遇到空行/event: 头时再 flush。 diff --git a/src/app/v1/_lib/proxy/anthropic-actual-response-model.ts b/src/app/v1/_lib/proxy/anthropic-actual-response-model.ts new file mode 100644 index 000000000..cfed171b4 --- /dev/null +++ b/src/app/v1/_lib/proxy/anthropic-actual-response-model.ts @@ -0,0 +1,125 @@ +/** + * Anthropic 流式响应"实际响应模型"解析(三态决策) + * + * 触发条件(同时满足): + * - providerType ∈ {claude, claude-auth} + * - requestedModel(重定向后)以 "claude-" 或 "anthropic/" 开头 + * + * "Anthropic 类型供应商" 在 CCH 中语义实为 "Anthropic-compatible API"; + * GLM / Z.ai / DeepSeek 等第三方供应商也可能通过 Anthropic API 协议接入, + * 但响应里没有 thinking signature。所以用"重定向后的请求模型族"判断真实 + * 上游是否是 Anthropic 模型族 —— claude-*(Anthropic 直连/中转)或 + * anthropic/*(部分聚合供应商命名前缀)。 + * + * 命中触发后: + * 1. 优先用 thinking signature 的 protobuf payload 解出模型名 + * → source = "signature"(只校验长度,**不**限制必须含 "claude" —— 为 + * 未来模型家族变革留余地) + * 2. 没拿到可用签名,但请求开启了思考 AND fallback 拿到 message_start 明文模型 + * → source = "fallback_no_signature_with_thinking" + * (UI 在此 source 下展示"无思考签名"badge 作为异常告警) + * 3. 其他情况(没开思考 / 没开签名 + 也没 message_start) + * → source = "fallback_no_thinking"(正常路径,无 badge) + * + * 未命中触发条件时返回 `source: null` / `actualResponseModel: null`, + * 调用方应自己走 `extractActualResponseModelForProvider`(原始逻辑)。 + */ + +import { extractActualResponseModelForProvider } from "@/app/v1/_lib/proxy/actual-response-model"; +import { extractThinkingSignatureModelFromStream } from "@/app/v1/_lib/proxy/thinking-signature-model"; +import type { ProviderType } from "@/types/provider"; + +export type ResponseModelSource = + | "signature" + | "fallback_no_signature_with_thinking" + | "fallback_no_thinking" + | null; + +export interface AnthropicActualResponseModelResult { + actualResponseModel: string | null; + source: ResponseModelSource; +} + +export interface ResolveAnthropicStreamActualResponseModelParams { + providerType: ProviderType | null | undefined; + /** 重定向后的实际请求模型(session.getCurrentModel()) */ + requestedModel: string | null; + /** 由 isThinkingEnabled(session.request.message) 派生 */ + thinkingEnabled: boolean; + /** 完整 SSE 流文本(allContent) */ + responseStreamText: string | null | undefined; +} + +const ANTHROPIC_PROVIDER_TYPES: ReadonlySet = new Set(["claude", "claude-auth"]); +const ANTHROPIC_MODEL_PREFIXES: readonly string[] = ["claude-", "anthropic/"]; + +/** DB `actual_response_model` 字段为 varchar(128),解码出的字符串必须落在该限制内。 */ +const MAX_MODEL_NAME_LENGTH = 128; + +export function resolveAnthropicStreamActualResponseModel( + params: ResolveAnthropicStreamActualResponseModelParams +): AnthropicActualResponseModelResult { + const { providerType, requestedModel, thinkingEnabled, responseStreamText } = params; + + if (!providerType || !ANTHROPIC_PROVIDER_TYPES.has(providerType)) { + return { actualResponseModel: null, source: null }; + } + if (!isAnthropicModelFamily(requestedModel)) { + return { actualResponseModel: null, source: null }; + } + + const rawSignatureModel = extractThinkingSignatureModelFromStream(responseStreamText ?? ""); + if (isValidSignatureModel(rawSignatureModel)) { + return { actualResponseModel: rawSignatureModel, source: "signature" }; + } + + // 没拿到可信签名:fallback 到 message_start 明文 model。通过 ForProvider 入口, + // 保持 provider → kind 映射的唯一来源(actual-response-model.kindFromProviderType)。 + const fallbackModel = extractActualResponseModelForProvider( + providerType, + true, + responseStreamText ?? "" + ); + + // 流被截断到没拿到 message_start 也没拿到签名 → 无法判断 "无签名" 是异常还是流根本没数据, + // 一律归 fallback_no_thinking,不亮 badge,避免误告警。 + if (fallbackModel === null) { + return { actualResponseModel: null, source: "fallback_no_thinking" }; + } + + return { + actualResponseModel: fallbackModel, + source: thinkingEnabled ? "fallback_no_signature_with_thinking" : "fallback_no_thinking", + }; +} + +function isAnthropicModelFamily(model: string | null): boolean { + if (!model) return false; + return ANTHROPIC_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix)); +} + +/** + * 签名解出字符串合理性校验:必须非空 + 长度可入库(varchar 128)。 + * **不**限制必须含 "claude",避免在未来模型家族变革(如 Anthropic 改名 / 推 + * 新系列时)误拒合法值。校验由"触发条件已经守卫上游是 Anthropic 协议"兜底。 + */ +function isValidSignatureModel(model: string | null): model is string { + if (!model) return false; + return model.length > 0 && model.length <= MAX_MODEL_NAME_LENGTH; +} + +/** + * 判断请求 message 是否开启了思考。 + * + * 约定: + * - `message.thinking.type === "enabled"` → 显式开启 + * - `message.thinking.type === "adaptive"` → adaptive 也视为开启(参考 thinking-budget-rectifier) + * - 其他(missing / null / 字符串 / 其他 type 字符串)→ 未开启 + */ +export function isThinkingEnabled(message: unknown): boolean { + if (!message || typeof message !== "object") return false; + const thinking = (message as { thinking?: unknown }).thinking; + if (!thinking || typeof thinking !== "object") return false; + const type = (thinking as { type?: unknown }).type; + return type === "enabled" || type === "adaptive"; +} diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 3de917794..e507abd83 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -1,3 +1,7 @@ +import { + isThinkingEnabled, + resolveAnthropicStreamActualResponseModel, +} from "@/app/v1/_lib/proxy/anthropic-actual-response-model"; import { ResponseFixer } from "@/app/v1/_lib/proxy/response-fixer"; import { AsyncTaskManager } from "@/lib/async-task-manager"; import { getEnvConfig } from "@/lib/config/env.schema"; @@ -2536,6 +2540,31 @@ export class ProxyResponseHandler { } } + // Anthropic 流式 thinking signature 模型检测(优先于明文 model 字段) + const currentRequestedModel = session.getCurrentModel(); + const thinkingActuallyEnabled = isThinkingEnabled(session.request.message); + const anthropicModelDetection = resolveAnthropicStreamActualResponseModel({ + providerType: provider.providerType, + requestedModel: currentRequestedModel, + thinkingEnabled: thinkingActuallyEnabled, + responseStreamText: allContent, + }); + if (anthropicModelDetection.source) { + session.addSpecialSetting({ + type: "thinking_signature_model_detection", + scope: "response", + hit: anthropicModelDetection.source === "fallback_no_signature_with_thinking", + source: anthropicModelDetection.source, + extractedModel: anthropicModelDetection.actualResponseModel, + signatureFound: anthropicModelDetection.source === "signature", + thinkingEnabled: thinkingActuallyEnabled, + requestedModel: currentRequestedModel, + }); + } + const finalActualResponseModel = anthropicModelDetection.source + ? anthropicModelDetection.actualResponseModel + : extractActualResponseModelForProvider(provider.providerType, true, allContent); + // 保存扩展信息(status code, tokens, provider chain) await updateMessageRequestDetails(messageContext.id, { statusCode: effectiveStatusCode, @@ -2549,12 +2578,8 @@ export class ProxyResponseHandler { cacheTtlApplied: usageForCost?.cache_ttl ?? null, providerChain: session.getProviderChain(), ...(streamErrorMessage ? { errorMessage: streamErrorMessage } : {}), - model: session.getCurrentModel() ?? undefined, // 更新重定向后的模型 - actualResponseModel: extractActualResponseModelForProvider( - provider.providerType, - true, - allContent - ), + model: currentRequestedModel ?? undefined, // 更新重定向后的模型 + actualResponseModel: finalActualResponseModel, providerId: providerIdForPersistence ?? session.provider?.id, // 更新最终供应商ID(重试切换后) context1mApplied: session.getContext1mApplied(), swapCacheTtlApplied: provider.swapCacheTtlBilling ?? false, diff --git a/src/app/v1/_lib/proxy/thinking-signature-model.ts b/src/app/v1/_lib/proxy/thinking-signature-model.ts new file mode 100644 index 000000000..c49522393 --- /dev/null +++ b/src/app/v1/_lib/proxy/thinking-signature-model.ts @@ -0,0 +1,220 @@ +/** + * Anthropic 思考签名(`signature_delta`)模型名解析 + * + * Anthropic 在流式响应里把"真正用于思考的模型名"嵌在 thinking signature 的 + * protobuf payload 中(字段路径 [2, 1, 6])。比 `message_start` 事件里的明文 + * `model` 字段更准确——明文 model 可能被上游中转层改写后不再与实际执行模型对齐。 + * + * 本模块只做"按字段路径解析 protobuf";不解密、不依赖第三方库、 + * 任何异常一律返回 null,绝不抛。 + * + * 字段路径作为可配参数,便于未来 Anthropic 调整 schema 时只改一行。 + */ + +import { extractJsonChunks } from "@/app/v1/_lib/proxy/actual-response-model"; + +const DEFAULT_FIELD_PATH: readonly number[] = [2, 1, 6]; + +/** + * 从单个 base64 签名解出 protobuf 路径终点的 utf-8 字符串。 + * + * - 输入 null/empty/非字符串 → null + * - base64 / protobuf 任意异常 → null + * - 终点必须是 wire-type 2(length-delimited)且解码为合法 utf-8 → 否则 null + */ +export function decodeThinkingSignatureModel( + base64Signature: string | null | undefined, + fieldPath: readonly number[] = DEFAULT_FIELD_PATH +): string | null { + if (typeof base64Signature !== "string" || base64Signature.length === 0) return null; + + let buf: Buffer; + try { + buf = decodeBase64Strict(base64Signature); + } catch { + return null; + } + if (buf.length === 0) return null; + + try { + const terminalBytes = walkLengthDelimitedPath(buf, fieldPath); + if (!terminalBytes) return null; + return safeUtf8(terminalBytes); + } catch { + return null; + } +} + +/** + * 扫描 SSE 流文本,寻找首个能成功解出模型名的 `signature_delta` 事件。 + * 复用 actual-response-model 的 `extractJsonChunks`(同时兼容 SSE 与 NDJSON)。 + */ +export function extractThinkingSignatureModelFromStream( + sseText: string | null | undefined, + fieldPath: readonly number[] = DEFAULT_FIELD_PATH +): string | null { + if (typeof sseText !== "string" || sseText.length === 0) return null; + + for (const chunk of extractJsonChunks(sseText)) { + let obj: unknown; + try { + obj = JSON.parse(chunk); + } catch { + continue; + } + const signature = readSignatureFromContentBlockDelta(obj); + if (!signature) continue; + const model = decodeThinkingSignatureModel(signature, fieldPath); + if (model) return model; + } + return null; +} + +function readSignatureFromContentBlockDelta(obj: unknown): string | null { + if (!obj || typeof obj !== "object") return null; + const typed = obj as { type?: unknown; delta?: unknown }; + if (typed.type !== "content_block_delta") return null; + if (!typed.delta || typeof typed.delta !== "object") return null; + const delta = typed.delta as { type?: unknown; signature?: unknown }; + if (delta.type !== "signature_delta") return null; + if (typeof delta.signature !== "string" || delta.signature.length === 0) return null; + return delta.signature; +} + +/** + * Node `Buffer.from(text, "base64")` 对非法字符是"宽容"的(会忽略), + * 这里加一个轻量校验:只允许 base64 alphabet + 可选 padding。 + * 解出零字节时也视为非法,避免把 "!!!" 误判成空 payload。 + */ +function decodeBase64Strict(input: string): Buffer { + const trimmed = input.trim(); + if (trimmed.length === 0) throw new Error("empty"); + // 接受标准 base64 (`+/`) 和 base64url (`-_`) 两种字母表,适配 Anthropic / 中转层 + // 任一编码变体。统一在 Buffer.from 之前把 URL-safe 字符替换为标准字符。 + if (!/^[A-Za-z0-9+/_-]+={0,2}$/.test(trimmed)) { + throw new Error("invalid base64 alphabet"); + } + // 长度 ≡ 1 (mod 4) 是唯一非法的余数(6 bits 不够编码一字节);0/2/3 都合法 + // (含 padded 与 unpadded)。先去掉尾部 padding,避免 "xxxxx=" 这种残缺 padded + // 输入(实际 6 bytes,unpadded 后 5 → mod 4 === 1)被误判为合法。 + const unpadded = trimmed.replace(/=+$/, ""); + if (unpadded.length % 4 === 1) throw new Error("invalid base64 length"); + const normalized = unpadded.replace(/-/g, "+").replace(/_/g, "/"); + return Buffer.from(normalized, "base64"); +} + +function safeUtf8(bytes: Buffer): string | null { + try { + const decoder = new TextDecoder("utf-8", { fatal: true }); + return decoder.decode(bytes); + } catch { + return null; + } +} + +/** + * 按字段路径走 protobuf,每层都必须是 wire-type 2 (length-delimited)。 + * - 路径长度 ≥ 1 + * - 终点字段也必须是 wire-type 2 (string/bytes/nested message) + * - 路径上任意一层缺失/类型不符 → 返回 null + */ +function walkLengthDelimitedPath(buf: Buffer, path: readonly number[]): Buffer | null { + if (path.length === 0) return null; + let current: Buffer = buf; + for (const fieldNumber of path) { + const field = findFirstField(current, fieldNumber); + if (!field) return null; + if (field.wireType !== 2 || !field.bytes) return null; + current = field.bytes; + } + return current; +} + +interface ProtoField { + fieldNumber: number; + wireType: number; + /** length-delimited 的 payload 切片(wire-type ≠ 2 时为 undefined) */ + bytes?: Buffer; + /** 整个 field(包括 tag/length 头)在父 buffer 的结束偏移(用于继续遍历兄弟字段) */ + endOffset: number; +} + +function findFirstField(buf: Buffer, fieldNumber: number): ProtoField | null { + let offset = 0; + while (offset < buf.length) { + const field = readNextField(buf, offset); + if (!field) return null; + if (field.fieldNumber === fieldNumber) return field; + offset = field.endOffset; + } + return null; +} + +const VARINT_TAG_FIELD_SHIFT = BigInt(3); +const VARINT_TAG_WIRE_MASK = BigInt(0x07); +const VARINT_BYTE_DATA_MASK = 0x7f; +const VARINT_CONTINUATION_MASK = 0x80; +const VARINT_PAYLOAD_BITS_PER_BYTE = BigInt(7); +const VARINT_MAX_SHIFT_BITS = BigInt(63); + +function readNextField(buf: Buffer, startOffset: number): ProtoField | null { + const tag = readVarint(buf, startOffset); + if (!tag) return null; + const key = tag.value; + const fieldNumber = Number(key >> VARINT_TAG_FIELD_SHIFT); + const wireType = Number(key & VARINT_TAG_WIRE_MASK); + if (fieldNumber <= 0) return null; + let offset = tag.nextOffset; + + switch (wireType) { + case 0: { + const v = readVarint(buf, offset); + if (!v) return null; + return { fieldNumber, wireType, endOffset: v.nextOffset }; + } + case 1: { + if (offset + 8 > buf.length) return null; + return { fieldNumber, wireType, endOffset: offset + 8 }; + } + case 2: { + const len = readVarint(buf, offset); + if (!len) return null; + const length = Number(len.value); + if (!Number.isSafeInteger(length) || length < 0) return null; + offset = len.nextOffset; + if (offset + length > buf.length) return null; + return { + fieldNumber, + wireType, + bytes: buf.subarray(offset, offset + length), + endOffset: offset + length, + }; + } + case 5: { + if (offset + 4 > buf.length) return null; + return { fieldNumber, wireType, endOffset: offset + 4 }; + } + default: + return null; + } +} + +function readVarint( + buf: Buffer, + startOffset: number +): { value: bigint; nextOffset: number } | null { + let result = BigInt(0); + let shift = BigInt(0); + let pos = startOffset; + while (pos < buf.length) { + const byte = buf[pos]; + pos += 1; + result |= BigInt(byte & VARINT_BYTE_DATA_MASK) << shift; + if ((byte & VARINT_CONTINUATION_MASK) === 0) { + return { value: result, nextOffset: pos }; + } + shift += VARINT_PAYLOAD_BITS_PER_BYTE; + if (shift > VARINT_MAX_SHIFT_BITS) return null; // varint too long + } + return null; // eof +} diff --git a/src/lib/utils/special-settings.ts b/src/lib/utils/special-settings.ts index 22185355f..a5f31c028 100644 --- a/src/lib/utils/special-settings.ts +++ b/src/lib/utils/special-settings.ts @@ -137,6 +137,15 @@ function buildSettingKey(setting: SpecialSetting): string { ]); case "response_input_rectifier": return JSON.stringify([setting.type, setting.hit, setting.action, setting.originalType]); + case "thinking_signature_model_detection": + return JSON.stringify([ + setting.type, + setting.source, + setting.signatureFound, + setting.thinkingEnabled, + setting.extractedModel, + setting.requestedModel, + ]); default: { // 兜底:保证即使未来扩展类型也不会导致运行时崩溃 const _exhaustive: never = setting; @@ -258,3 +267,20 @@ export function getPricingResolutionSpecialSetting( ) ?? null ); } + +export function getThinkingSignatureModelDetectionSpecialSetting( + specialSettings?: SpecialSetting[] | null +): Extract | null { + if (!Array.isArray(specialSettings) || specialSettings.length === 0) { + return null; + } + + return ( + specialSettings.find( + ( + setting + ): setting is Extract => + setting.type === "thinking_signature_model_detection" + ) ?? null + ); +} diff --git a/src/types/special-settings.ts b/src/types/special-settings.ts index fc12e402f..a93b8d1fe 100644 --- a/src/types/special-settings.ts +++ b/src/types/special-settings.ts @@ -21,7 +21,8 @@ export type SpecialSetting = | GeminiGoogleSearchOverrideSpecialSetting | PricingResolutionSpecialSetting | CodexServiceTierResultSpecialSetting - | ResponseInputRectifierSpecialSetting; + | ResponseInputRectifierSpecialSetting + | ThinkingSignatureModelDetectionSpecialSetting; export type SpecialSettingChangeValue = string | number | boolean | null; @@ -270,3 +271,30 @@ export type ResponseInputRectifierSpecialSetting = { action: "string_to_array" | "object_to_array" | "empty_string_to_empty_array" | "passthrough"; originalType: "string" | "object" | "array" | "other"; }; + +/** + * Anthropic 思考签名模型检测审计 + * + * 在 Anthropic 流式响应中,优先用 `signature_delta` 的 protobuf payload + * (字段路径 [2, 1, 6])解出实际响应模型,比 `message_start` 明文 model 更准确。 + * + * `source` 三态: + * - `signature`: 成功从签名解出模型(最理想路径) + * - `fallback_no_signature_with_thinking`: 请求开启了思考但流中没拿到可用签名 + * (无 signature_delta 事件 / base64 损坏 / protobuf 字段路径解不出), + * 退化到 message_start 明文 model。UI 在此 source 下亮"无思考签名"badge。 + * - `fallback_no_thinking`: 请求未开启思考(正常路径,无 badge) + * + * `hit` 仅在 `fallback_no_signature_with_thinking` 时为 true(异常告警语义), + * 与现有 rectifier hit 语义一致。 + */ +export type ThinkingSignatureModelDetectionSpecialSetting = { + type: "thinking_signature_model_detection"; + scope: "response"; + hit: boolean; + source: "signature" | "fallback_no_signature_with_thinking" | "fallback_no_thinking"; + extractedModel: string | null; + signatureFound: boolean; + thinkingEnabled: boolean; + requestedModel: string | null; +}; diff --git a/tests/unit/proxy/anthropic-actual-response-model.test.ts b/tests/unit/proxy/anthropic-actual-response-model.test.ts new file mode 100644 index 000000000..92e1c4cd9 --- /dev/null +++ b/tests/unit/proxy/anthropic-actual-response-model.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from "vitest"; +import { + isThinkingEnabled, + resolveAnthropicStreamActualResponseModel, +} from "@/app/v1/_lib/proxy/anthropic-actual-response-model"; + +const REAL_SIGNATURE_BASE64 = + "EqsDCmMIDhgCKkCrnWTbZMEF0r5uok/aYSgRICLVbOUhwZJhOfCxigdcVkbcTEAsm/33aCjav1PGuQPRqeZ3RAn4VTYmOZUnQHZOMg9jbGF1ZGUtb3B1cy00LTc4AEIIdGhpbmtpbmcSDH4eDs/asAFTgfDkVRoMkNlw68oKoopYj9TnIjCDgiWGjzG1woio60hvwVQRMb0ASwJyYMjZQWqCXTubppc6YpvGLIrhjtJsMfSCC/Qq9QGlGbLsHHRN4ulPmTANpxm1H83mRvzzpkYd96OGTFq/RIjHIA+CVdkiQu57eR0tj/egvnKiD0F0aYp//vOQR7dweMU75+LpNAJKuL6hIR0AwlU92NOp5EaSvO1JBIkzmcgpZyANjMKHwmTziKIqJ3nP8JRaaF/9Zi/xWKymHki7ThrD6hRbY6Kc6UXvFIo44ZmOKQOBlhtau+8ze87cKZVGWa1QyqJfFZgB0dPnD9jEjTLh6hPz9XHKPQsMEz9OZ+DYHs6oJPCms9QxssaqcTpQK4aRh04LMIU+UvkZIPCI7KEzQOXHfRLNZ2uV/EF3n0hbGzVPzRgB"; + +/** + * 单事件结尾需要空行(\n\n)来满足 W3C SSE 边界规范;join("\n") 在数组项之间插 + * \n,与每个 chunk 末尾自带的 \n 合并为 \n\n,实现真实事件边界。 + */ +function buildMessageStartChunk(model: string): string { + return [ + "event: message_start", + `data: ${JSON.stringify({ + type: "message_start", + message: { type: "message", model }, + })}`, + "", + ].join("\n"); +} + +function buildSignatureDeltaChunk(signature: string): string { + return [ + "event: content_block_delta", + `data: ${JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { type: "signature_delta", signature }, + })}`, + "", + ].join("\n"); +} + +/** Protobuf varint:7 bits/byte,MSB=continuation,LSB first。长度 ≥128 时必须多字节。 */ +function encodeVarint(value: number): Buffer { + const out: number[] = []; + let v = value >>> 0; + while (v >= 0x80) { + out.push((v & 0x7f) | 0x80); + v >>>= 7; + } + out.push(v); + return Buffer.from(out); +} + +/** 构造一个会被 protobuf [2,1,6] 解出 modelText 的合法签名 base64。 */ +function buildSignatureBase64ForModel(modelText: string): string { + const utf8 = Buffer.from(modelText, "utf8"); + const terminal = Buffer.concat([Buffer.from([0x32]), encodeVarint(utf8.length), utf8]); + const middle = Buffer.concat([Buffer.from([0x0a]), encodeVarint(terminal.length), terminal]); + const outer = Buffer.concat([Buffer.from([0x12]), encodeVarint(middle.length), middle]); + return outer.toString("base64"); +} + +describe("isThinkingEnabled", () => { + it("thinking.type === 'enabled' → true", () => { + expect(isThinkingEnabled({ thinking: { type: "enabled", budget_tokens: 32000 } })).toBe(true); + }); + + it("thinking.type === 'adaptive' → true(adaptive 也视为开启)", () => { + expect(isThinkingEnabled({ thinking: { type: "adaptive" } })).toBe(true); + }); + + it("thinking.type === 'disabled' → false", () => { + expect(isThinkingEnabled({ thinking: { type: "disabled" } })).toBe(false); + }); + + it("没有 thinking 字段 → false", () => { + expect(isThinkingEnabled({})).toBe(false); + }); + + it("thinking 不是对象 → false", () => { + expect(isThinkingEnabled({ thinking: null })).toBe(false); + expect(isThinkingEnabled({ thinking: "enabled" })).toBe(false); + expect(isThinkingEnabled({ thinking: true })).toBe(false); + }); + + it("非对象/null/undefined → false,绝不抛", () => { + expect(isThinkingEnabled(null)).toBe(false); + expect(isThinkingEnabled(undefined)).toBe(false); + expect(isThinkingEnabled("string")).toBe(false); + expect(isThinkingEnabled(42)).toBe(false); + }); +}); + +describe("resolveAnthropicStreamActualResponseModel", () => { + it("providerType 不是 Anthropic → source=null(调用方走旧 fallback)", () => { + const result = resolveAnthropicStreamActualResponseModel({ + providerType: "openai-compatible", + requestedModel: "claude-opus-4-7", + thinkingEnabled: true, + responseStreamText: buildSignatureDeltaChunk(REAL_SIGNATURE_BASE64), + }); + expect(result).toEqual({ actualResponseModel: null, source: null }); + }); + + it("providerType=null/undefined → source=null", () => { + expect( + resolveAnthropicStreamActualResponseModel({ + providerType: null, + requestedModel: "claude-opus-4-7", + thinkingEnabled: true, + responseStreamText: "", + }) + ).toEqual({ actualResponseModel: null, source: null }); + expect( + resolveAnthropicStreamActualResponseModel({ + providerType: undefined, + requestedModel: "claude-opus-4-7", + thinkingEnabled: false, + responseStreamText: "", + }) + ).toEqual({ actualResponseModel: null, source: null }); + }); + + it("Anthropic provider + requestedModel 非 Anthropic 模型族(如 glm-4.6) → source=null", () => { + // GLM 等供应商通过 Anthropic API 协议接入,但响应里没有 thinking signature, + // 不应触发签名检测,以免错误归类为 fallback_no_signature_with_thinking。 + const result = resolveAnthropicStreamActualResponseModel({ + providerType: "claude", + requestedModel: "glm-4.6", + thinkingEnabled: true, + responseStreamText: buildSignatureDeltaChunk(REAL_SIGNATURE_BASE64), + }); + expect(result).toEqual({ actualResponseModel: null, source: null }); + }); + + it("requestedModel=null → source=null", () => { + const result = resolveAnthropicStreamActualResponseModel({ + providerType: "claude", + requestedModel: null, + thinkingEnabled: true, + responseStreamText: buildSignatureDeltaChunk(REAL_SIGNATURE_BASE64), + }); + expect(result).toEqual({ actualResponseModel: null, source: null }); + }); + + it("requestedModel='claude-opus-4-7' + 签名命中 → source='signature'(即使 message_start 明文不同)", () => { + const stream = [ + buildMessageStartChunk("claude-haiku-4-5"), + buildSignatureDeltaChunk(REAL_SIGNATURE_BASE64), + ].join("\n"); + const result = resolveAnthropicStreamActualResponseModel({ + providerType: "claude", + requestedModel: "claude-opus-4-7", + thinkingEnabled: true, + responseStreamText: stream, + }); + expect(result).toEqual({ actualResponseModel: "claude-opus-4-7", source: "signature" }); + }); + + it("requestedModel='anthropic/claude-opus-4' 前缀(聚合供应商命名) → 命中触发", () => { + const stream = buildSignatureDeltaChunk(REAL_SIGNATURE_BASE64); + const result = resolveAnthropicStreamActualResponseModel({ + providerType: "claude", + requestedModel: "anthropic/claude-opus-4", + thinkingEnabled: true, + responseStreamText: stream, + }); + expect(result).toEqual({ actualResponseModel: "claude-opus-4-7", source: "signature" }); + }); + + it("claude-auth 类型同样走 Anthropic 分支", () => { + const stream = buildSignatureDeltaChunk(REAL_SIGNATURE_BASE64); + const result = resolveAnthropicStreamActualResponseModel({ + providerType: "claude-auth", + requestedModel: "claude-opus-4-7", + thinkingEnabled: false, + responseStreamText: stream, + }); + expect(result).toEqual({ actualResponseModel: "claude-opus-4-7", source: "signature" }); + }); + + it("无 signature_delta + thinkingEnabled=true + message_start 有效 → fallback_no_signature_with_thinking", () => { + const stream = buildMessageStartChunk("claude-opus-4-5"); + const result = resolveAnthropicStreamActualResponseModel({ + providerType: "claude", + requestedModel: "claude-opus-4-7", + thinkingEnabled: true, + responseStreamText: stream, + }); + expect(result).toEqual({ + actualResponseModel: "claude-opus-4-5", + source: "fallback_no_signature_with_thinking", + }); + }); + + it("无 signature_delta + thinkingEnabled=false + message_start 有效 → fallback_no_thinking", () => { + const stream = buildMessageStartChunk("claude-haiku-4-5"); + const result = resolveAnthropicStreamActualResponseModel({ + providerType: "claude", + requestedModel: "claude-opus-4-7", + thinkingEnabled: false, + responseStreamText: stream, + }); + expect(result).toEqual({ + actualResponseModel: "claude-haiku-4-5", + source: "fallback_no_thinking", + }); + }); + + it("损坏的 signature base64 + thinkingEnabled=true + message_start 有效 → fallback_no_signature_with_thinking", () => { + const stream = [ + buildMessageStartChunk("claude-opus-4-5"), + buildSignatureDeltaChunk("###corrupt!!!"), + ].join("\n"); + const result = resolveAnthropicStreamActualResponseModel({ + providerType: "claude", + requestedModel: "claude-opus-4-7", + thinkingEnabled: true, + responseStreamText: stream, + }); + expect(result).toEqual({ + actualResponseModel: "claude-opus-4-5", + source: "fallback_no_signature_with_thinking", + }); + }); + + it("有签名但 protobuf 路径解不出 + thinkingEnabled=true → 同上合并归类", () => { + // 合法 base64,但 payload 不包含 [2, 1, 6] 路径(例如只有 field 1) + const decoyB64 = Buffer.from("0a0568656c6c6f", "hex").toString("base64"); + const stream = [ + buildMessageStartChunk("claude-opus-4-5"), + buildSignatureDeltaChunk(decoyB64), + ].join("\n"); + const result = resolveAnthropicStreamActualResponseModel({ + providerType: "claude", + requestedModel: "claude-opus-4-7", + thinkingEnabled: true, + responseStreamText: stream, + }); + expect(result).toEqual({ + actualResponseModel: "claude-opus-4-5", + source: "fallback_no_signature_with_thinking", + }); + }); + + it("签名解出非 claude 字符串(如未来模型家族变革) → 仍然信任(只校验长度)", () => { + // 例如未来 Anthropic 推 'opus-5-2030' 不再带 claude- 前缀,我们不应误拒 + const futureModelB64 = buildSignatureBase64ForModel("opus-5-2030"); + const stream = [ + buildMessageStartChunk("claude-haiku-4-5"), + buildSignatureDeltaChunk(futureModelB64), + ].join("\n"); + const result = resolveAnthropicStreamActualResponseModel({ + providerType: "claude", + requestedModel: "claude-opus-4-7", + thinkingEnabled: true, + responseStreamText: stream, + }); + expect(result).toEqual({ actualResponseModel: "opus-5-2030", source: "signature" }); + }); + + it("签名解出的字符串超过 128 字符 → 拒绝(varchar 128 入库限制),fallback 到 message_start", () => { + const oversized = `claude-${"x".repeat(200)}`; + const oversizedB64 = buildSignatureBase64ForModel(oversized); + const stream = [ + buildMessageStartChunk("claude-opus-4-5"), + buildSignatureDeltaChunk(oversizedB64), + ].join("\n"); + const result = resolveAnthropicStreamActualResponseModel({ + providerType: "claude", + requestedModel: "claude-opus-4-7", + thinkingEnabled: true, + responseStreamText: stream, + }); + expect(result).toEqual({ + actualResponseModel: "claude-opus-4-5", + source: "fallback_no_signature_with_thinking", + }); + }); + + it("响应流为空 + thinkingEnabled=true → fallback_no_thinking(无 message_start 不算异常,避免误告警)", () => { + const result = resolveAnthropicStreamActualResponseModel({ + providerType: "claude", + requestedModel: "claude-opus-4-7", + thinkingEnabled: true, + responseStreamText: "", + }); + expect(result).toEqual({ actualResponseModel: null, source: "fallback_no_thinking" }); + }); + + it("响应流为空 + thinkingEnabled=false → fallback_no_thinking,模型为 null", () => { + const result = resolveAnthropicStreamActualResponseModel({ + providerType: "claude", + requestedModel: "claude-opus-4-7", + thinkingEnabled: false, + responseStreamText: "", + }); + expect(result).toEqual({ actualResponseModel: null, source: "fallback_no_thinking" }); + }); + + it("responseStreamText=null 安全处理", () => { + const result = resolveAnthropicStreamActualResponseModel({ + providerType: "claude", + requestedModel: "claude-opus-4-7", + thinkingEnabled: true, + responseStreamText: null, + }); + expect(result).toEqual({ actualResponseModel: null, source: "fallback_no_thinking" }); + }); +}); diff --git a/tests/unit/proxy/thinking-signature-model.test.ts b/tests/unit/proxy/thinking-signature-model.test.ts new file mode 100644 index 000000000..a3491ebb2 --- /dev/null +++ b/tests/unit/proxy/thinking-signature-model.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "vitest"; +import { + decodeThinkingSignatureModel, + extractThinkingSignatureModelFromStream, +} from "@/app/v1/_lib/proxy/thinking-signature-model"; + +/** + * Anthropic thinking signature 真实样例(来自需求中提供的 SSE chunk)。 + * 字段路径 [2, 1, 6] 应解出 "claude-opus-4-7"。 + */ +const REAL_SIGNATURE_BASE64 = + "EqsDCmMIDhgCKkCrnWTbZMEF0r5uok/aYSgRICLVbOUhwZJhOfCxigdcVkbcTEAsm/33aCjav1PGuQPRqeZ3RAn4VTYmOZUnQHZOMg9jbGF1ZGUtb3B1cy00LTc4AEIIdGhpbmtpbmcSDH4eDs/asAFTgfDkVRoMkNlw68oKoopYj9TnIjCDgiWGjzG1woio60hvwVQRMb0ASwJyYMjZQWqCXTubppc6YpvGLIrhjtJsMfSCC/Qq9QGlGbLsHHRN4ulPmTANpxm1H83mRvzzpkYd96OGTFq/RIjHIA+CVdkiQu57eR0tj/egvnKiD0F0aYp//vOQR7dweMU75+LpNAJKuL6hIR0AwlU92NOp5EaSvO1JBIkzmcgpZyANjMKHwmTziKIqJ3nP8JRaaF/9Zi/xWKymHki7ThrD6hRbY6Kc6UXvFIo44ZmOKQOBlhtau+8ze87cKZVGWa1QyqJfFZgB0dPnD9jEjTLh6hPz9XHKPQsMEz9OZ+DYHs6oJPCms9QxssaqcTpQK4aRh04LMIU+UvkZIPCI7KEzQOXHfRLNZ2uV/EF3n0hbGzVPzRgB"; + +/** 把 hex 字符串构造成 protobuf payload 的 base64。 */ +function buildBase64FromHex(hex: string): string { + return Buffer.from(hex.replace(/\s+/g, ""), "hex").toString("base64"); +} + +/** + * 构造嵌套 protobuf: + * { field 2: { field 1: { field 6: "" } } } + * + * Wire layout (大端可读视角): + * outer: 0x12 (tag field=2 wire=2) + * middle: 0x0a (tag field=1 wire=2) + * terminal: 0x32 (tag field=6 wire=2) + */ +function buildNestedModelBase64(modelText: string): string { + const utf8 = Buffer.from(modelText, "utf8"); + const terminal = Buffer.concat([Buffer.from([0x32, utf8.length]), utf8]); + const middle = Buffer.concat([Buffer.from([0x0a, terminal.length]), terminal]); + const outer = Buffer.concat([Buffer.from([0x12, middle.length]), middle]); + return outer.toString("base64"); +} + +describe("decodeThinkingSignatureModel", () => { + it("解出真实样例 → claude-opus-4-7", () => { + expect(decodeThinkingSignatureModel(REAL_SIGNATURE_BASE64)).toBe("claude-opus-4-7"); + }); + + it("默认路径 [2,1,6] 可被覆写,自定义路径也能解析", () => { + // 自构造:仅有 field 6 直接含字符串(单层) + const single = buildBase64FromHex(`32 05 68 65 6c 6c 6f`); // field 6 "hello" + expect(decodeThinkingSignatureModel(single, [6])).toBe("hello"); + }); + + it("两层嵌套自定义路径 [3, 1] (其中 field 3 wire 2 嵌套, field 1 wire 2 string)", () => { + // inner: 0x0a (field 1 wire 2) 0x03 "abc" → 5 bytes + // outer: 0x1a (field 3 wire 2) 0x05 + inner → 7 bytes + const b64 = buildBase64FromHex(`1a 05 0a 03 61 62 63`); + expect(decodeThinkingSignatureModel(b64, [3, 1])).toBe("abc"); + }); + + it("空字符串输入 → null", () => { + expect(decodeThinkingSignatureModel("")).toBeNull(); + }); + + it("非法 base64(含非法字符)→ null", () => { + expect(decodeThinkingSignatureModel("!!!not_base64!!!")).toBeNull(); + }); + + it("截断的 protobuf → null", () => { + // 取真实样例前 10 字节,几乎肯定截断在 varint 中间 + const truncated = Buffer.from(REAL_SIGNATURE_BASE64, "base64").subarray(0, 10); + expect(decodeThinkingSignatureModel(truncated.toString("base64"))).toBeNull(); + }); + + it("字段路径不存在(payload 没有 field 2)→ null", () => { + // 只构造 field 1 的简单消息 + const b64 = buildBase64FromHex(`0a 03 78 79 7a`); + expect(decodeThinkingSignatureModel(b64)).toBeNull(); + }); + + it("终点字段 wire-type 不是 length-delimited → null", () => { + // 终点 [2,1,6] 但 field 6 是 varint(wire 0,tag=0x30 = 48) + // outer field 2 = { field 1 = { field 6 = varint 7 } } + // inner_terminal: 0x30 0x07 (varint field 6 = 7) + // middle: 0x0a 0x02 + terminal = 4 bytes + // outer: 0x12 0x04 + middle = 6 bytes + const b64 = buildBase64FromHex(`12 04 0a 02 30 07`); + expect(decodeThinkingSignatureModel(b64)).toBeNull(); + }); + + it("中间路径字段 wire-type 不是 length-delimited → null", () => { + // outer field 2 是 varint(wire 0,tag=0x10) + // 0x10 0x05 (varint field 2 = 5) + const b64 = buildBase64FromHex(`10 05`); + expect(decodeThinkingSignatureModel(b64)).toBeNull(); + }); + + it("能解析任意 utf-8 字符串(包括短名/带连字符)", () => { + expect(decodeThinkingSignatureModel(buildNestedModelBase64("c"), [2, 1, 6])).toBe("c"); + expect(decodeThinkingSignatureModel(buildNestedModelBase64("claude-haiku-4-7"))).toBe( + "claude-haiku-4-7" + ); + }); + + it("null/undefined 输入 → null,绝不抛", () => { + expect(decodeThinkingSignatureModel(null as unknown as string)).toBeNull(); + expect(decodeThinkingSignatureModel(undefined as unknown as string)).toBeNull(); + }); +}); + +describe("extractThinkingSignatureModelFromStream", () => { + const realSseBlock = [ + "event: content_block_delta", + `data: ${JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { type: "signature_delta", signature: REAL_SIGNATURE_BASE64 }, + })}`, + "", + ].join("\n"); + + it("完整 SSE 流命中,解出 claude-opus-4-7", () => { + const stream = [ + "event: message_start", + `data: ${JSON.stringify({ + type: "message_start", + message: { type: "message", model: "claude-haiku-4-7" }, // 故意与签名不同 + })}`, + "", + "event: content_block_start", + `data: ${JSON.stringify({ type: "content_block_start", index: 0 })}`, + "", + realSseBlock, + "data: [DONE]", + "", + ].join("\n"); + expect(extractThinkingSignatureModelFromStream(stream)).toBe("claude-opus-4-7"); + }); + + it("流中没有 signature_delta → null", () => { + const stream = [ + "event: message_start", + `data: ${JSON.stringify({ + type: "message_start", + message: { type: "message", model: "claude-opus-4-5" }, + })}`, + "", + "data: [DONE]", + "", + ].join("\n"); + expect(extractThinkingSignatureModelFromStream(stream)).toBeNull(); + }); + + it("首个 signature 损坏,后续 signature 正常 → 取后续", () => { + const goodB64 = buildNestedModelBase64("claude-fallback-7"); + const stream = [ + "event: content_block_delta", + `data: ${JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { type: "signature_delta", signature: "!!!corrupt!!!" }, + })}`, + "", + "event: content_block_delta", + `data: ${JSON.stringify({ + type: "content_block_delta", + index: 1, + delta: { type: "signature_delta", signature: goodB64 }, + })}`, + "", + "data: [DONE]", + "", + ].join("\n"); + expect(extractThinkingSignatureModelFromStream(stream)).toBe("claude-fallback-7"); + }); + + it("非 signature_delta 的 delta 应跳过(text_delta/input_json_delta)", () => { + const stream = [ + "event: content_block_delta", + `data: ${JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "hi" }, + })}`, + "", + "event: content_block_delta", + `data: ${JSON.stringify({ + type: "content_block_delta", + index: 1, + delta: { type: "signature_delta", signature: REAL_SIGNATURE_BASE64 }, + })}`, + "", + ].join("\n"); + expect(extractThinkingSignatureModelFromStream(stream)).toBe("claude-opus-4-7"); + }); + + it("空字符串 / 仅注释 / 只有 [DONE] → null", () => { + expect(extractThinkingSignatureModelFromStream("")).toBeNull(); + expect(extractThinkingSignatureModelFromStream(": ping\n\ndata: [DONE]\n")).toBeNull(); + }); + + it("所有 signature 都损坏 → null", () => { + const stream = [ + "event: content_block_delta", + `data: ${JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { type: "signature_delta", signature: "###" }, + })}`, + "", + "event: content_block_delta", + `data: ${JSON.stringify({ + type: "content_block_delta", + index: 1, + delta: { type: "signature_delta", signature: "@@@" }, + })}`, + "", + ].join("\n"); + expect(extractThinkingSignatureModelFromStream(stream)).toBeNull(); + }); + + it("自定义 fieldPath 透传给 decoder", () => { + const b64 = buildBase64FromHex(`32 05 68 65 6c 6c 6f`); + const stream = [ + "event: content_block_delta", + `data: ${JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { type: "signature_delta", signature: b64 }, + })}`, + "", + ].join("\n"); + expect(extractThinkingSignatureModelFromStream(stream, [6])).toBe("hello"); + }); + + it("null / undefined / 非字符串输入 → null", () => { + expect(extractThinkingSignatureModelFromStream(null as unknown as string)).toBeNull(); + expect(extractThinkingSignatureModelFromStream(undefined as unknown as string)).toBeNull(); + }); +}); From 833c5ec88f70dac4936618d7e81c314b447e6fff Mon Sep 17 00:00:00 2001 From: puyujian <46592377+puyujian@users.noreply.github.com> Date: Thu, 28 May 2026 19:59:57 +0800 Subject: [PATCH 3/5] fix(proxy): normalize nullable Responses output fields (#1220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(proxy): 规范化 Responses 输出空值 * fix(proxy): 完善 Responses 输出归一化边界 --- src/app/v1/_lib/proxy/response-fixer/index.ts | 4 +- .../response-fixer/response-fixer.test.ts | 19 +- src/app/v1/_lib/proxy/response-handler.ts | 1 + .../_lib/proxy/response-output-normalizer.ts | 189 +++++++++++++++ .../proxy/response-output-normalizer.test.ts | 226 ++++++++++++++++++ 5 files changed, 436 insertions(+), 3 deletions(-) create mode 100644 src/app/v1/_lib/proxy/response-output-normalizer.ts create mode 100644 tests/unit/proxy/response-output-normalizer.test.ts diff --git a/src/app/v1/_lib/proxy/response-fixer/index.ts b/src/app/v1/_lib/proxy/response-fixer/index.ts index ae8a4285b..56f25928c 100644 --- a/src/app/v1/_lib/proxy/response-fixer/index.ts +++ b/src/app/v1/_lib/proxy/response-fixer/index.ts @@ -4,6 +4,7 @@ import { SessionManager } from "@/lib/session-manager"; import { updateMessageRequestDetails } from "@/repository/message"; import type { ResponseFixerSpecialSetting } from "@/types/special-settings"; import type { ResponseFixerConfig } from "@/types/system-config"; +import { normalizeResponseOutput } from "../response-output-normalizer"; import type { ProxySession } from "../session"; import { EncodingFixer } from "./encoding-fixer"; import { JsonFixer } from "./json-fixer"; @@ -287,11 +288,12 @@ export class ResponseFixer { const headers = cleanResponseHeaders(response.headers); - return new Response(toArrayBufferUint8Array(data), { + const fixedResponse = new Response(toArrayBufferUint8Array(data), { status: response.status, statusText: response.statusText, headers, }); + return await normalizeResponseOutput(session, fixedResponse); } private static processStream( diff --git a/src/app/v1/_lib/proxy/response-fixer/response-fixer.test.ts b/src/app/v1/_lib/proxy/response-fixer/response-fixer.test.ts index e0ba8f7c4..2e4f2728c 100644 --- a/src/app/v1/_lib/proxy/response-fixer/response-fixer.test.ts +++ b/src/app/v1/_lib/proxy/response-fixer/response-fixer.test.ts @@ -76,16 +76,31 @@ describe("ResponseFixer", () => { }); const session = createSession(); - const response = new Response('{"a":1}', { + session.originalFormat = "response"; + const response = new Response('{"object":"response","output":null}', { headers: { "content-type": "application/json" }, }); const fixed = await ResponseFixer.process(session, response); - expect(await fixed.text()).toBe('{"a":1}'); + expect(await fixed.text()).toBe('{"object":"response","output":null}'); expect(fixed.headers.get("x-cch-response-fixer")).toBeNull(); expect(session.getSpecialSettings()).toBeNull(); }); + test("非流式 Responses 响应:启用时应执行输出归一化", async () => { + const { ResponseFixer } = await import("./index"); + + const session = createSession(); + session.originalFormat = "response"; + const response = new Response('{"object":"response","output":null}', { + headers: { "content-type": "application/json" }, + }); + + const fixed = await ResponseFixer.process(session, response); + + expect(await fixed.json()).toMatchObject({ object: "response", output: [] }); + }); + test("非流式响应:命中编码修复时应写入 specialSettings 并持久化", async () => { const { ResponseFixer } = await import("./index"); diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index e507abd83..3fe84eb0b 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -881,6 +881,7 @@ export class ProxyResponseHandler { let fixedResponse = response; if (!session.getEndpointPolicy().bypassResponseRectifier) { try { + // raw passthrough 端点跳过 ResponseFixer,也跳过其中的 Responses 输出归一化。 fixedResponse = await ResponseFixer.process(session, response); } catch (error) { logger.error( diff --git a/src/app/v1/_lib/proxy/response-output-normalizer.ts b/src/app/v1/_lib/proxy/response-output-normalizer.ts new file mode 100644 index 000000000..3283ac71b --- /dev/null +++ b/src/app/v1/_lib/proxy/response-output-normalizer.ts @@ -0,0 +1,189 @@ +import { logger } from "@/lib/logger"; +import type { ProxySession } from "./session"; + +type JsonRecord = Record; + +export type ResponseOutputNormalizationResult = { + payload: unknown; + applied: boolean; + fixes: string[]; +}; + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isJsonContentType(contentType: string): boolean { + const normalized = contentType.toLowerCase(); + return normalized.includes("application/json") || normalized.includes("+json"); +} + +function cleanResponseHeaders(headers: Headers): Headers { + const cleaned = new Headers(headers); + cleaned.delete("transfer-encoding"); + cleaned.delete("content-length"); + cleaned.delete("content-encoding"); + return cleaned; +} + +function stringifyArguments(value: unknown): string { + if (value == null) return "{}"; + if (typeof value === "string") return value; + + try { + return JSON.stringify(value) ?? "{}"; + } catch { + return String(value); + } +} + +function normalizeFunctionArguments( + target: JsonRecord, + key: string, + fixes: string[], + path: string +): void { + if (!(key in target)) return; + + const value = target[key]; + const normalized = stringifyArguments(value); + if (normalized === value) return; + + target[key] = normalized; + fixes.push(`${path}.${key}`); +} + +function normalizeContentPart(part: unknown, fixes: string[], path: string): void { + if (!isRecord(part)) return; + + if ("text" in part && part.text === null) { + part.text = ""; + fixes.push(`${path}.text`); + } + + if ("annotations" in part && part.annotations === null) { + part.annotations = []; + fixes.push(`${path}.annotations`); + } + + if ("logprobs" in part && part.logprobs === null) { + part.logprobs = []; + fixes.push(`${path}.logprobs`); + } +} + +function normalizeToolCall(toolCall: unknown, fixes: string[], path: string): void { + if (!isRecord(toolCall)) return; + + const nestedFunction = toolCall.function; + if (isRecord(nestedFunction)) { + normalizeFunctionArguments(nestedFunction, "arguments", fixes, `${path}.function`); + } +} + +function normalizeOutputItem(item: unknown, fixes: string[], path: string): void { + if (!isRecord(item)) return; + + // Responses API 的 message.content 是数组;部分兼容上游会把空内容写成 null。 + if ("content" in item) { + if (item.content === null) { + item.content = []; + fixes.push(`${path}.content`); + } else if (Array.isArray(item.content)) { + item.content.forEach((part, index) => { + normalizeContentPart(part, fixes, `${path}.content[${index}]`); + }); + } + } + + if ("summary" in item && item.summary === null) { + item.summary = []; + fixes.push(`${path}.summary`); + } + + normalizeFunctionArguments(item, "arguments", fixes, path); + + const nestedFunction = item.function; + if (isRecord(nestedFunction)) { + normalizeFunctionArguments(nestedFunction, "arguments", fixes, `${path}.function`); + } + + if (Array.isArray(item.tool_calls)) { + item.tool_calls.forEach((toolCall, index) => { + normalizeToolCall(toolCall, fixes, `${path}.tool_calls[${index}]`); + }); + } +} + +export function normalizeResponseOutputPayload( + payload: unknown +): ResponseOutputNormalizationResult { + const fixes: string[] = []; + + if (!isRecord(payload) || payload.object !== "response") { + return { payload, applied: false, fixes }; + } + + if ("output" in payload) { + if (payload.output === null) { + payload.output = []; + fixes.push("output"); + } else if (Array.isArray(payload.output)) { + payload.output.forEach((item, index) => { + normalizeOutputItem(item, fixes, `output[${index}]`); + }); + } + } + + if ("tools" in payload && payload.tools === null) { + payload.tools = []; + fixes.push("tools"); + } + + return { payload, applied: fixes.length > 0, fixes }; +} + +export async function normalizeResponseOutput( + session: ProxySession, + response: Response +): Promise { + if (session.originalFormat !== "response") return response; + if (response.status < 200 || response.status >= 300) return response; + + const contentType = response.headers.get("content-type") || ""; + if (!isJsonContentType(contentType)) return response; + + let rawText: string; + try { + rawText = await response.clone().text(); + } catch (error) { + logger.warn("[ResponseOutputNormalizer] Failed to read response clone", { + error: error instanceof Error ? error.message : String(error), + sessionId: session.sessionId ?? null, + requestSequence: session.requestSequence ?? null, + }); + return response; + } + + let parsed: unknown; + try { + parsed = JSON.parse(rawText); + } catch { + return response; + } + + const result = normalizeResponseOutputPayload(parsed); + if (!result.applied) return response; + + logger.info("[ResponseOutputNormalizer] Normalized Responses API output", { + fixes: result.fixes, + sessionId: session.sessionId ?? null, + requestSequence: session.requestSequence ?? null, + }); + + return new Response(JSON.stringify(result.payload), { + status: response.status, + statusText: response.statusText, + headers: cleanResponseHeaders(response.headers), + }); +} diff --git a/tests/unit/proxy/response-output-normalizer.test.ts b/tests/unit/proxy/response-output-normalizer.test.ts new file mode 100644 index 000000000..066a3c4d5 --- /dev/null +++ b/tests/unit/proxy/response-output-normalizer.test.ts @@ -0,0 +1,226 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + normalizeResponseOutput, + normalizeResponseOutputPayload, +} from "@/app/v1/_lib/proxy/response-output-normalizer"; +import type { ProxySession } from "@/app/v1/_lib/proxy/session"; + +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function createResponseSession(): ProxySession { + return { + originalFormat: "response", + sessionId: "sess_test", + requestSequence: 1, + } as unknown as ProxySession; +} + +describe("normalizeResponseOutputPayload", () => { + it("normalizes nullable Responses fields that official SDKs parse as arrays or strings", () => { + const payload = { + id: "resp_test", + object: "response", + status: "completed", + output: [ + { + id: "msg_1", + type: "message", + role: "assistant", + status: "completed", + content: null, + }, + { + id: "msg_2", + type: "message", + role: "assistant", + status: "completed", + content: [ + { + type: "output_text", + text: null, + annotations: null, + logprobs: null, + }, + ], + }, + { + id: "fc_1", + type: "function_call", + name: "lookup", + arguments: null, + }, + { + id: "tc_1", + type: "tool_calls", + tool_calls: [{ function: { name: "search", arguments: { q: "ok" } } }], + }, + { + id: "rs_1", + type: "reasoning", + summary: null, + }, + ], + tools: null, + usage: null, + }; + + const result = normalizeResponseOutputPayload(payload); + + expect(result.applied).toBe(true); + expect(payload.output[0].content).toEqual([]); + expect(payload.output[1].content[0]).toMatchObject({ + text: "", + annotations: [], + logprobs: [], + }); + expect(payload.output[2].arguments).toBe("{}"); + expect(payload.output[3].tool_calls[0].function.arguments).toBe('{"q":"ok"}'); + expect(payload.output[4].summary).toEqual([]); + expect(payload.tools).toEqual([]); + expect(payload.usage).toBeNull(); + }); + + it("normalizes top-level null output to an empty array", () => { + const payload = { + id: "resp_test", + object: "response", + status: "completed", + output: null, + }; + + const result = normalizeResponseOutputPayload(payload); + + expect(result).toMatchObject({ applied: true }); + expect(payload.output).toEqual([]); + }); + + it("leaves non-response JSON payloads untouched", () => { + const payload = { + object: "list", + output: null, + data: [], + }; + + const result = normalizeResponseOutputPayload(payload); + + expect(result.applied).toBe(false); + expect(payload.output).toBeNull(); + }); +}); + +describe("normalizeResponseOutput", () => { + it("returns a new SDK-compatible JSON response when nullable fields are fixed", async () => { + const response = new Response( + JSON.stringify({ + id: "resp_test", + object: "response", + status: "completed", + output: [ + { + id: "msg_1", + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: null, annotations: null }], + }, + ], + }), + { + status: 200, + headers: { + "content-type": "Application/JSON", + "content-length": "999", + "content-encoding": "gzip", + }, + } + ); + + const normalized = await normalizeResponseOutput(createResponseSession(), response); + const body = await normalized.json(); + + expect(normalized.headers.has("content-length")).toBe(false); + expect(normalized.headers.has("content-encoding")).toBe(false); + expect(body.output[0].content[0]).toMatchObject({ text: "", annotations: [] }); + }); + + it("skips non-Responses client formats", async () => { + const response = new Response('{"object":"response","output":null}', { + status: 200, + headers: { "content-type": "application/json" }, + }); + const session = { + ...createResponseSession(), + originalFormat: "openai", + } as unknown as ProxySession; + + const normalized = await normalizeResponseOutput(session, response); + + expect(normalized).toBe(response); + }); + + it("returns the original response when no normalization is needed", async () => { + const response = new Response( + JSON.stringify({ + id: "resp_test", + object: "response", + status: "completed", + output: [ + { + id: "msg_1", + type: "message", + role: "assistant", + status: "completed", + content: [], + }, + ], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + + const normalized = await normalizeResponseOutput(createResponseSession(), response); + + expect(normalized).toBe(response); + }); + + it("skips non-2xx responses", async () => { + const response = new Response('{"object":"response","output":null}', { + status: 500, + headers: { "content-type": "application/json" }, + }); + + const normalized = await normalizeResponseOutput(createResponseSession(), response); + + expect(normalized).toBe(response); + }); + + it("skips non-JSON content types", async () => { + const response = new Response("text", { + status: 200, + headers: { "content-type": "text/plain" }, + }); + + const normalized = await normalizeResponseOutput(createResponseSession(), response); + + expect(normalized).toBe(response); + }); + + it("returns the original response when JSON parsing fails", async () => { + const response = new Response("not json", { + status: 200, + headers: { "content-type": "application/json" }, + }); + + const normalized = await normalizeResponseOutput(createResponseSession(), response); + + expect(normalized).toBe(response); + }); +}); From 0e47f17c7d8b6ecadd61ceea8a1a41f8c108434a Mon Sep 17 00:00:00 2001 From: Clouder Date: Thu, 28 May 2026 20:00:56 +0800 Subject: [PATCH 4/5] fix: return full self user list shape (#1213) * fix: return full self user list shape * fix: target self user display loading * chore: remove unreachable self user guard --- src/actions/users.ts | 294 ++++++++++-------- src/app/api/v1/resources/users/handlers.ts | 23 +- tests/api/v1/users/users.test.ts | 43 ++- tests/unit/api/v1/api-client-actions.test.ts | 10 +- .../users-action-get-users-compat.test.ts | 58 ++++ 5 files changed, 284 insertions(+), 144 deletions(-) diff --git a/src/actions/users.ts b/src/actions/users.ts index 1ffbe2c3e..1c543c155 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -185,6 +185,135 @@ function canExposeFullKey( return session.key.canLoginWebUi && (isAdmin || session.user.id === targetUser.id); } +async function buildUserDisplays( + users: User[], + session: UserActionSession, + isAdmin: boolean +): Promise { + if (users.length === 0) { + return []; + } + + const locale = await getLocale(); + const t = await getTranslations("users"); + const userIds = users.map((u) => u.id); + const [keysMap, usageMap] = await Promise.all([ + findKeyListBatch(userIds), + findKeyUsageTodayBatch(userIds), + ]); + const statisticsMap = await findKeysStatisticsBatchFromKeys(keysMap); + + return users.map((user) => { + try { + const keys = keysMap.get(user.id) || []; + const usageRecords = usageMap.get(user.id) || []; + const keyStatistics = statisticsMap.get(user.id) || []; + const canUserManageKey = canExposeFullKey(session, user, isAdmin); + + const usageLookup = new Map( + usageRecords.map((item) => [ + item.keyId, + { totalCost: item.totalCost ?? 0, totalTokens: item.totalTokens ?? 0 }, + ]) + ); + const statisticsLookup = new Map(keyStatistics.map((stat) => [stat.keyId, stat])); + + return { + id: user.id, + name: user.name, + note: user.description || undefined, + role: user.role, + rpm: user.rpm, + dailyQuota: user.dailyQuota, + providerGroup: user.providerGroup || undefined, + tags: user.tags || [], + limit5hUsd: user.limit5hUsd ?? null, + limit5hResetMode: user.limit5hResetMode, + limitWeeklyUsd: user.limitWeeklyUsd ?? null, + limitMonthlyUsd: user.limitMonthlyUsd ?? null, + limitTotalUsd: user.limitTotalUsd ?? null, + costResetAt: user.costResetAt ?? null, + limitConcurrentSessions: user.limitConcurrentSessions ?? null, + dailyResetMode: user.dailyResetMode, + dailyResetTime: user.dailyResetTime, + isEnabled: user.isEnabled, + expiresAt: user.expiresAt ?? null, + allowedClients: user.allowedClients || [], + blockedClients: user.blockedClients || [], + allowedModels: user.allowedModels ?? [], + keys: keys.map((key) => { + const stats = statisticsLookup.get(key.id); + return { + id: key.id, + name: key.name, + maskedKey: maskKey(key.key), + canReveal: canUserManageKey, + canCopy: canUserManageKey, + expiresAt: key.expiresAt + ? key.expiresAt.toISOString().split("T")[0] + : t("neverExpires"), + status: key.isEnabled ? "enabled" : ("disabled" as const), + createdAt: key.createdAt, + createdAtFormatted: key.createdAt.toLocaleString(locale, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }), + todayUsage: usageLookup.get(key.id)?.totalCost ?? 0, + todayTokens: usageLookup.get(key.id)?.totalTokens ?? 0, + todayCallCount: stats?.todayCallCount ?? 0, + lastUsedAt: stats?.lastUsedAt ?? null, + lastProviderName: stats?.lastProviderName ?? null, + modelStats: stats?.modelStats ?? [], + canLoginWebUi: key.canLoginWebUi, + limit5hUsd: key.limit5hUsd, + limit5hResetMode: key.limit5hResetMode, + limitDailyUsd: key.limitDailyUsd, + dailyResetMode: key.dailyResetMode, + dailyResetTime: key.dailyResetTime, + limitWeeklyUsd: key.limitWeeklyUsd, + limitMonthlyUsd: key.limitMonthlyUsd, + limitTotalUsd: key.limitTotalUsd, + limitConcurrentSessions: key.limitConcurrentSessions || 0, + costResetAt: key.costResetAt?.toISOString() ?? null, + providerGroup: key.providerGroup, + }; + }), + }; + } catch (error) { + logger.error(`Failed to process keys for user ${user.id}:`, error); + return { + id: user.id, + name: user.name, + note: user.description || undefined, + role: user.role, + rpm: user.rpm, + dailyQuota: user.dailyQuota, + providerGroup: user.providerGroup || undefined, + tags: user.tags || [], + limit5hUsd: user.limit5hUsd ?? null, + limit5hResetMode: user.limit5hResetMode, + limitWeeklyUsd: user.limitWeeklyUsd ?? null, + limitMonthlyUsd: user.limitMonthlyUsd ?? null, + limitTotalUsd: user.limitTotalUsd ?? null, + costResetAt: user.costResetAt ?? null, + limitConcurrentSessions: user.limitConcurrentSessions ?? null, + dailyResetMode: user.dailyResetMode, + dailyResetTime: user.dailyResetTime, + isEnabled: user.isEnabled, + expiresAt: user.expiresAt ?? null, + allowedClients: user.allowedClients || [], + blockedClients: user.blockedClients || [], + allowedModels: user.allowedModels ?? [], + keys: [], + }; + } + }); +} + /** * 批量获取用户列表的返回结果。 */ @@ -334,10 +463,6 @@ export async function getUsers(params?: GetUsersBatchParams): Promise u.id); - const [keysMap, usageMap] = await Promise.all([ - findKeyListBatch(userIds), - findKeyUsageTodayBatch(userIds), - ]); - const statisticsMap = await findKeysStatisticsBatchFromKeys(keysMap); - - const userDisplays: UserDisplay[] = users.map((user) => { - try { - const keys = keysMap.get(user.id) || []; - const usageRecords = usageMap.get(user.id) || []; - const keyStatistics = statisticsMap.get(user.id) || []; + return await buildUserDisplays(users, session, isAdmin); + } catch (error) { + logger.error("Failed to fetch user data:", error); + return []; + } +} - const usageLookup = new Map( - usageRecords.map((item) => [ - item.keyId, - { totalCost: item.totalCost ?? 0, totalTokens: item.totalTokens ?? 0 }, - ]) - ); - const statisticsLookup = new Map(keyStatistics.map((stat) => [stat.keyId, stat])); +/** + * 获取当前登录用户的用户管理页显示数据。 + * + * 与 getUsers() 的非管理员路径保持同一返回形状,但不让管理员 self endpoint + * 退化为全量用户列表扫描。 + */ +export async function getCurrentUserDisplay(): Promise> { + try { + const tError = await getTranslations("errors"); + const session = await getSession(); + if (!session) { + return { + ok: false, + error: tError("UNAUTHORIZED"), + errorCode: ERROR_CODES.UNAUTHORIZED, + }; + } - return { - id: user.id, - name: user.name, - note: user.description || undefined, - role: user.role, - rpm: user.rpm, - dailyQuota: user.dailyQuota, - providerGroup: user.providerGroup || undefined, - tags: user.tags || [], - limit5hUsd: user.limit5hUsd ?? null, - limit5hResetMode: user.limit5hResetMode, - limitWeeklyUsd: user.limitWeeklyUsd ?? null, - limitMonthlyUsd: user.limitMonthlyUsd ?? null, - limitTotalUsd: user.limitTotalUsd ?? null, - costResetAt: user.costResetAt ?? null, - limitConcurrentSessions: user.limitConcurrentSessions ?? null, - dailyResetMode: user.dailyResetMode, - dailyResetTime: user.dailyResetTime, - isEnabled: user.isEnabled, - expiresAt: user.expiresAt ?? null, - allowedClients: user.allowedClients || [], - blockedClients: user.blockedClients || [], - allowedModels: user.allowedModels ?? [], - keys: keys.map((key) => { - const stats = statisticsLookup.get(key.id); - const canUserManageKey = canExposeFullKey(session, user, isAdmin); - return { - id: key.id, - name: key.name, - maskedKey: maskKey(key.key), - canReveal: canUserManageKey, - canCopy: canUserManageKey, - expiresAt: key.expiresAt - ? key.expiresAt.toISOString().split("T")[0] - : t("neverExpires"), - status: key.isEnabled ? "enabled" : ("disabled" as const), - createdAt: key.createdAt, - createdAtFormatted: key.createdAt.toLocaleString(locale, { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }), - todayUsage: usageLookup.get(key.id)?.totalCost ?? 0, - todayTokens: usageLookup.get(key.id)?.totalTokens ?? 0, - todayCallCount: stats?.todayCallCount ?? 0, - lastUsedAt: stats?.lastUsedAt ?? null, - lastProviderName: stats?.lastProviderName ?? null, - modelStats: stats?.modelStats ?? [], - // Web UI 登录权限控制 - canLoginWebUi: key.canLoginWebUi, - // 限额配置 - limit5hUsd: key.limit5hUsd, - limit5hResetMode: key.limit5hResetMode, - limitDailyUsd: key.limitDailyUsd, - dailyResetMode: key.dailyResetMode, - dailyResetTime: key.dailyResetTime, - limitWeeklyUsd: key.limitWeeklyUsd, - limitMonthlyUsd: key.limitMonthlyUsd, - limitTotalUsd: key.limitTotalUsd, - limitConcurrentSessions: key.limitConcurrentSessions || 0, - costResetAt: key.costResetAt?.toISOString() ?? null, - providerGroup: key.providerGroup, - }; - }), - }; - } catch (error) { - logger.error(`Failed to process keys for user ${user.id}:`, error); - return { - id: user.id, - name: user.name, - note: user.description || undefined, - role: user.role, - rpm: user.rpm, - dailyQuota: user.dailyQuota, - providerGroup: user.providerGroup || undefined, - tags: user.tags || [], - limit5hUsd: user.limit5hUsd ?? null, - limit5hResetMode: user.limit5hResetMode, - limitWeeklyUsd: user.limitWeeklyUsd ?? null, - limitMonthlyUsd: user.limitMonthlyUsd ?? null, - limitTotalUsd: user.limitTotalUsd ?? null, - costResetAt: user.costResetAt ?? null, - limitConcurrentSessions: user.limitConcurrentSessions ?? null, - dailyResetMode: user.dailyResetMode, - dailyResetTime: user.dailyResetTime, - isEnabled: user.isEnabled, - expiresAt: user.expiresAt ?? null, - allowedClients: user.allowedClients || [], - blockedClients: user.blockedClients || [], - allowedModels: user.allowedModels ?? [], - keys: [], - }; - } - }); + const user = await findUserById(session.user.id); + if (!user) { + return { + ok: false, + error: tError("USER_NOT_FOUND"), + errorCode: ERROR_CODES.NOT_FOUND, + }; + } - return userDisplays; + const [displayUser] = await buildUserDisplays([user], session, session.user.role === "admin"); + return { ok: true, data: displayUser }; } catch (error) { - logger.error("Failed to fetch user data:", error); - return []; + logger.error("Failed to fetch current user display data:", error); + const message = + error instanceof Error ? error.message : "Failed to fetch current user display data"; + return { ok: false, error: message, errorCode: ERROR_CODES.INTERNAL_ERROR }; } } diff --git a/src/app/api/v1/resources/users/handlers.ts b/src/app/api/v1/resources/users/handlers.ts index 1f306367e..9216c90a4 100644 --- a/src/app/api/v1/resources/users/handlers.ts +++ b/src/app/api/v1/resources/users/handlers.ts @@ -78,19 +78,23 @@ export async function listCurrentUser(c: Context): Promise { }); } const actions = await import("@/actions/users"); - const result = await callAction( - c, - actions.getUserById, - [currentUserId] as never[], - c.get("auth") - ); + const result = await callAction(c, actions.getCurrentUserDisplay, [] as never[], c.get("auth")); if (!result.ok) return actionError(c, result); + if (result.data.id !== currentUserId) { + return createProblemResponse({ + status: 404, + instance: new URL(c.req.url).pathname, + errorCode: "resource.not_found", + detail: "Current user was not found.", + }); + } + const items = [redactUserKeys(result.data)]; return jsonResponse({ - items: [redactUserKeys(result.data)], + items, pageInfo: { nextCursor: null, hasMore: false, - limit: 1, + limit: items.length, }, }); } @@ -353,6 +357,9 @@ function redactUserKeys(value: unknown): unknown { if (!value || typeof value !== "object") { return value; } + if (value instanceof Date) { + return value; + } const entries = Object.entries(value as Record) .filter(([key]) => key !== "fullKey") diff --git a/tests/api/v1/users/users.test.ts b/tests/api/v1/users/users.test.ts index 782727443..4b80db618 100644 --- a/tests/api/v1/users/users.test.ts +++ b/tests/api/v1/users/users.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; const validateAuthTokenMock = vi.hoisted(() => vi.fn()); const getUsersMock = vi.hoisted(() => vi.fn()); +const getCurrentUserDisplayMock = vi.hoisted(() => vi.fn()); const getUserByIdMock = vi.hoisted(() => vi.fn()); const getUsersBatchCoreMock = vi.hoisted(() => vi.fn()); const getUsersUsageBatchMock = vi.hoisted(() => vi.fn()); @@ -29,6 +30,7 @@ vi.mock("@/lib/auth", async (importOriginal) => { vi.mock("@/actions/users", () => ({ getUsers: getUsersMock, + getCurrentUserDisplay: getCurrentUserDisplayMock, getUserById: getUserByIdMock, getUsersBatchCore: getUsersBatchCoreMock, getUsersUsageBatch: getUsersUsageBatchMock, @@ -84,6 +86,7 @@ describe("v1 users endpoints", () => { data: { users: [user(1)], nextCursor: "next", hasMore: true }, }); getUsersMock.mockResolvedValue([user(1), user(250)]); + getCurrentUserDisplayMock.mockResolvedValue({ ok: true, data: user(1) }); getUserByIdMock.mockImplementation(async (id: number) => { if (id === 404) { return { ok: false, error: "Not found", errorCode: "NOT_FOUND" }; @@ -161,6 +164,23 @@ describe("v1 users endpoints", () => { test("returns the current user from a read-tier self list endpoint", async () => { validateAuthTokenMock.mockResolvedValueOnce(userSession); + getCurrentUserDisplayMock.mockResolvedValueOnce({ + ok: true, + data: { + ...user(9), + expiresAt: new Date("2026-05-07T07:41:10.000Z"), + costResetAt: new Date("2026-04-30T00:00:00.000Z"), + keys: [ + { + id: 10, + name: "default", + maskedKey: "sk-...cret", + fullKey: "sk-user-secret", + createdAt: new Date("2026-04-30T07:41:10.000Z"), + }, + ], + }, + }); const self = await callV1Route({ method: "GET", @@ -170,16 +190,28 @@ describe("v1 users endpoints", () => { expect(self.response.status).toBe(200); expect(self.json).toMatchObject({ - items: [{ id: 9, name: "user-9" }], + items: [ + { + id: 9, + name: "user-9", + keys: [{ id: 10, name: "default", maskedKey: "sk-...cret" }], + }, + ], pageInfo: { nextCursor: null, hasMore: false, limit: 1, }, }); + expect(Array.isArray(self.json.items[0].keys)).toBe(true); + expect(self.json.items[0].expiresAt).toBe("2026-05-07T07:41:10.000Z"); + expect(self.json.items[0].costResetAt).toBe("2026-04-30T00:00:00.000Z"); + expect(self.json.items[0].keys[0].createdAt).toBe("2026-04-30T07:41:10.000Z"); expect(JSON.stringify(self.json)).not.toContain("sk-user-secret"); - expect(getUserByIdMock).toHaveBeenCalledWith(9); + expect(JSON.stringify(self.json)).not.toContain("fullKey"); + expect(getCurrentUserDisplayMock).toHaveBeenCalledWith(); expect(getUsersMock).not.toHaveBeenCalled(); + expect(getUserByIdMock).not.toHaveBeenCalled(); expect(validateAuthTokenMock).toHaveBeenCalledWith("user-token", { allowReadOnlyAccess: true, }); @@ -196,12 +228,15 @@ describe("v1 users endpoints", () => { expect(self.response.status).toBe(200); expect(self.json).toMatchObject({ - items: [{ id: 1, name: "user-1" }], + items: [{ id: 1, name: "user-1", keys: [{ id: 10, name: "default" }] }], pageInfo: { nextCursor: null, hasMore: false, limit: 1 }, }); + expect(Array.isArray(self.json.items[0].keys)).toBe(true); expect(JSON.stringify(self.json)).not.toContain("user-250"); - expect(getUserByIdMock).toHaveBeenCalledWith(1); + expect(JSON.stringify(self.json)).not.toContain("fullKey"); + expect(getCurrentUserDisplayMock).toHaveBeenCalledWith(); expect(getUsersMock).not.toHaveBeenCalled(); + expect(getUserByIdMock).not.toHaveBeenCalled(); }); test("reads user detail from an id-capable action instead of the first list page", async () => { diff --git a/tests/unit/api/v1/api-client-actions.test.ts b/tests/unit/api/v1/api-client-actions.test.ts index f79e0454b..5b8f29564 100644 --- a/tests/unit/api/v1/api-client-actions.test.ts +++ b/tests/unit/api/v1/api-client-actions.test.ts @@ -143,12 +143,14 @@ describe("v1 action compatibility client", () => { }) ) .mockResolvedValueOnce({ - users: [{ id: 9, name: "self" }], - nextCursor: null, - hasMore: false, + items: [{ id: 9, name: "self", keys: [{ id: 90, name: "default" }] }], + pageInfo: { nextCursor: null, hasMore: false }, }); - await expect(users.getUsers()).resolves.toEqual([{ id: 9, name: "self" }]); + const result = await users.getUsers(); + + expect(result).toHaveLength(1); + expect(result).toMatchObject([{ id: 9, name: "self", keys: [{ id: 90, name: "default" }] }]); expect(getMock).toHaveBeenNthCalledWith(1, "/api/v1/users"); expect(getMock).toHaveBeenNthCalledWith(2, "/api/v1/users:self"); diff --git a/tests/unit/users-action-get-users-compat.test.ts b/tests/unit/users-action-get-users-compat.test.ts index d9fc19961..2dcf336e7 100644 --- a/tests/unit/users-action-get-users-compat.test.ts +++ b/tests/unit/users-action-get-users-compat.test.ts @@ -155,6 +155,64 @@ describe("getUsers compatibility", () => { expect(result[0]?.name).toBe("xiaolunanbei"); }); + test("getCurrentUserDisplay only loads the current session user", async () => { + getSessionMock.mockResolvedValueOnce({ + user: { id: 42, role: "admin" }, + key: { canLoginWebUi: true }, + }); + const currentUser = makeUser(42, "current-admin"); + currentUser.expiresAt = new Date("2026-05-07T07:41:10.000Z"); + findUserByIdMock.mockResolvedValueOnce(currentUser); + findKeyListBatchMock.mockResolvedValueOnce( + new Map([ + [ + 42, + [ + { + id: 420, + name: "default", + key: "sk-current-user-secret", + expiresAt: null, + isEnabled: true, + createdAt: new Date("2026-04-30T07:41:10.000Z"), + canLoginWebUi: true, + limit5hUsd: null, + limit5hResetMode: "rolling", + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: 0, + costResetAt: null, + providerGroup: "default", + }, + ], + ], + ]) + ); + + const { getCurrentUserDisplay } = await import("@/actions/users"); + + const result = await getCurrentUserDisplay(); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data).toMatchObject({ + id: 42, + name: "current-admin", + keys: [{ id: 420, name: "default", maskedKey: "sk-c••••••cret", canReveal: true }], + }); + expect(result.data.expiresAt).toBeInstanceOf(Date); + expect(result.data.keys[0]?.createdAt).toBeInstanceOf(Date); + expect(findUserByIdMock).toHaveBeenCalledWith(42); + expect(findUserListBatchMock).not.toHaveBeenCalled(); + expect(findKeyListBatchMock).toHaveBeenCalledWith([42]); + expect(findKeyUsageTodayBatchMock).toHaveBeenCalledWith([42]); + expect(findKeysStatisticsBatchFromKeysMock).toHaveBeenCalledWith(expect.any(Map)); + }); + test("getUsersBatchCore returns JSON-safe date fields for v1 API transport", async () => { const user = makeUser(88, "dated-user"); user.expiresAt = new Date("2026-05-07T07:41:10.000Z"); From 37029b1377a3a8980f7b49d80931c3bcad429d27 Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Thu, 28 May 2026 20:01:21 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E9=A1=B5=20Redis=20=E8=81=9A=E5=90=88?= =?UTF-8?q?=E4=B8=8E=E8=BD=AE=E8=AF=A2=E6=80=A7=E8=83=BD=20(#1211)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 优化公开状态页 Redis 聚合与轮询性能 * 修复公开状态页 rollup 边界问题 * 完善公开状态页 rollup 可靠性 * 完善公开状态页轮询测试清理 * 完善公开状态页轮询测试清理 --------- Co-authored-by: tesgth032 --- src/app/[locale]/status/[slug]/page.tsx | 2 +- .../_components/public-status-timeline.tsx | 103 ++- .../status/_components/public-status-view.tsx | 55 +- src/lib/model-vendor-icons.test.ts | 31 +- src/lib/model-vendor-icons.tsx | 186 +--- src/lib/model-vendor-rules.ts | 119 +++ src/lib/public-status/aggregation-core.ts | 35 + src/lib/public-status/aggregation.ts | 41 +- src/lib/public-status/config-publisher.ts | 2 + src/lib/public-status/config-snapshot.ts | 109 ++- src/lib/public-status/read-store.ts | 219 ++++- src/lib/public-status/rebuild-hints.ts | 9 + src/lib/public-status/rebuild-worker.ts | 81 +- src/lib/public-status/redis-contract.ts | 73 +- src/lib/public-status/rollup-store.ts | 819 ++++++++++++++++++ src/lib/public-status/scheduler.ts | 4 +- src/lib/public-status/vendor-icon-key.ts | 2 +- src/repository/message.ts | 204 +++++ tests/unit/public-status/aggregation.test.ts | 86 ++ .../public-status/config-publisher.test.ts | 1 + .../public-status/config-snapshot.test.ts | 50 ++ .../public-status/no-db-import-guard.test.ts | 4 + .../public-status/public-status-view.test.tsx | 298 +++++-- tests/unit/public-status/read-store.test.ts | 226 +++++ .../unit/public-status/rebuild-worker.test.ts | 148 +++- .../unit/public-status/redis-contract.test.ts | 58 ++ tests/unit/public-status/rollup-store.test.ts | 537 ++++++++++++ .../message-public-status-rollup.test.ts | 514 +++++++++++ 28 files changed, 3614 insertions(+), 402 deletions(-) create mode 100644 src/lib/model-vendor-rules.ts create mode 100644 src/lib/public-status/aggregation-core.ts create mode 100644 src/lib/public-status/rollup-store.ts create mode 100644 tests/unit/public-status/rollup-store.test.ts create mode 100644 tests/unit/repository/message-public-status-rollup.test.ts diff --git a/src/app/[locale]/status/[slug]/page.tsx b/src/app/[locale]/status/[slug]/page.tsx index 0ad1e3fb7..2190592df 100644 --- a/src/app/[locale]/status/[slug]/page.tsx +++ b/src/app/[locale]/status/[slug]/page.tsx @@ -46,7 +46,7 @@ export default async function PublicStatusGroupPage({ targetGroup, } = await loadGroupContext(slug); if (!targetGroup) { - notFound(); + return notFound(); } const filteredPayload = { ...initialPayload, groups: [targetGroup] }; diff --git a/src/app/[locale]/status/_components/public-status-timeline.tsx b/src/app/[locale]/status/_components/public-status-timeline.tsx index e31dca979..d41293847 100644 --- a/src/app/[locale]/status/_components/public-status-timeline.tsx +++ b/src/app/[locale]/status/_components/public-status-timeline.tsx @@ -1,6 +1,6 @@ "use client"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useMemo, useState } from "react"; import { cn } from "@/lib/utils"; import type { FilledTimelineCell } from "../_lib/fill-display-timeline"; import { formatTtfb } from "../_lib/format-ttfb"; @@ -74,8 +74,36 @@ export function PublicStatusTimeline({ locale, labels, }: PublicStatusTimelineProps) { + const [activeIndex, setActiveIndex] = useState(null); + const activeCell = activeIndex === null ? null : (cells[activeIndex] ?? null); + const activeBucket = activeCell?.bucket ?? null; + const activeIsPlaceholder = activeBucket?.bucketStart.startsWith("empty-") ?? false; + const activeSummary = useMemo(() => { + if (!activeBucket) { + return null; + } + + return { + range: activeIsPlaceholder + ? null + : formatRange(activeBucket.bucketStart, activeBucket.bucketEnd, locale, timeZone), + availability: + activeBucket.availabilityPct === null ? "—" : `${activeBucket.availabilityPct.toFixed(2)}%`, + ttfb: formatTtfb(activeBucket.ttfbMs), + tps: activeBucket.tps === null ? "—" : activeBucket.tps.toFixed(1), + }; + }, [activeBucket, activeIsPlaceholder, locale, timeZone]); + return ( - +
setActiveIndex(null)} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + setActiveIndex(null); + } + }} + >
{cells.map((cell, index) => { const { bucket } = cell; - const isPlaceholder = bucket.bucketStart.startsWith("empty-"); return ( - - -
- + {activeSummary ? ( +
+ {activeSummary.range ? ( +

{activeSummary.range}

+ ) : null} +
+ + {labels.availability}{" "} + {activeSummary.availability} + + + {labels.ttfb} {activeSummary.ttfb} + + + {labels.tps} {activeSummary.tps} + +
+
+ ) : null} +
); } diff --git a/src/app/[locale]/status/_components/public-status-view.tsx b/src/app/[locale]/status/_components/public-status-view.tsx index f1cc0ac51..36be96a78 100644 --- a/src/app/[locale]/status/_components/public-status-view.tsx +++ b/src/app/[locale]/status/_components/public-status-view.tsx @@ -49,6 +49,10 @@ import { SortableGroupPanel } from "./sortable-group-panel"; import { StatusHero } from "./status-hero"; import { StatusToolbar } from "./status-toolbar"; +type ViewModelSnapshot = PublicStatusPayload["groups"][number]["models"][number] & { + timelineReusedFromPrevious?: boolean; +}; + interface PublicStatusViewProps { initialPayload: PublicStatusPayload; initialStatus?: PublicStatusRouteStatus; @@ -151,6 +155,17 @@ function aggregateByFailed(states: DisplayState[]): DisplayState { return "operational"; } +function deriveCurrentModelState(model: ViewModelSnapshot): DisplayState { + if (model.timeline.length === 0 || model.timelineReusedFromPrevious) { + return model.latestState; + } + return deriveLatestModelState(model); +} + +function shouldUseServerModelSummary(model: ViewModelSnapshot): boolean { + return model.timeline.length === 0 || Boolean(model.timelineReusedFromPrevious); +} + export function PublicStatusView({ initialPayload, initialStatus, @@ -184,6 +199,7 @@ export function PublicStatusView({ if (filterSlug) { params.set("groupSlug", filterSlug); } + params.set("include", "meta,defaults,groups"); const requestUrl = params.size > 0 ? `/api/public-status?${params.toString()}` : "/api/public-status"; const response = await fetch(requestUrl, { cache: "no-store" }); @@ -195,7 +211,35 @@ export function PublicStatusView({ ? { ...next, groups: next.groups.filter((g) => g.publicGroupSlug === filterSlug) } : next; startTransition(() => { - setPayload(scoped); + setPayload((previous) => ({ + ...scoped, + groups: scoped.groups.map((group) => { + const previousGroup = previous.groups.find( + (candidate) => candidate.publicGroupSlug === group.publicGroupSlug + ); + if (!previousGroup) { + return group; + } + + return { + ...group, + models: group.models.map((model) => { + const previousModel = previousGroup.models.find( + (candidate) => candidate.publicModelKey === model.publicModelKey + ); + if (model.timeline.length > 0) { + return model; + } + + return { + ...model, + timeline: previousModel?.timeline ?? [], + timelineReusedFromPrevious: Boolean(previousModel?.timeline.length), + }; + }), + }; + }), + })); setRouteStatus(nextResponse.status); }); } catch { @@ -222,9 +266,14 @@ export function PublicStatusView({ const derivedModels = group.models.map((model) => { const filled = fillDisplayTimeline(model.timeline); const chartCells = sliceTimelineForChart(filled, CHART_BUCKETS); - const uptime24h = computeUptimePct(model.timeline); + const viewModel = model as ViewModelSnapshot; + const useServerSummary = shouldUseServerModelSummary(viewModel); + const uptime24h = + useServerSummary && model.availabilityPct !== null + ? model.availabilityPct + : computeUptimePct(model.timeline); const ttfb24h = computeAvgTtfb(model.timeline); - const latest = deriveLatestModelState(model); + const latest = deriveCurrentModelState(viewModel); return { model, chartCells, uptime24h, ttfb24h, latest }; }); const issueCount = derivedModels.filter((d) => d.latest === "failed").length; diff --git a/src/lib/model-vendor-icons.test.ts b/src/lib/model-vendor-icons.test.ts index 874c0af6f..0d8ec3d0d 100644 --- a/src/lib/model-vendor-icons.test.ts +++ b/src/lib/model-vendor-icons.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { getModelVendor, PRICE_FILTER_VENDORS } from "./model-vendor-icons"; describe("getModelVendor", () => { @@ -119,6 +119,35 @@ describe("getModelVendor", () => { expect(getModelVendor("o1")?.i18nKey).toBe("openai"); expect(getModelVendor("yi")?.i18nKey).toBe("yi"); }); + + it("warns in development when a vendor rule has no registered icon", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + vi.resetModules(); + vi.doMock("@/lib/model-vendor-rules", () => ({ + getModelVendor: () => ({ + prefix: "missing", + hasColor: false, + i18nKey: "missing-vendor", + }), + })); + vi.stubEnv("NODE_ENV", "development"); + + try { + const { getModelVendor: getMockedModelVendor } = await import("./model-vendor-icons"); + const result = getMockedModelVendor("missing-model"); + + expect(result?.i18nKey).toBe("missing-vendor"); + expect(warnSpy).toHaveBeenCalledWith( + '[model-vendor-icons] No icon registered for i18nKey "missing-vendor"' + ); + } finally { + vi.unstubAllEnvs(); + warnSpy.mockRestore(); + vi.doUnmock("@/lib/model-vendor-rules"); + vi.resetModules(); + } + }); }); describe("PRICE_FILTER_VENDORS", () => { diff --git a/src/lib/model-vendor-icons.tsx b/src/lib/model-vendor-icons.tsx index 069d0426c..e2f2dad7c 100644 --- a/src/lib/model-vendor-icons.tsx +++ b/src/lib/model-vendor-icons.tsx @@ -33,152 +33,58 @@ import { Yi, Zhipu, } from "@lobehub/icons"; +import { + getModelVendor as getModelVendorRule, + type ModelVendorRule, +} from "@/lib/model-vendor-rules"; -export interface ModelVendorEntry { - prefix: string; +export type ModelVendorEntry = ModelVendorRule & { icon: React.ComponentType<{ className?: string }>; - hasColor: boolean; - i18nKey: string; - litellmProvider?: string; -} +}; -// Strictly sorted by prefix length descending to ensure longest-match-first. -// Within same length, sorted alphabetically. -const MODEL_VENDOR_RULES: ModelVendorEntry[] = [ - // 9 chars - { - prefix: "codestral", - icon: Mistral.Color, - hasColor: true, - i18nKey: "mistral", - litellmProvider: "mistral", - }, - { prefix: "sensenova", icon: SenseNova.Color, hasColor: true, i18nKey: "sensenova" }, - // 8 chars - { prefix: "baichuan", icon: Baichuan.Color, hasColor: true, i18nKey: "baichuan" }, - { - prefix: "deepseek", - icon: DeepSeek.Color, - hasColor: true, - i18nKey: "deepseek", - litellmProvider: "deepseek", - }, - { prefix: "internlm", icon: InternLM.Color, hasColor: true, i18nKey: "internlm" }, - { prefix: "moonshot", icon: Moonshot, hasColor: false, i18nKey: "moonshot" }, - // 7 chars - { - prefix: "chatglm", - icon: ChatGLM.Color, - hasColor: true, - i18nKey: "zhipuai", - litellmProvider: "zhipuai", - }, - { - prefix: "chatgpt", - icon: OpenAI, - hasColor: false, - i18nKey: "openai", - litellmProvider: "openai", - }, - { - prefix: "command", - icon: Cohere.Color, - hasColor: true, - i18nKey: "cohere", - litellmProvider: "cohere_chat", - }, - { prefix: "hunyuan", icon: Hunyuan.Color, hasColor: true, i18nKey: "hunyuan" }, - { prefix: "minimax", icon: Minimax.Color, hasColor: true, i18nKey: "minimax" }, - { - prefix: "mistral", - icon: Mistral.Color, - hasColor: true, - i18nKey: "mistral", - litellmProvider: "mistral", - }, - { - prefix: "mixtral", - icon: Mistral.Color, - hasColor: true, - i18nKey: "mistral", - litellmProvider: "mistral", - }, - { - prefix: "pixtral", - icon: Mistral.Color, - hasColor: true, - i18nKey: "mistral", - litellmProvider: "mistral", - }, - // 6 chars - { - prefix: "claude", - icon: Claude.Color, - hasColor: true, - i18nKey: "anthropic", - litellmProvider: "anthropic", - }, - { - prefix: "doubao", - icon: Doubao.Color, - hasColor: true, - i18nKey: "volcengine", - litellmProvider: "volcengine", - }, - { - prefix: "gemini", - icon: Gemini.Color, - hasColor: true, - i18nKey: "vertex", - litellmProvider: "vertex_ai-language-models", - }, - { prefix: "nvidia", icon: Nvidia.Color, hasColor: true, i18nKey: "nvidia" }, - { prefix: "wenxin", icon: Wenxin.Color, hasColor: true, i18nKey: "wenxin" }, - // 5 chars - { prefix: "ernie", icon: Wenxin.Color, hasColor: true, i18nKey: "wenxin" }, - { prefix: "gemma", icon: Gemma.Color, hasColor: true, i18nKey: "gemma" }, - { prefix: "llama", icon: Meta.Color, hasColor: true, i18nKey: "meta" }, - { prefix: "sonar", icon: Perplexity.Color, hasColor: true, i18nKey: "perplexity" }, - { prefix: "spark", icon: Spark.Color, hasColor: true, i18nKey: "spark" }, - // 4 chars - { prefix: "abab", icon: Minimax.Color, hasColor: true, i18nKey: "minimax" }, - { prefix: "grok", icon: Grok, hasColor: false, i18nKey: "xai", litellmProvider: "xai" }, - { prefix: "kimi", icon: Kimi.Color, hasColor: true, i18nKey: "kimi" }, - { prefix: "pplx", icon: Perplexity.Color, hasColor: true, i18nKey: "perplexity" }, - { prefix: "qwen", icon: Qwen.Color, hasColor: true, i18nKey: "qwen" }, - { - prefix: "seed", - icon: Doubao.Color, - hasColor: true, - i18nKey: "volcengine", - litellmProvider: "volcengine", - }, - { prefix: "step", icon: Stepfun.Color, hasColor: true, i18nKey: "stepfun" }, - // 3 chars - { - prefix: "glm", - icon: ChatGLM.Color, - hasColor: true, - i18nKey: "zhipuai", - litellmProvider: "zhipuai", - }, - { prefix: "gpt", icon: OpenAI, hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, - // 2 chars - { prefix: "o1", icon: OpenAI, hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, - { prefix: "o3", icon: OpenAI, hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, - { prefix: "o4", icon: OpenAI, hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, - { prefix: "yi", icon: Yi.Color, hasColor: true, i18nKey: "yi" }, -]; +const MODEL_VENDOR_ICON_BY_KEY: Record> = { + anthropic: Claude.Color, + baichuan: Baichuan.Color, + cohere: Cohere.Color, + deepseek: DeepSeek.Color, + gemma: Gemma.Color, + hunyuan: Hunyuan.Color, + internlm: InternLM.Color, + kimi: Kimi.Color, + meta: Meta.Color, + minimax: Minimax.Color, + mistral: Mistral.Color, + moonshot: Moonshot, + nvidia: Nvidia.Color, + openai: OpenAI, + perplexity: Perplexity.Color, + qwen: Qwen.Color, + sensenova: SenseNova.Color, + spark: Spark.Color, + stepfun: Stepfun.Color, + vertex: Gemini.Color, + volcengine: Doubao.Color, + wenxin: Wenxin.Color, + xai: Grok, + yi: Yi.Color, + zhipuai: ChatGLM.Color, +}; export function getModelVendor(modelId: string): ModelVendorEntry | null { - if (!modelId) return null; - const lower = modelId.toLowerCase(); - for (const rule of MODEL_VENDOR_RULES) { - if (lower.startsWith(rule.prefix)) { - return rule; - } + const rule = getModelVendorRule(modelId); + if (!rule) { + return null; } - return null; + + const icon = MODEL_VENDOR_ICON_BY_KEY[rule.i18nKey]; + if (!icon && process.env.NODE_ENV !== "production") { + console.warn(`[model-vendor-icons] No icon registered for i18nKey "${rule.i18nKey}"`); + } + + return { + ...rule, + icon: icon ?? OpenAI, + }; } export const PRICE_FILTER_VENDORS: Array<{ diff --git a/src/lib/model-vendor-rules.ts b/src/lib/model-vendor-rules.ts new file mode 100644 index 000000000..2ad4ff0b4 --- /dev/null +++ b/src/lib/model-vendor-rules.ts @@ -0,0 +1,119 @@ +// Strictly sorted by prefix length descending to ensure longest-match-first. +// Within same length, sorted alphabetically. +export const MODEL_VENDOR_RULES = [ + { + prefix: "codestral", + hasColor: true, + i18nKey: "mistral", + litellmProvider: "mistral", + }, + { prefix: "sensenova", hasColor: true, i18nKey: "sensenova" }, + { prefix: "baichuan", hasColor: true, i18nKey: "baichuan" }, + { + prefix: "deepseek", + hasColor: true, + i18nKey: "deepseek", + litellmProvider: "deepseek", + }, + { prefix: "internlm", hasColor: true, i18nKey: "internlm" }, + { prefix: "moonshot", hasColor: false, i18nKey: "moonshot" }, + { + prefix: "chatglm", + hasColor: true, + i18nKey: "zhipuai", + litellmProvider: "zhipuai", + }, + { + prefix: "chatgpt", + hasColor: false, + i18nKey: "openai", + litellmProvider: "openai", + }, + { + prefix: "command", + hasColor: true, + i18nKey: "cohere", + litellmProvider: "cohere_chat", + }, + { prefix: "hunyuan", hasColor: true, i18nKey: "hunyuan" }, + { prefix: "minimax", hasColor: true, i18nKey: "minimax" }, + { + prefix: "mistral", + hasColor: true, + i18nKey: "mistral", + litellmProvider: "mistral", + }, + { + prefix: "mixtral", + hasColor: true, + i18nKey: "mistral", + litellmProvider: "mistral", + }, + { + prefix: "pixtral", + hasColor: true, + i18nKey: "mistral", + litellmProvider: "mistral", + }, + { + prefix: "claude", + hasColor: true, + i18nKey: "anthropic", + litellmProvider: "anthropic", + }, + { + prefix: "doubao", + hasColor: true, + i18nKey: "volcengine", + litellmProvider: "volcengine", + }, + { + prefix: "gemini", + hasColor: true, + i18nKey: "vertex", + litellmProvider: "vertex_ai-language-models", + }, + { prefix: "nvidia", hasColor: true, i18nKey: "nvidia" }, + { prefix: "wenxin", hasColor: true, i18nKey: "wenxin" }, + { prefix: "ernie", hasColor: true, i18nKey: "wenxin" }, + { prefix: "gemma", hasColor: true, i18nKey: "gemma" }, + { prefix: "llama", hasColor: true, i18nKey: "meta" }, + { prefix: "sonar", hasColor: true, i18nKey: "perplexity" }, + { prefix: "spark", hasColor: true, i18nKey: "spark" }, + { prefix: "abab", hasColor: true, i18nKey: "minimax" }, + { prefix: "grok", hasColor: false, i18nKey: "xai", litellmProvider: "xai" }, + { prefix: "kimi", hasColor: true, i18nKey: "kimi" }, + { prefix: "pplx", hasColor: true, i18nKey: "perplexity" }, + { prefix: "qwen", hasColor: true, i18nKey: "qwen" }, + { + prefix: "seed", + hasColor: true, + i18nKey: "volcengine", + litellmProvider: "volcengine", + }, + { prefix: "step", hasColor: true, i18nKey: "stepfun" }, + { + prefix: "glm", + hasColor: true, + i18nKey: "zhipuai", + litellmProvider: "zhipuai", + }, + { prefix: "gpt", hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, + { prefix: "o1", hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, + { prefix: "o3", hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, + { prefix: "o4", hasColor: false, i18nKey: "openai", litellmProvider: "openai" }, + { prefix: "yi", hasColor: true, i18nKey: "yi" }, +] as const; + +export type ModelVendorRule = (typeof MODEL_VENDOR_RULES)[number]; + +export function getModelVendor(modelId: string): ModelVendorRule | null { + if (!modelId) return null; + const lower = modelId.toLowerCase(); + for (const rule of MODEL_VENDOR_RULES) { + if (lower.startsWith(rule.prefix)) { + return rule; + } + } + return null; +} diff --git a/src/lib/public-status/aggregation-core.ts b/src/lib/public-status/aggregation-core.ts new file mode 100644 index 000000000..a3b07d4d9 --- /dev/null +++ b/src/lib/public-status/aggregation-core.ts @@ -0,0 +1,35 @@ +export interface PublicStatusConfiguredGroup { + sourceGroupId?: number | null; + sourceGroupName: string; + publicGroupSlug: string; + displayName: string; + explanatoryCopy: string | null; + sortOrder: number; + models: Array<{ + publicModelKey: string; + label: string; + vendorIconKey: string; + requestTypeBadge: string; + }>; +} + +export function computeTokensPerSecond(input: { + outputTokens?: number | null; + durationMs?: number | null; + ttfbMs?: number | null; +}): number | null { + if (!input.outputTokens || input.outputTokens <= 0) { + return null; + } + + if (!input.durationMs || input.durationMs <= 0) { + return null; + } + + const generationMs = input.durationMs - (input.ttfbMs ?? 0); + if (generationMs <= 0) { + return null; + } + + return Number((input.outputTokens / (generationMs / 1000)).toFixed(4)); +} diff --git a/src/lib/public-status/aggregation.ts b/src/lib/public-status/aggregation.ts index 48f494c81..2ed6fdf22 100644 --- a/src/lib/public-status/aggregation.ts +++ b/src/lib/public-status/aggregation.ts @@ -8,6 +8,7 @@ import { import { resolveProviderGroupsWithDefault } from "@/lib/utils/provider-group"; import { EXCLUDE_WARMUP_CONDITION } from "@/repository/_shared/message-request-conditions"; import type { ProviderChainItem } from "@/types/message"; +import { computeTokensPerSecond, type PublicStatusConfiguredGroup } from "./aggregation-core"; import type { InternalPublicStatusConfigSnapshot } from "./config-snapshot"; import type { PublicStatusPayload, PublicStatusTimelineBucket } from "./payload"; @@ -43,19 +44,7 @@ export interface PublicStatusRequestRow { providerChain?: PublicStatusRequestChainItem[] | null; } -export interface PublicStatusConfiguredGroup { - sourceGroupName: string; - publicGroupSlug: string; - displayName: string; - explanatoryCopy: string | null; - sortOrder: number; - models: Array<{ - publicModelKey: string; - label: string; - vendorIconKey: string; - requestTypeBadge: string; - }>; -} +export type { PublicStatusConfiguredGroup } from "./aggregation-core"; export interface PublicStatusAggregationResult { generatedAt: string; @@ -84,6 +73,7 @@ export function getConfiguredPublicStatusGroups( group.models.length > 0 ) .map((group) => ({ + sourceGroupId: group.sourceGroupId ?? null, sourceGroupName: group.sourceGroupName!.trim(), publicGroupSlug: group.slug, displayName: group.displayName, @@ -102,26 +92,7 @@ export function getConfiguredPublicStatusGroups( ); } -export function computeTokensPerSecond(input: { - outputTokens?: number | null; - durationMs?: number | null; - ttfbMs?: number | null; -}): number | null { - if (!input.outputTokens || input.outputTokens <= 0) { - return null; - } - - if (!input.durationMs || input.durationMs <= 0) { - return null; - } - - const generationMs = input.durationMs - (input.ttfbMs ?? 0); - if (generationMs <= 0) { - return null; - } - - return Number((input.outputTokens / (generationMs / 1000)).toFixed(4)); -} +export { computeTokensPerSecond } from "./aggregation-core"; export function isExcludedFromPublicStatusFailure(signal: PublicStatusFailureSignal): boolean { return ( @@ -352,10 +323,10 @@ export function buildPublicStatusPayloadFromRequests(input: { bucket.failureCount += 1; } - if (typeof request.ttfbMs === "number") { + if (outcome === "success" && typeof request.ttfbMs === "number") { bucket.ttfbValues.push(request.ttfbMs); } - if (typeof tps === "number") { + if (outcome === "success" && typeof tps === "number") { bucket.tpsValues.push(tps); } } diff --git a/src/lib/public-status/config-publisher.ts b/src/lib/public-status/config-publisher.ts index 24333619b..b19065c19 100644 --- a/src/lib/public-status/config-publisher.ts +++ b/src/lib/public-status/config-publisher.ts @@ -79,6 +79,7 @@ export async function publishCurrentPublicStatusConfigProjection(input: { settings.publicStatusAggregationIntervalMinutes ); const defaultRangeHours = normalizePublicRange(settings.publicStatusWindowHours); + const providerGroupIdByName = new Map(providerGroups.map((group) => [group.name, group.id])); const snapshot = buildPublicStatusConfigSnapshot({ configVersion: input.configVersion ?? `cfg-${Date.now()}`, @@ -119,6 +120,7 @@ export async function publishCurrentPublicStatusConfigProjection(input: { defaultIntervalMinutes: snapshot.defaultIntervalMinutes, defaultRangeHours: snapshot.defaultRangeHours, groups: enabledGroups.map((group) => ({ + sourceGroupId: providerGroupIdByName.get(group.groupName) ?? null, sourceGroupName: group.groupName, slug: group.publicGroupSlug, displayName: group.displayName, diff --git a/src/lib/public-status/config-snapshot.ts b/src/lib/public-status/config-snapshot.ts index c40b9736a..2818aad14 100644 --- a/src/lib/public-status/config-snapshot.ts +++ b/src/lib/public-status/config-snapshot.ts @@ -4,13 +4,14 @@ import { buildPublicStatusConfigSnapshotKey, buildPublicStatusConfigVersionPointerKey, buildPublicStatusInternalConfigSnapshotKey, + LEGACY_PUBLIC_STATUS_REDIS_PREFIX, } from "./redis-contract"; export const DEFAULT_PUBLIC_STATUS_SITE_DESCRIPTION = "Request-derived public status"; /** * TTL (seconds) applied to *versioned* config snapshot keys written by this - * module — i.e. `public-status:v1:config:` and the internal variant. + * module — i.e. versioned public-status config keys and the internal variant. * * 30 days matches `GENERATION_PROJECTION_TTL_SECONDS` in `rebuild-worker.ts`, * which already governs the manifest / series / snapshot keys for this @@ -45,6 +46,7 @@ export interface PublicStatusGroupSnapshot { } export interface InternalPublicStatusGroupSnapshot extends PublicStatusGroupSnapshot { + sourceGroupId?: number | null; sourceGroupName: string; } @@ -102,6 +104,11 @@ interface RedisReader { status?: string; } +interface ReadCurrentSnapshotOptions { + redis?: RedisReader | null; + allowLegacyFallback?: boolean; +} + function normalizePublicSiteDescription(value: unknown): string | null { if (typeof value !== "string") { return null; @@ -162,7 +169,7 @@ function extractCurrentConfigVersion(pointerRaw: string | null): string | null { return pointer.configVersion; } if (pointer?.key) { - const match = pointer.key.match(/:config(?::internal)?:([^:]+)$/); + const match = pointer.key.match(/:config(?:-internal)?:([^:]+)$/); if (match?.[1]) { return decodeURIComponent(match[1]); } @@ -329,55 +336,93 @@ export async function publishCurrentPublicStatusConfigPointers(input: { export async function readCurrentPublicStatusConfigSnapshot(input?: { redis?: RedisReader | null; + allowLegacyFallback?: boolean; }): Promise { const redis = input?.redis ?? getRedisClient({ allowWhenRateLimitDisabled: true }); if (!redis || ("status" in redis && redis.status && redis.status !== "ready")) { return null; } - const currentVersion = extractCurrentConfigVersion( - await safeGet(redis, buildPublicStatusConfigVersionPointerKey()) - ); - if (currentVersion) { - const snapshotRaw = await safeGet(redis, buildPublicStatusConfigSnapshotKey(currentVersion)); - return safeParseJson(snapshotRaw); - } + const prefixes = + input?.allowLegacyFallback === false + ? [undefined] + : [undefined, LEGACY_PUBLIC_STATUS_REDIS_PREFIX]; + for (const prefix of prefixes) { + const currentVersion = extractCurrentConfigVersion( + await safeGet(redis, buildPublicStatusConfigVersionPointerKey({ prefix })) + ); + if (currentVersion) { + const snapshotRaw = await safeGet( + redis, + buildPublicStatusConfigSnapshotKey(currentVersion, { prefix }) + ); + const snapshot = safeParseJson(snapshotRaw); + if (snapshot) { + return snapshot; + } + } - const pointerRaw = await safeGet(redis, buildPublicStatusConfigSnapshotKey()); - const pointer = safeParseJson<{ key?: string }>(pointerRaw); - if (!pointer?.key) { - return null; + const pointerRaw = await safeGet( + redis, + buildPublicStatusConfigSnapshotKey("current", { prefix }) + ); + const pointer = safeParseJson<{ key?: string }>(pointerRaw); + if (!pointer?.key) { + continue; + } + const snapshot = safeParseJson(await safeGet(redis, pointer.key)); + if (snapshot) { + return snapshot; + } } - const snapshotRaw = await safeGet(redis, pointer.key); - return safeParseJson(snapshotRaw); + + return null; } -export async function readCurrentInternalPublicStatusConfigSnapshot(input?: { - redis?: RedisReader | null; -}): Promise { +export async function readCurrentInternalPublicStatusConfigSnapshot( + input?: ReadCurrentSnapshotOptions +): Promise { const redis = input?.redis ?? getRedisClient({ allowWhenRateLimitDisabled: true }); if (!redis || ("status" in redis && redis.status && redis.status !== "ready")) { return null; } - const currentVersion = extractCurrentConfigVersion( - await safeGet(redis, buildPublicStatusConfigVersionPointerKey()) - ); - if (currentVersion) { - const snapshotRaw = await safeGet( + const prefixes = + input?.allowLegacyFallback === false + ? [undefined] + : [undefined, LEGACY_PUBLIC_STATUS_REDIS_PREFIX]; + for (const prefix of prefixes) { + const currentVersion = extractCurrentConfigVersion( + await safeGet(redis, buildPublicStatusConfigVersionPointerKey({ prefix })) + ); + if (currentVersion) { + const snapshotRaw = await safeGet( + redis, + buildPublicStatusInternalConfigSnapshotKey(currentVersion, { prefix }) + ); + const snapshot = safeParseJson(snapshotRaw); + if (snapshot) { + return snapshot; + } + } + + const pointerRaw = await safeGet( redis, - buildPublicStatusInternalConfigSnapshotKey(currentVersion) + buildPublicStatusInternalConfigSnapshotKey("current", { prefix }) ); - return safeParseJson(snapshotRaw); + const pointer = safeParseJson<{ key?: string }>(pointerRaw); + if (!pointer?.key) { + continue; + } + const snapshot = safeParseJson( + await safeGet(redis, pointer.key) + ); + if (snapshot) { + return snapshot; + } } - const pointerRaw = await safeGet(redis, buildPublicStatusInternalConfigSnapshotKey()); - const pointer = safeParseJson<{ key?: string }>(pointerRaw); - if (!pointer?.key) { - return null; - } - const snapshotRaw = await safeGet(redis, pointer.key); - return safeParseJson(snapshotRaw); + return null; } export async function readPublicStatusSiteMetadata(input?: { diff --git a/src/lib/public-status/read-store.ts b/src/lib/public-status/read-store.ts index 7aeb7dced..4dec7e75d 100644 --- a/src/lib/public-status/read-store.ts +++ b/src/lib/public-status/read-store.ts @@ -9,6 +9,7 @@ import type { import { buildPublicStatusCurrentSnapshotKey, buildPublicStatusManifestKey, + LEGACY_PUBLIC_STATUS_REDIS_PREFIX, type PublicStatusManifest, resolvePublicStatusManifestState, } from "./redis-contract"; @@ -25,6 +26,21 @@ interface PublicStatusSnapshotRecord { groups: unknown; } +interface ProjectionReadResult { + prefix?: string; + selectedManifest: PublicStatusManifest; + resolution: ReturnType; + snapshot: PublicStatusSnapshotRecord; +} + +interface ProjectionReadMiss { + reason: "manifest-missing" | "snapshot-missing"; +} + +type ProjectionReadOutcome = + | { ok: true; projection: ProjectionReadResult } + | { ok: false; miss: ProjectionReadMiss }; + async function safeGet(redis: RedisReader, key: string): Promise { try { return await redis.get(key); @@ -178,6 +194,92 @@ function sanitizeGroupSnapshots(input: unknown): PublicStatusGroupSnapshot[] { }); } +async function readProjection(input: { + redis: RedisReader; + intervalMinutes: number; + rangeHours: number; + nowIso: string; + configVersion?: string; + prefix?: string; +}): Promise { + const manifestConfigVersion = input.configVersion ?? "current"; + const manifest = parseJson( + await safeGet( + input.redis, + buildPublicStatusManifestKey({ + configVersion: manifestConfigVersion, + intervalMinutes: input.intervalMinutes, + rangeHours: input.rangeHours, + prefix: input.prefix, + }) + ) + ); + const currentManifest = + manifestConfigVersion === "current" + ? manifest + : parseJson( + await safeGet( + input.redis, + buildPublicStatusManifestKey({ + configVersion: "current", + intervalMinutes: input.intervalMinutes, + rangeHours: input.rangeHours, + prefix: input.prefix, + }) + ) + ); + + let selectedManifest = manifest; + let resolution = resolvePublicStatusManifestState(selectedManifest, input.nowIso); + + if (!resolution.sourceGeneration && currentManifest) { + selectedManifest = currentManifest; + resolution = { + ...resolvePublicStatusManifestState(currentManifest, input.nowIso), + rebuildState: "stale", + }; + } + + if (!selectedManifest || !resolution.sourceGeneration) { + return { ok: false, miss: { reason: "manifest-missing" } }; + } + + const snapshotKey = buildPublicStatusCurrentSnapshotKey({ + intervalMinutes: input.intervalMinutes, + rangeHours: input.rangeHours, + generation: resolution.sourceGeneration, + prefix: input.prefix, + }); + const snapshot = parseJson(await safeGet(input.redis, snapshotKey)); + + if (!snapshot) { + return { ok: false, miss: { reason: "snapshot-missing" } }; + } + + return { + ok: true, + projection: { + prefix: input.prefix, + selectedManifest, + resolution, + snapshot, + }, + }; +} + +function projectionToPayload(input: { + projection: ProjectionReadResult; + rebuildState?: PublicStatusPayload["rebuildState"]; +}): PublicStatusPayload { + return { + rebuildState: input.rebuildState ?? input.projection.resolution.rebuildState, + sourceGeneration: input.projection.snapshot.sourceGeneration, + generatedAt: input.projection.snapshot.generatedAt, + freshUntil: input.projection.snapshot.freshUntil, + groups: sanitizeGroupSnapshots(input.projection.snapshot.groups), + }; +} + export async function readPublicStatusPayload(input: { intervalMinutes: number; rangeHours: number; @@ -197,64 +299,99 @@ export async function readPublicStatusPayload(input: { return buildRebuildingPayload(); } - const manifestKey = buildPublicStatusManifestKey({ - configVersion: input.configVersion ?? "current", - intervalMinutes: input.intervalMinutes, - rangeHours: input.rangeHours, - }); - const manifest = parseJson(await safeGet(redis, manifestKey)); - const currentManifestKey = buildPublicStatusManifestKey({ - configVersion: "current", + const primaryRead = await readProjection({ + redis, intervalMinutes: input.intervalMinutes, rangeHours: input.rangeHours, + nowIso: input.nowIso, + configVersion: input.configVersion, }); - const currentManifest = parseJson(await safeGet(redis, currentManifestKey)); - let selectedManifest = manifest; - let resolution = resolvePublicStatusManifestState(selectedManifest, input.nowIso); - - if (!resolution.sourceGeneration && currentManifest) { - selectedManifest = currentManifest; - resolution = { - ...resolvePublicStatusManifestState(currentManifest, input.nowIso), - rebuildState: "stale", - }; + let projection = primaryRead.ok ? primaryRead.projection : null; + let miss = primaryRead.ok ? null : primaryRead.miss; + + if (!projection) { + const legacyRead = await readProjection({ + redis, + intervalMinutes: input.intervalMinutes, + rangeHours: input.rangeHours, + nowIso: input.nowIso, + configVersion: input.configVersion, + prefix: LEGACY_PUBLIC_STATUS_REDIS_PREFIX, + }); + projection = legacyRead.ok ? legacyRead.projection : null; + if (!legacyRead.ok && miss?.reason !== "snapshot-missing") { + miss = legacyRead.miss; + } } - if (!resolution.sourceGeneration) { - await input.triggerRebuildHint("manifest-missing"); + if (!projection) { + await input.triggerRebuildHint(miss?.reason ?? "manifest-missing"); return buildRebuildingPayload(); } - const snapshotKey = buildPublicStatusCurrentSnapshotKey({ - intervalMinutes: input.intervalMinutes, - rangeHours: input.rangeHours, - generation: resolution.sourceGeneration, - }); - const snapshot = parseJson(await safeGet(redis, snapshotKey)); + if ( + projection.prefix !== LEGACY_PUBLIC_STATUS_REDIS_PREFIX && + projection.selectedManifest.rollupCoverageComplete === false + ) { + const legacyRead = await readProjection({ + redis, + intervalMinutes: input.intervalMinutes, + rangeHours: input.rangeHours, + nowIso: input.nowIso, + configVersion: input.configVersion, + prefix: LEGACY_PUBLIC_STATUS_REDIS_PREFIX, + }); + + if (legacyRead.ok) { + await input.triggerRebuildHint("rollup-coverage-incomplete"); + await input.triggerRebuildHint("legacy-generation"); + if ( + input.configVersion && + legacyRead.projection.selectedManifest.configVersion !== input.configVersion + ) { + await input.triggerRebuildHint("config-version-mismatch"); + } + return projectionToPayload({ + projection: legacyRead.projection, + rebuildState: "stale", + }); + } - if (!snapshot) { - await input.triggerRebuildHint("snapshot-missing"); - return buildRebuildingPayload(); + await input.triggerRebuildHint("rollup-coverage-incomplete"); + if (input.configVersion && projection.selectedManifest.configVersion !== input.configVersion) { + await input.triggerRebuildHint("config-version-mismatch"); + } + return projectionToPayload({ + projection, + rebuildState: "stale", + }); } - if (resolution.rebuildState !== "fresh") { + if ( + projection.resolution.rebuildState !== "fresh" || + projection.prefix === LEGACY_PUBLIC_STATUS_REDIS_PREFIX + ) { await input.triggerRebuildHint("stale-generation"); } - if (input.configVersion && selectedManifest?.configVersion !== input.configVersion) { + if (projection.prefix === LEGACY_PUBLIC_STATUS_REDIS_PREFIX) { + await input.triggerRebuildHint("legacy-generation"); + } + + if (input.configVersion && projection.selectedManifest.configVersion !== input.configVersion) { await input.triggerRebuildHint("config-version-mismatch"); - resolution = { - ...resolution, + return projectionToPayload({ + projection, rebuildState: "stale", - }; + }); } - return { - rebuildState: resolution.rebuildState, - sourceGeneration: snapshot.sourceGeneration, - generatedAt: snapshot.generatedAt, - freshUntil: snapshot.freshUntil, - groups: sanitizeGroupSnapshots(snapshot.groups), - }; + return projectionToPayload({ + projection, + rebuildState: + projection.prefix === LEGACY_PUBLIC_STATUS_REDIS_PREFIX + ? "stale" + : projection.resolution.rebuildState, + }); } diff --git a/src/lib/public-status/rebuild-hints.ts b/src/lib/public-status/rebuild-hints.ts index fe14f0f41..fd997f5ae 100644 --- a/src/lib/public-status/rebuild-hints.ts +++ b/src/lib/public-status/rebuild-hints.ts @@ -58,6 +58,15 @@ export async function schedulePublicStatusRebuild(input: { intervalMinutes: input.intervalMinutes, rangeHours: input.rangeHours, }); + const existingHintTtlMs = typeof redis.pttl === "function" ? Number(await redis.pttl(key)) : -1; + if (Number.isFinite(existingHintTtlMs) && existingHintTtlMs > 0) { + return { + accepted: true, + rebuildState: "rebuilding", + key, + }; + } + await redis.set( key, JSON.stringify({ diff --git a/src/lib/public-status/rebuild-worker.ts b/src/lib/public-status/rebuild-worker.ts index 56f09b9ae..95e36e5ee 100644 --- a/src/lib/public-status/rebuild-worker.ts +++ b/src/lib/public-status/rebuild-worker.ts @@ -1,9 +1,4 @@ import { getRedisClient } from "@/lib/redis"; -import { - buildPublicStatusPayloadFromRequests, - getConfiguredPublicStatusGroups, - queryPublicStatusRequests, -} from "./aggregation"; import { publishCurrentPublicStatusConfigProjection } from "./config-publisher"; import { readCurrentInternalPublicStatusConfigSnapshot } from "./config-snapshot"; import { @@ -12,9 +7,18 @@ import { buildPublicStatusCurrentSnapshotKey, buildPublicStatusManifestKey, buildPublicStatusRebuildLockKey, + buildPublicStatusRollupCoverageStartKey, buildPublicStatusSeriesChunkKey, buildPublicStatusTempKey, } from "./redis-contract"; +import { + assertSupportedPublicStatusRollupInterval, + buildPublicStatusPayloadFromRollups, + buildPublicStatusRollupBucketStarts, + getConfiguredPublicStatusGroupsFromSnapshot, + parsePublicStatusRollupField, + readPublicStatusRollupBuckets, +} from "./rollup-store"; interface PublicStatusRebuildResult { sourceGeneration: string; @@ -28,6 +32,7 @@ const GENERATION_PROJECTION_TTL_SECONDS = 60 * 60 * 24 * 30; interface RedisHintWriter { get?(key: string): Promise | string | null; + hgetall?(key: string): Promise> | Record; set(key: string, value: string, mode: "EX", seconds: number): Promise | unknown; set(key: string, value: string): Promise | unknown; del?(...keys: string[]): Promise | unknown; @@ -84,6 +89,23 @@ function shouldPromoteCurrentManifest( return true; } +function isRollupCoverageComplete(input: { + coverageStartedAt: string | null; + coveredFrom: string; +}): boolean { + if (!input.coverageStartedAt) { + return false; + } + + const coverageStartedAtMs = Date.parse(input.coverageStartedAt); + const coveredFromMs = Date.parse(input.coveredFrom); + return ( + Number.isFinite(coverageStartedAtMs) && + Number.isFinite(coveredFromMs) && + coverageStartedAtMs <= coveredFromMs + ); +} + async function publishPublicStatusProjection(input: { redis: RedisHintWriter; configVersion: string; @@ -93,6 +115,9 @@ async function publishPublicStatusProjection(input: { generatedAt: string; coveredFrom: string; coveredTo: string; + rollupCoverageStartedAt: string | null; + rollupCoverageComplete: boolean; + rollupSampleCount: number; groups: unknown; }): Promise { const snapshotKey = buildPublicStatusCurrentSnapshotKey({ @@ -149,6 +174,9 @@ async function publishPublicStatusProjection(input: { freshUntil: snapshotRecord.freshUntil, rebuildState: "idle" as const, lastCompleteGeneration: input.sourceGeneration, + rollupCoverageStartedAt: input.rollupCoverageStartedAt, + rollupCoverageComplete: input.rollupCoverageComplete, + rollupSampleCount: input.rollupSampleCount, }; await setWithTtl( @@ -280,6 +308,7 @@ export async function rebuildPublicStatusProjection(input: { | { status: "skipped"; reason: "distributed-lock-held"; sourceGeneration: string } | { status: "updated"; sourceGeneration: string } > { + assertSupportedPublicStatusRollupInterval(input.intervalMinutes); const redis = getReadyRedisClient(input.redis); if (!redis) { return { status: "disabled", reason: "redis-unavailable" }; @@ -287,12 +316,17 @@ export async function rebuildPublicStatusProjection(input: { if (typeof redis.get !== "function") { return { status: "disabled", reason: "redis-unavailable" }; } + if (typeof redis.hgetall !== "function") { + return { status: "disabled", reason: "redis-unavailable" }; + } const redisReader = redis as RedisHintWriter & { get(key: string): Promise | string | null; + hgetall(key: string): Promise> | Record; }; let configSnapshot = await readCurrentInternalPublicStatusConfigSnapshot({ redis: redisReader, + allowLegacyFallback: false, }); if (!configSnapshot) { try { @@ -302,6 +336,7 @@ export async function rebuildPublicStatusProjection(input: { if (publishResult.written) { configSnapshot = await readCurrentInternalPublicStatusConfigSnapshot({ redis: redisReader, + allowLegacyFallback: false, }); } } catch { @@ -312,7 +347,7 @@ export async function rebuildPublicStatusProjection(input: { return { status: "disabled", reason: "missing-config" }; } - const groups = getConfiguredPublicStatusGroups(configSnapshot); + const groups = getConfiguredPublicStatusGroupsFromSnapshot(configSnapshot); if (groups.length === 0) { return { status: "disabled", reason: "no-configured-groups" }; } @@ -348,17 +383,36 @@ export async function rebuildPublicStatusProjection(input: { } try { - const requests = await queryPublicStatusRequests({ - groups, - coveredFrom: new Date(coveredFrom), - coveredTo: new Date(coveredTo), + const rollupBuckets = await readPublicStatusRollupBuckets({ + redis: redisReader, + bucketStarts: buildPublicStatusRollupBucketStarts({ + rangeHours: input.rangeHours, + intervalMinutes: input.intervalMinutes, + now: new Date(coveredTo), + }), + }); + const rollupCoverageStartedAt = await redisReader.get( + buildPublicStatusRollupCoverageStartKey() + ); + const rollupSampleCount = rollupBuckets.reduce((sum, bucket) => { + for (const [field, value] of bucket.values) { + const parsedField = parsePublicStatusRollupField(field); + if (parsedField?.metric === "success" || parsedField?.metric === "failure") { + sum += value; + } + } + return sum; + }, 0); + const rollupCoverageComplete = isRollupCoverageComplete({ + coverageStartedAt: rollupCoverageStartedAt, + coveredFrom, }); - const aggregation = buildPublicStatusPayloadFromRequests({ + const aggregation = buildPublicStatusPayloadFromRollups({ rangeHours: input.rangeHours, intervalMinutes: input.intervalMinutes, now: new Date(coveredTo), groups, - requests, + rollupBuckets, }); await publishPublicStatusProjection({ @@ -370,6 +424,9 @@ export async function rebuildPublicStatusProjection(input: { generatedAt: aggregation.generatedAt, coveredFrom: aggregation.coveredFrom, coveredTo: aggregation.coveredTo, + rollupCoverageStartedAt, + rollupCoverageComplete, + rollupSampleCount, groups: aggregation.groups, }); diff --git a/src/lib/public-status/redis-contract.ts b/src/lib/public-status/redis-contract.ts index a0188ab77..b3e01e857 100644 --- a/src/lib/public-status/redis-contract.ts +++ b/src/lib/public-status/redis-contract.ts @@ -1,6 +1,8 @@ import { createHash } from "node:crypto"; -const PUBLIC_STATUS_REDIS_PREFIX = "public-status:v1"; +export const PUBLIC_STATUS_REDIS_PREFIX = "public-status:v2"; +export const LEGACY_PUBLIC_STATUS_REDIS_PREFIX = "public-status:v1"; +export const PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES = 5; export type PublicStatusServeState = "fresh" | "stale" | "rebuilding" | "no-data"; @@ -16,6 +18,9 @@ export interface PublicStatusManifest { freshUntil: string; rebuildState: "idle" | "rebuilding"; lastCompleteGeneration: string | null; + rollupCoverageStartedAt?: string | null; + rollupCoverageComplete?: boolean; + rollupSampleCount?: number; } export interface PublicStatusManifestResolution { @@ -65,27 +70,38 @@ export function buildGenerationFingerprint(input: { return createHash("sha1").update(fingerprint).digest("hex").slice(0, 16); } -export function buildPublicStatusConfigSnapshotKey(configVersion = "current"): string { - return `${PUBLIC_STATUS_REDIS_PREFIX}:config:${encodeKeyPart(configVersion)}`; +function resolvePrefix(prefix?: string): string { + return prefix ?? PUBLIC_STATUS_REDIS_PREFIX; } -export function buildPublicStatusInternalConfigSnapshotKey(configVersion = "current"): string { - return `${PUBLIC_STATUS_REDIS_PREFIX}:config-internal:${encodeKeyPart(configVersion)}`; +export function buildPublicStatusConfigSnapshotKey( + configVersion = "current", + options?: { prefix?: string } +): string { + return `${resolvePrefix(options?.prefix)}:config:${encodeKeyPart(configVersion)}`; } -export function buildPublicStatusConfigVersionPointerKey(): string { - return `${PUBLIC_STATUS_REDIS_PREFIX}:config-version:current`; +export function buildPublicStatusInternalConfigSnapshotKey( + configVersion = "current", + options?: { prefix?: string } +): string { + return `${resolvePrefix(options?.prefix)}:config-internal:${encodeKeyPart(configVersion)}`; +} + +export function buildPublicStatusConfigVersionPointerKey(options?: { prefix?: string }): string { + return `${resolvePrefix(options?.prefix)}:config-version:current`; } export function buildPublicStatusManifestKey(input: { configVersion: string; intervalMinutes: number; rangeHours: number; + prefix?: string; }): string { assertPositiveInteger(input.intervalMinutes, "intervalMinutes"); assertPositiveInteger(input.rangeHours, "rangeHours"); return [ - PUBLIC_STATUS_REDIS_PREFIX, + resolvePrefix(input.prefix), "manifest", encodeKeyPart(input.configVersion), `${input.intervalMinutes}m`, @@ -97,11 +113,12 @@ export function buildPublicStatusCurrentSnapshotKey(input: { intervalMinutes: number; rangeHours: number; generation: string; + prefix?: string; }): string { assertPositiveInteger(input.intervalMinutes, "intervalMinutes"); assertPositiveInteger(input.rangeHours, "rangeHours"); return [ - PUBLIC_STATUS_REDIS_PREFIX, + resolvePrefix(input.prefix), "snapshot", encodeKeyPart(input.generation), `${input.intervalMinutes}m`, @@ -114,10 +131,11 @@ export function buildPublicStatusSeriesChunkKey(input: { generation: string; bucketStartIso: string; bucketEndIso: string; + prefix?: string; }): string { assertPositiveInteger(input.intervalMinutes, "intervalMinutes"); return [ - PUBLIC_STATUS_REDIS_PREFIX, + resolvePrefix(input.prefix), "series", encodeKeyPart(input.generation), `${input.intervalMinutes}m`, @@ -126,17 +144,46 @@ export function buildPublicStatusSeriesChunkKey(input: { ].join(":"); } -export function buildPublicStatusRebuildLockKey(flightKey: string): string { - return `${PUBLIC_STATUS_REDIS_PREFIX}:rebuild-lock:${encodeKeyPart(flightKey)}`; +export function buildPublicStatusRollupKey(input: { + bucketStartIso: string; + bucketMinutes?: number; + prefix?: string; +}): string { + const bucketMinutes = input.bucketMinutes ?? PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES; + assertPositiveInteger(bucketMinutes, "bucketMinutes"); + // alignBucketStartUtc 会按 UTC 向下对齐到最近的 bucketMinutes 边界。 + return [ + resolvePrefix(input.prefix), + "rollup", + `${bucketMinutes}m`, + encodeKeyPart(alignBucketStartUtc(input.bucketStartIso, bucketMinutes)), + ].join(":"); +} + +export function buildPublicStatusRollupCoverageStartKey(options?: { + bucketMinutes?: number; + prefix?: string; +}): string { + const bucketMinutes = options?.bucketMinutes ?? PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES; + assertPositiveInteger(bucketMinutes, "bucketMinutes"); + return `${resolvePrefix(options?.prefix)}:rollup:coverage-start:${bucketMinutes}m`; +} + +export function buildPublicStatusRebuildLockKey( + flightKey: string, + options?: { prefix?: string } +): string { + return `${resolvePrefix(options?.prefix)}:rebuild-lock:${encodeKeyPart(flightKey)}`; } export function buildPublicStatusRebuildHintKey(input: { intervalMinutes: number; rangeHours: number; + prefix?: string; }): string { assertPositiveInteger(input.intervalMinutes, "intervalMinutes"); assertPositiveInteger(input.rangeHours, "rangeHours"); - return `${PUBLIC_STATUS_REDIS_PREFIX}:rebuild-hint:${input.intervalMinutes}m:${input.rangeHours}h`; + return `${resolvePrefix(input.prefix)}:rebuild-hint:${input.intervalMinutes}m:${input.rangeHours}h`; } export function buildPublicStatusTempKey(baseKey: string, nonce: string): string { diff --git a/src/lib/public-status/rollup-store.ts b/src/lib/public-status/rollup-store.ts new file mode 100644 index 000000000..275e15edc --- /dev/null +++ b/src/lib/public-status/rollup-store.ts @@ -0,0 +1,819 @@ +import { logger } from "@/lib/logger"; +import type { PublicStatusConfiguredGroup } from "@/lib/public-status/aggregation-core"; +import { computeTokensPerSecond } from "@/lib/public-status/aggregation-core"; +import { + type InternalPublicStatusConfigSnapshot, + readCurrentInternalPublicStatusConfigSnapshot, +} from "@/lib/public-status/config-snapshot"; +import { PUBLIC_STATUS_INTERVAL_OPTIONS } from "@/lib/public-status/constants"; +import type { PublicStatusPayload, PublicStatusTimelineBucket } from "@/lib/public-status/payload"; +import { + alignBucketStartUtc, + buildPublicStatusRollupCoverageStartKey, + buildPublicStatusRollupKey, + PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES, +} from "@/lib/public-status/redis-contract"; +import { getRedisClient } from "@/lib/redis"; +import { + classifyProviderChainItemOutcome, + resolveSuccessRateModelKey, +} from "@/lib/request-outcome"; +import { resolveProviderGroupsWithDefault } from "@/lib/utils/provider-group"; +import type { ProviderChainItem } from "@/types/message"; + +const ROLLUP_FIELD_SEPARATOR = "|"; +const ROLLUP_TTL_SECONDS = 60 * 60 * 24 * 32; +const CONFIGURED_GROUPS_CACHE_TTL_MS = 30_000; +const EMPTY_CONFIGURED_GROUPS_CACHE_TTL_MS = 5_000; + +export type PublicStatusRollupMetric = + | "success" + | "failure" + | "ttfb_sum" + | "ttfb_count" + | "tps_sum" + | "tps_count"; + +export interface PublicStatusRollupEvent { + createdAt: string | Date; + model?: string | null; + originalModel?: string | null; + durationMs?: number | null; + ttfbMs?: number | null; + outputTokens?: number | null; + providerChain?: ProviderChainItem[] | null; +} + +export interface PublicStatusRollupIncrement { + groupId: string; + modelKey: string; + metric: PublicStatusRollupMetric; + value: number; +} + +export interface PublicStatusRollupBucket { + bucketStart: string; + values: Map; +} + +export interface PublicStatusRollupAggregationResult { + generatedAt: string; + coveredFrom: string; + coveredTo: string; + groups: PublicStatusPayload["groups"]; +} + +export type PublicStatusRollupWriteResult = + | { + written: true; + retryable: false; + incrementCount: number; + key: string; + } + | { + written: false; + retryable: boolean; + reason: "ignored" | "redis-unavailable" | "write-failed"; + incrementCount: number; + key: string | null; + }; + +interface RedisRollupWriter { + hincrbyfloat?(key: string, field: string, increment: number): Promise | unknown; + expire?(key: string, seconds: number): Promise | unknown; + pipeline?(): { + hincrbyfloat(key: string, field: string, increment: number): unknown; + set?(key: string, value: string, mode: "NX"): unknown; + expire(key: string, seconds: number): unknown; + exec(): Promise | null> | Array<[Error | null, unknown]> | null; + }; + set?(key: string, value: string, mode?: "NX"): Promise | unknown; + status?: string; +} + +interface RedisRollupReader { + hgetall(key: string): Promise> | Record; + pipeline?(): { + hgetall(key: string): unknown; + exec(): Promise | null>; + }; + status?: string; +} + +let cachedConfiguredGroups: { + configVersion: string; + groups: PublicStatusConfiguredGroup[]; + retryable: boolean; + expiresAt: number; +} | null = null; + +function encodeRollupPart(value: string | number): string { + return encodeURIComponent(String(value)); +} + +function decodeRollupPart(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +export function assertSupportedPublicStatusRollupInterval(intervalMinutes: number): void { + if ( + !PUBLIC_STATUS_INTERVAL_OPTIONS.includes( + intervalMinutes as (typeof PUBLIC_STATUS_INTERVAL_OPTIONS)[number] + ) + ) { + throw new Error( + `Unsupported public status rollup intervalMinutes: ${intervalMinutes}. Supported values: ${PUBLIC_STATUS_INTERVAL_OPTIONS.join( + ", " + )}` + ); + } +} + +export function buildPublicStatusRollupField(input: { + groupId: string | number; + modelKey: string; + metric: PublicStatusRollupMetric; +}): string { + return [ + encodeRollupPart(input.groupId), + encodeRollupPart(input.modelKey), + encodeRollupPart(input.metric), + ].join(ROLLUP_FIELD_SEPARATOR); +} + +export function parsePublicStatusRollupField( + field: string +): { groupId: string; modelKey: string; metric: PublicStatusRollupMetric } | null { + const parts = field.split(ROLLUP_FIELD_SEPARATOR); + if (parts.length !== 3) { + return null; + } + + const metric = decodeRollupPart(parts[2] ?? ""); + if ( + metric !== "success" && + metric !== "failure" && + metric !== "ttfb_sum" && + metric !== "ttfb_count" && + metric !== "tps_sum" && + metric !== "tps_count" + ) { + return null; + } + + return { + groupId: decodeRollupPart(parts[0] ?? ""), + modelKey: decodeRollupPart(parts[1] ?? ""), + metric, + }; +} + +function normalizeNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function getPublicStatusGroupId( + input: Pick +): string { + return input.sourceGroupId !== undefined && input.sourceGroupId !== null + ? String(input.sourceGroupId) + : input.sourceGroupName; +} + +function getPublicStatusGroupRollupIds( + input: Pick +): string[] { + const primary = getPublicStatusGroupId(input); + return [primary]; +} + +function buildConfiguredGroupLookups(groups: PublicStatusConfiguredGroup[]): { + modelToGroups: Map; + groupsBySourceName: Map; + groupsByRollupId: Map; +} { + const modelToGroups = new Map(); + const groupsBySourceName = new Map(); + const groupsByRollupId = new Map(); + + for (const group of groups) { + groupsBySourceName.set(group.sourceGroupName, group); + groupsByRollupId.set(getPublicStatusGroupId(group), group); + for (const model of group.models) { + const existing = modelToGroups.get(model.publicModelKey) ?? []; + existing.push(group); + modelToGroups.set(model.publicModelKey, existing); + } + } + + return { modelToGroups, groupsBySourceName, groupsByRollupId }; +} + +export function getConfiguredPublicStatusGroupsFromSnapshot( + snapshot: InternalPublicStatusConfigSnapshot +): PublicStatusConfiguredGroup[] { + return snapshot.groups + .filter( + (group) => + typeof group.sourceGroupName === "string" && + group.sourceGroupName.trim().length > 0 && + Array.isArray(group.models) && + group.models.length > 0 + ) + .map((group) => ({ + sourceGroupId: group.sourceGroupId ?? null, + sourceGroupName: group.sourceGroupName.trim(), + publicGroupSlug: group.slug, + displayName: group.displayName, + explanatoryCopy: group.description, + sortOrder: group.sortOrder, + models: group.models.map((model) => ({ + publicModelKey: model.publicModelKey, + label: model.label, + vendorIconKey: model.vendorIconKey, + requestTypeBadge: model.requestTypeBadge, + })), + })) + .sort( + (left, right) => + left.sortOrder - right.sortOrder || left.displayName.localeCompare(right.displayName) + ); +} + +export async function getConfiguredPublicStatusGroupsForRollupResolution(): Promise<{ + groups: PublicStatusConfiguredGroup[]; + retryable: boolean; +}> { + const now = Date.now(); + if (cachedConfiguredGroups && cachedConfiguredGroups.expiresAt > now) { + return { + groups: cachedConfiguredGroups.groups, + retryable: cachedConfiguredGroups.retryable, + }; + } + + const snapshot = await readCurrentInternalPublicStatusConfigSnapshot({ + allowLegacyFallback: false, + }); + if (!snapshot) { + cachedConfiguredGroups = { + configVersion: "", + groups: [], + retryable: true, + expiresAt: now + EMPTY_CONFIGURED_GROUPS_CACHE_TTL_MS, + }; + return { groups: [], retryable: true }; + } + + const groups = getConfiguredPublicStatusGroupsFromSnapshot(snapshot); + cachedConfiguredGroups = { + configVersion: snapshot.configVersion, + groups, + retryable: false, + expiresAt: now + CONFIGURED_GROUPS_CACHE_TTL_MS, + }; + return { groups, retryable: false }; +} + +export async function getConfiguredPublicStatusGroupsForRollup(): Promise< + PublicStatusConfiguredGroup[] +> { + return (await getConfiguredPublicStatusGroupsForRollupResolution()).groups; +} + +export function buildPublicStatusRollupIncrements(input: { + event: PublicStatusRollupEvent; + groups: PublicStatusConfiguredGroup[]; +}): PublicStatusRollupIncrement[] { + const modelKey = resolveSuccessRateModelKey({ + originalModel: input.event.originalModel, + model: input.event.model, + }); + if (!modelKey) { + return []; + } + + const { modelToGroups, groupsBySourceName, groupsByRollupId } = buildConfiguredGroupLookups( + input.groups + ); + const configuredGroups = modelToGroups.get(modelKey); + if (!configuredGroups || configuredGroups.length === 0) { + return []; + } + + const groupOutcome = new Map(); + for (const item of input.event.providerChain ?? []) { + const outcome = classifyProviderChainItemOutcome({ + statusCode: item.statusCode ?? undefined, + reason: item.reason ?? undefined, + errorMessage: item.errorMessage ?? undefined, + errorDetails: item.errorDetails, + })?.outcome; + if (!outcome) { + continue; + } + + const itemGroups = Array.from(new Set(resolveProviderGroupsWithDefault(item.groupTag))); + for (const sourceGroupName of itemGroups) { + if (!groupsBySourceName.has(sourceGroupName)) { + continue; + } + + const existing = groupOutcome.get(sourceGroupName); + if (existing === "success") { + continue; + } + + if (outcome === "success") { + groupOutcome.set(sourceGroupName, "success"); + continue; + } + + if (!existing || existing === "excluded") { + groupOutcome.set(sourceGroupName, outcome); + } + } + } + + const ttfbMs = normalizeNumber(input.event.ttfbMs); + const tps = computeTokensPerSecond({ + outputTokens: input.event.outputTokens, + durationMs: input.event.durationMs, + ttfbMs, + }); + const increments: PublicStatusRollupIncrement[] = []; + + for (const [sourceGroupName, outcome] of groupOutcome.entries()) { + if (outcome === "excluded") { + continue; + } + + const group = groupsBySourceName.get(sourceGroupName); + if (!group?.models.some((model) => model.publicModelKey === modelKey)) { + continue; + } + + const groupId = getPublicStatusGroupId(group); + if (groupsByRollupId.get(groupId) !== group) { + continue; + } + increments.push({ + groupId, + modelKey, + metric: outcome === "success" ? "success" : "failure", + value: 1, + }); + if (outcome === "success" && ttfbMs !== null) { + increments.push( + { groupId, modelKey, metric: "ttfb_sum", value: ttfbMs }, + { groupId, modelKey, metric: "ttfb_count", value: 1 } + ); + } + if (outcome === "success" && tps !== null) { + increments.push( + { groupId, modelKey, metric: "tps_sum", value: tps }, + { groupId, modelKey, metric: "tps_count", value: 1 } + ); + } + } + + return increments; +} + +export async function writePublicStatusRollupEvent(input: { + event: PublicStatusRollupEvent; + groups: PublicStatusConfiguredGroup[]; + redis?: RedisRollupWriter | null; +}): Promise { + const increments = buildPublicStatusRollupIncrements(input); + if (increments.length === 0) { + return { + written: false, + retryable: false, + reason: "ignored", + incrementCount: 0, + key: null, + }; + } + + const createdAtIso = + input.event.createdAt instanceof Date + ? input.event.createdAt.toISOString() + : input.event.createdAt; + const key = buildPublicStatusRollupKey({ bucketStartIso: createdAtIso }); + const coverageStartKey = buildPublicStatusRollupCoverageStartKey(); + const bucketStartIso = alignBucketStartUtc(createdAtIso, PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES); + const redis = input.redis ?? getRedisClient({ allowWhenRateLimitDisabled: true }); + if ( + !redis || + ("status" in redis && redis.status && redis.status !== "ready") || + typeof redis.hincrbyfloat !== "function" + ) { + return { + written: false, + retryable: true, + reason: "redis-unavailable", + incrementCount: increments.length, + key, + }; + } + + if (typeof redis.pipeline === "function") { + const pipeline = redis.pipeline(); + const pipelineOperationLabels: string[] = []; + for (const increment of increments) { + const field = buildPublicStatusRollupField(increment); + pipeline.hincrbyfloat(key, field, increment.value); + pipelineOperationLabels.push(field); + } + if (typeof pipeline.set === "function") { + pipeline.set(coverageStartKey, bucketStartIso, "NX"); + pipelineOperationLabels.push(coverageStartKey); + pipeline.expire(coverageStartKey, ROLLUP_TTL_SECONDS); + pipelineOperationLabels.push(`${coverageStartKey}:expire`); + } + pipeline.expire(key, ROLLUP_TTL_SECONDS); + pipelineOperationLabels.push(`${key}:expire`); + const results = await pipeline.exec(); + if (!results) { + throw new Error(`Public status rollup pipeline failed for ${key}: empty exec result`); + } + const failures = results?.flatMap(([error], index) => (error ? [{ error, index }] : [])) ?? []; + if (failures.length > 0) { + const firstFailure = failures[0]!; + const failedField = + pipelineOperationLabels[firstFailure.index] ?? `${key}:pipeline:${firstFailure.index}`; + throw new Error( + `Public status rollup pipeline failed for ${failedField}: ${firstFailure.error.message}` + ); + } + } else { + for (const increment of increments) { + const field = buildPublicStatusRollupField(increment); + await redis.hincrbyfloat(key, field, increment.value); + } + if (typeof redis.set === "function") { + await redis.set(coverageStartKey, bucketStartIso, "NX"); + } + await redis.expire?.(key, ROLLUP_TTL_SECONDS); + await redis.expire?.(coverageStartKey, ROLLUP_TTL_SECONDS); + } + + return { written: true, retryable: false, incrementCount: increments.length, key }; +} + +export function queuePublicStatusRollupWrite(input: { + event: PublicStatusRollupEvent; + groups: PublicStatusConfiguredGroup[]; +}): Promise { + return writePublicStatusRollupEvent(input).catch((error) => { + logger.warn("[PublicStatus] Failed to write rollup event", { + error: error instanceof Error ? error.message : String(error), + }); + return { + written: false, + retryable: true, + reason: "write-failed", + incrementCount: 0, + key: null, + }; + }); +} + +export async function readPublicStatusRollupBuckets(input: { + redis: RedisRollupReader; + bucketStarts: string[]; +}): Promise { + const parseBucket = (bucketStart: string, raw: unknown): PublicStatusRollupBucket => { + const values = new Map(); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return { bucketStart, values }; + } + + for (const [field, rawValue] of Object.entries(raw as Record)) { + const value = Number(rawValue); + if (Number.isFinite(value)) { + values.set(field, value); + } + } + return { bucketStart, values }; + }; + + if (typeof input.redis.pipeline === "function") { + const buckets: PublicStatusRollupBucket[] = []; + const batchSize = 200; + for (let index = 0; index < input.bucketStarts.length; index += batchSize) { + const batchStarts = input.bucketStarts.slice(index, index + batchSize); + const pipeline = input.redis.pipeline(); + for (const bucketStart of batchStarts) { + pipeline.hgetall(buildPublicStatusRollupKey({ bucketStartIso: bucketStart })); + } + + const results = await pipeline.exec(); + for (let batchIndex = 0; batchIndex < batchStarts.length; batchIndex++) { + const [error, raw] = results?.[batchIndex] ?? [null, null]; + buckets.push(parseBucket(batchStarts[batchIndex]!, error ? null : raw)); + } + } + return buckets; + } + + const buckets: PublicStatusRollupBucket[] = []; + const concurrency = 32; + for (let index = 0; index < input.bucketStarts.length; index += concurrency) { + const batchStarts = input.bucketStarts.slice(index, index + concurrency); + const batchBuckets = await Promise.all( + batchStarts.map(async (bucketStart) => + parseBucket( + bucketStart, + await input.redis.hgetall(buildPublicStatusRollupKey({ bucketStartIso: bucketStart })) + ) + ) + ); + buckets.push(...batchBuckets); + } + + return buckets; +} + +function getRollupValue(input: { + bucket: PublicStatusRollupBucket; + groupId: string; + modelKey: string; + metric: PublicStatusRollupMetric; +}): number { + return ( + input.bucket.values.get( + buildPublicStatusRollupField({ + groupId: input.groupId, + modelKey: input.modelKey, + metric: input.metric, + }) + ) ?? 0 + ); +} + +function applyBoundedGapFill(input: { + timeline: Array<"operational" | "failed" | null>; + maxGapBuckets?: number; +}): Array<"operational" | "failed" | null> { + const result = [...input.timeline]; + const maxGapBuckets = input.maxGapBuckets ?? 3; + + let lastKnownIndex = -1; + for (let index = 0; index < input.timeline.length; index++) { + const current = input.timeline[index]; + if (current === null) { + continue; + } + + if (lastKnownIndex >= 0) { + const previous = input.timeline[lastKnownIndex]; + const gapBuckets = index - lastKnownIndex - 1; + if ( + gapBuckets > 0 && + gapBuckets <= maxGapBuckets && + previous !== null && + previous === current + ) { + for (let fillIndex = lastKnownIndex + 1; fillIndex < index; fillIndex++) { + result[fillIndex] = previous; + } + } + } + + lastKnownIndex = index; + } + + return result; +} + +function average(sum: number, count: number): number | null { + if (!Number.isFinite(sum) || !Number.isFinite(count) || count <= 0) { + return null; + } + return Number((sum / count).toFixed(4)); +} + +function buildBucketStarts(input: { + now: string | Date; + rangeHours: number; + intervalMinutes: number; +}): { coveredFrom: string; coveredTo: string; bucketStarts: string[] } { + assertSupportedPublicStatusRollupInterval(input.intervalMinutes); + const now = input.now instanceof Date ? input.now : new Date(input.now); + const baseBucketMs = PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES * 60 * 1000; + const bucketCount = Math.ceil((input.rangeHours * 60) / PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES); + const coveredTo = alignBucketStartUtc(now.toISOString(), input.intervalMinutes); + const coveredToMs = Date.parse(coveredTo); + const coveredFromMs = coveredToMs - bucketCount * baseBucketMs; + + return { + coveredFrom: new Date(coveredFromMs).toISOString(), + coveredTo, + bucketStarts: Array.from({ length: bucketCount }, (_, index) => + new Date(coveredFromMs + index * baseBucketMs).toISOString() + ), + }; +} + +export function buildPublicStatusPayloadFromRollups(input: { + rangeHours: number; + intervalMinutes: number; + now: string | Date; + groups: PublicStatusConfiguredGroup[]; + rollupBuckets: PublicStatusRollupBucket[]; +}): PublicStatusRollupAggregationResult { + const { coveredFrom, coveredTo, bucketStarts } = buildBucketStarts(input); + const bucketByStart = new Map(input.rollupBuckets.map((bucket) => [bucket.bucketStart, bucket])); + const intervalFactor = Math.max( + 1, + Math.round(input.intervalMinutes / PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES) + ); + const displayBuckets = bucketStarts.flatMap((bucketStart, index) => + index % intervalFactor === 0 ? [{ bucketStart, index }] : [] + ); + + const groups = input.groups.map((group) => { + const groupIds = getPublicStatusGroupRollupIds(group); + const models = group.models.map((model) => { + const aggregateBuckets = displayBuckets.map((displayBucket) => { + const slice = bucketStarts.slice(displayBucket.index, displayBucket.index + intervalFactor); + return slice.reduce( + (acc, bucketStart) => { + const bucket = bucketByStart.get(bucketStart); + if (!bucket) { + return acc; + } + + for (const groupId of groupIds) { + acc.successCount += getRollupValue({ + bucket, + groupId, + modelKey: model.publicModelKey, + metric: "success", + }); + acc.failureCount += getRollupValue({ + bucket, + groupId, + modelKey: model.publicModelKey, + metric: "failure", + }); + acc.ttfbSum += getRollupValue({ + bucket, + groupId, + modelKey: model.publicModelKey, + metric: "ttfb_sum", + }); + acc.ttfbCount += getRollupValue({ + bucket, + groupId, + modelKey: model.publicModelKey, + metric: "ttfb_count", + }); + acc.tpsSum += getRollupValue({ + bucket, + groupId, + modelKey: model.publicModelKey, + metric: "tps_sum", + }); + acc.tpsCount += getRollupValue({ + bucket, + groupId, + modelKey: model.publicModelKey, + metric: "tps_count", + }); + } + return acc; + }, + { + bucketStart: displayBucket.bucketStart, + successCount: 0, + failureCount: 0, + ttfbSum: 0, + ttfbCount: 0, + tpsSum: 0, + tpsCount: 0, + } + ); + }); + + const rawTimeline = aggregateBuckets.map((bucket) => { + const total = bucket.successCount + bucket.failureCount; + if (total <= 0) { + return null; + } + return bucket.successCount > 0 ? "operational" : "failed"; + }); + const filledTimeline = applyBoundedGapFill({ timeline: rawTimeline }); + + let latestTtfbMs: number | null = null; + let latestTps: number | null = null; + const timeline: PublicStatusTimelineBucket[] = aggregateBuckets.map((bucket, index) => { + const bucketStartMs = Date.parse(bucket.bucketStart); + const total = bucket.successCount + bucket.failureCount; + const availabilityPct = + total <= 0 + ? filledTimeline[index] === "operational" + ? 100 + : filledTimeline[index] === "failed" + ? 0 + : null + : Number(((bucket.successCount / total) * 100).toFixed(2)); + const ttfbMs = average(bucket.ttfbSum, bucket.ttfbCount); + const tps = average(bucket.tpsSum, bucket.tpsCount); + + if (ttfbMs !== null) { + latestTtfbMs = ttfbMs; + } + if (tps !== null) { + latestTps = tps; + } + + return { + bucketStart: bucket.bucketStart, + bucketEnd: new Date(bucketStartMs + input.intervalMinutes * 60 * 1000).toISOString(), + state: + filledTimeline[index] === "operational" + ? "operational" + : filledTimeline[index] === "failed" + ? "failed" + : "no_data", + availabilityPct, + ttfbMs, + tps, + sampleCount: total, + }; + }); + + const totalSuccess = aggregateBuckets.reduce((sum, bucket) => sum + bucket.successCount, 0); + const totalFailure = aggregateBuckets.reduce((sum, bucket) => sum + bucket.failureCount, 0); + const totalCount = totalSuccess + totalFailure; + const availabilityPct = + totalCount <= 0 ? null : Number(((totalSuccess / totalCount) * 100).toFixed(2)); + const latestKnownBucket = + [...aggregateBuckets].reverse().find((bucket) => { + const total = bucket.successCount + bucket.failureCount; + return total > 0; + }) ?? null; + const latestBucketAvailabilityPct = latestKnownBucket + ? (latestKnownBucket.successCount / + (latestKnownBucket.successCount + latestKnownBucket.failureCount)) * + 100 + : null; + const latestStateRaw = + latestKnownBucket && latestKnownBucket.successCount <= 0 + ? "failed" + : latestBucketAvailabilityPct !== null && latestBucketAvailabilityPct < 50 + ? "degraded" + : ([...filledTimeline].reverse().find((state) => state !== null) ?? null); + + return { + publicModelKey: model.publicModelKey, + label: model.label, + vendorIconKey: model.vendorIconKey, + requestTypeBadge: model.requestTypeBadge, + latestState: + latestStateRaw === "operational" + ? "operational" + : latestStateRaw === "degraded" + ? "degraded" + : latestStateRaw === "failed" + ? "failed" + : "no_data", + availabilityPct, + latestTtfbMs, + latestTps, + timeline, + } satisfies PublicStatusPayload["groups"][number]["models"][number]; + }); + + return { + publicGroupSlug: group.publicGroupSlug, + displayName: group.displayName, + explanatoryCopy: group.explanatoryCopy, + models, + } satisfies PublicStatusPayload["groups"][number]; + }); + + return { + generatedAt: coveredTo, + coveredFrom, + coveredTo, + groups, + }; +} + +export function buildPublicStatusRollupBucketStarts(input: { + now: string | Date; + rangeHours: number; + intervalMinutes: number; +}): string[] { + return buildBucketStarts(input).bucketStarts; +} + +export { PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES }; diff --git a/src/lib/public-status/scheduler.ts b/src/lib/public-status/scheduler.ts index 1bfa8e6de..1edd2e378 100644 --- a/src/lib/public-status/scheduler.ts +++ b/src/lib/public-status/scheduler.ts @@ -10,12 +10,12 @@ import { getRedisClient } from "@/lib/redis"; import { scanPattern } from "@/lib/redis/scan-helper"; import { readCurrentInternalPublicStatusConfigSnapshot } from "./config-snapshot"; import { rebuildPublicStatusProjection } from "./rebuild-worker"; -import { buildPublicStatusManifestKey } from "./redis-contract"; +import { buildPublicStatusManifestKey, PUBLIC_STATUS_REDIS_PREFIX } from "./redis-contract"; const LOCK_KEY = "locks:public-status-rebuild-scheduler"; const TICK_INTERVAL_MS = 30_000; const LOCK_TTL_MS = 30_000; -const REBUILD_HINT_PATTERN = "public-status:v1:rebuild-hint:*"; +const REBUILD_HINT_PATTERN = `${PUBLIC_STATUS_REDIS_PREFIX}:rebuild-hint:*`; const schedulerState = globalThis as unknown as { __CCH_PUBLIC_STATUS_REBUILD_SCHEDULER_STARTED__?: boolean; diff --git a/src/lib/public-status/vendor-icon-key.ts b/src/lib/public-status/vendor-icon-key.ts index f3b03ac49..b283d5e11 100644 --- a/src/lib/public-status/vendor-icon-key.ts +++ b/src/lib/public-status/vendor-icon-key.ts @@ -1,4 +1,4 @@ -import { getModelVendor } from "@/lib/model-vendor-icons"; +import { getModelVendor } from "@/lib/model-vendor-rules"; import type { ProviderType } from "@/types/provider"; export const PUBLIC_STATUS_VENDOR_ICON_KEYS = [ diff --git a/src/repository/message.ts b/src/repository/message.ts index 5ad4467b4..e4e925074 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -5,6 +5,11 @@ import { db } from "@/drizzle/db"; import { keys as keysTable, messageRequest, providers, usageLedger, users } from "@/drizzle/schema"; import { getEnvConfig } from "@/lib/config/env.schema"; import { isLedgerOnlyMode } from "@/lib/ledger-fallback"; +import { logger } from "@/lib/logger"; +import { + getConfiguredPublicStatusGroupsForRollupResolution, + queuePublicStatusRollupWrite, +} from "@/lib/public-status/rollup-store"; import { formatCostForStorage } from "@/lib/utils/currency"; import type { StoredCostBreakdown } from "@/types/cost-breakdown"; import type { CreateMessageRequestData, MessageRequest, ProviderChainItem } from "@/types/message"; @@ -14,6 +19,188 @@ import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; import { toMessageRequest } from "./_shared/transformers"; import { enqueueMessageRequestUpdate } from "./message-write-buffer"; +type PublicStatusRequestSeed = { + createdAt: Date; + model?: string | null; + originalModel?: string | null; + durationMs?: number | null; +}; + +type PublicStatusFinalDetails = { + statusCode?: number; + outputTokens?: number; + ttfbMs?: number | null; + providerChain?: CreateMessageRequestData["provider_chain"]; + errorMessage?: string; + model?: string; +}; + +const publicStatusRequestSeedCache = new Map(); +const PUBLIC_STATUS_REQUEST_SEED_CACHE_MAX_SIZE = 10_000; +const publicStatusFinalizedRequestCache = new Map(); +const PUBLIC_STATUS_FINALIZED_REQUEST_CACHE_MAX_SIZE = 10_000; +const publicStatusInFlightRequestCache = new Set(); + +function rememberPublicStatusRequestSeed(id: number, seed: PublicStatusRequestSeed): void { + publicStatusRequestSeedCache.set(id, seed); + if (publicStatusRequestSeedCache.size <= PUBLIC_STATUS_REQUEST_SEED_CACHE_MAX_SIZE) { + return; + } + + const firstKey = publicStatusRequestSeedCache.keys().next().value as number | undefined; + if (firstKey !== undefined) { + publicStatusRequestSeedCache.delete(firstKey); + } +} + +function peekPublicStatusRequestSeed(id: number): PublicStatusRequestSeed | null { + return publicStatusRequestSeedCache.get(id) ?? null; +} + +function consumePublicStatusRequestSeed(id: number): void { + publicStatusRequestSeedCache.delete(id); +} + +function claimPublicStatusFinalization(id: number): boolean { + if (publicStatusFinalizedRequestCache.has(id)) { + return false; + } + + publicStatusFinalizedRequestCache.set(id, true); + if (publicStatusFinalizedRequestCache.size <= PUBLIC_STATUS_FINALIZED_REQUEST_CACHE_MAX_SIZE) { + return true; + } + + const firstKey = publicStatusFinalizedRequestCache.keys().next().value as number | undefined; + if (firstKey !== undefined) { + publicStatusFinalizedRequestCache.delete(firstKey); + } + return true; +} + +function unclaimPublicStatusFinalization(id: number): void { + publicStatusFinalizedRequestCache.delete(id); +} + +function markPublicStatusRequestInFlight(id: number): boolean { + if (publicStatusInFlightRequestCache.has(id)) { + return false; + } + publicStatusInFlightRequestCache.add(id); + return true; +} + +function clearPublicStatusRequestInFlight(id: number): void { + publicStatusInFlightRequestCache.delete(id); +} + +function updatePublicStatusRequestSeed(id: number, patch: Partial): void { + const seed = publicStatusRequestSeedCache.get(id); + if (!seed) { + return; + } + publicStatusRequestSeedCache.set(id, { ...seed, ...patch }); +} + +function isPublicStatusFinalDetails(details: PublicStatusFinalDetails): boolean { + return details.providerChain !== undefined && details.statusCode !== undefined; +} + +async function readPublicStatusRequestSeedFallback( + id: number +): Promise { + const [row] = await db + .select({ + createdAt: messageRequest.createdAt, + model: messageRequest.model, + originalModel: messageRequest.originalModel, + durationMs: messageRequest.durationMs, + }) + .from(messageRequest) + .where( + and(eq(messageRequest.id, id), isNull(messageRequest.deletedAt), EXCLUDE_WARMUP_CONDITION) + ) + .limit(1); + + if (!row?.createdAt) { + return null; + } + + return { + createdAt: row.createdAt, + model: row.model, + originalModel: row.originalModel, + durationMs: row.durationMs, + }; +} + +function queuePublicStatusRollupForFinalDetails( + id: number, + details: PublicStatusFinalDetails +): void { + if (!isPublicStatusFinalDetails(details) || !markPublicStatusRequestInFlight(id)) { + return; + } + if (!claimPublicStatusFinalization(id)) { + clearPublicStatusRequestInFlight(id); + return; + } + + void (async () => { + try { + const seed = + peekPublicStatusRequestSeed(id) ?? (await readPublicStatusRequestSeedFallback(id)); + if (!seed) { + logger.warn("[MessageRequest] Missing public status rollup request seed", { + messageRequestId: id, + }); + unclaimPublicStatusFinalization(id); + return; + } + + const groupResolution = await getConfiguredPublicStatusGroupsForRollupResolution(); + if (groupResolution.groups.length === 0) { + if (groupResolution.retryable) { + unclaimPublicStatusFinalization(id); + } else { + consumePublicStatusRequestSeed(id); + } + return; + } + + const result = await queuePublicStatusRollupWrite({ + groups: groupResolution.groups, + event: { + createdAt: seed.createdAt, + model: details.model ?? seed.model, + originalModel: seed.originalModel, + durationMs: seed.durationMs, + ttfbMs: details.ttfbMs, + outputTokens: details.outputTokens, + providerChain: details.providerChain, + }, + }); + if (!result.written) { + if (result.retryable) { + unclaimPublicStatusFinalization(id); + } else { + consumePublicStatusRequestSeed(id); + } + return; + } + consumePublicStatusRequestSeed(id); + } catch (error) { + unclaimPublicStatusFinalization(id); + logger.warn("[MessageRequest] Failed to queue public status rollup", { + error: error instanceof Error ? error.message : String(error), + messageRequestId: id, + }); + } finally { + clearPublicStatusRequestInFlight(id); + } + })(); +} + /** * 创建消息请求记录 */ @@ -72,6 +259,13 @@ export async function createMessageRequest( deletedAt: messageRequest.deletedAt, }); + rememberPublicStatusRequestSeed(result.id, { + createdAt: result.createdAt!, + model: result.model, + originalModel: result.originalModel, + durationMs: result.durationMs, + }); + return toMessageRequest(result); } @@ -79,6 +273,7 @@ export async function createMessageRequest( * 更新消息请求的耗时 */ export async function updateMessageRequestDuration(id: number, durationMs: number): Promise { + updatePublicStatusRequestSeed(id, { durationMs }); if (getEnvConfig().MESSAGE_REQUEST_WRITE_MODE === "async") { enqueueMessageRequestUpdate(id, { durationMs }); return; @@ -177,8 +372,14 @@ export async function updateMessageRequestDetails( specialSettings?: CreateMessageRequestData["special_settings"]; // 特殊设置(审计/展示) } ): Promise { + const shouldQueuePublicStatusRollup = + details.providerChain !== undefined && details.statusCode !== undefined; + if (getEnvConfig().MESSAGE_REQUEST_WRITE_MODE === "async") { enqueueMessageRequestUpdate(id, details); + if (shouldQueuePublicStatusRollup) { + queuePublicStatusRollupForFinalDetails(id, details); + } return; } @@ -245,6 +446,9 @@ export async function updateMessageRequestDetails( } await db.update(messageRequest).set(updateData).where(eq(messageRequest.id, id)); + if (shouldQueuePublicStatusRollup) { + queuePublicStatusRollupForFinalDetails(id, details); + } } /** diff --git a/tests/unit/public-status/aggregation.test.ts b/tests/unit/public-status/aggregation.test.ts index aa0c3eb0a..ff10ffa4e 100644 --- a/tests/unit/public-status/aggregation.test.ts +++ b/tests/unit/public-status/aggregation.test.ts @@ -183,6 +183,92 @@ describe("public-status aggregation", () => { expect(model?.timeline.every((bucket) => bucket.sampleCount === 0)).toBe(true); }); + it("attributes latency and throughput only to the successful fallback group", () => { + const result = buildPublicStatusPayloadFromRequests({ + rangeHours: 1, + intervalMinutes: 15, + now: "2026-04-21T11:00:00.000Z", + groups: [ + { + sourceGroupName: "openai", + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: null, + sortOrder: 1, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + }, + ], + }, + { + sourceGroupName: "backup", + publicGroupSlug: "backup", + displayName: "Backup", + explanatoryCopy: null, + sortOrder: 2, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + }, + ], + }, + ], + requests: [ + { + id: 40, + createdAt: "2026-04-21T10:10:00.000Z", + originalModel: "gpt-4.1", + durationMs: 1200, + ttfbMs: 200, + outputTokens: 50, + providerChain: [ + { + id: 401, + name: "failed-provider", + groupTag: "openai", + reason: "retry_failed", + statusCode: 500, + }, + { + id: 402, + name: "successful-provider", + groupTag: "backup", + reason: "request_success", + statusCode: 200, + }, + ], + }, + ], + }); + + const failedModel = result.groups[0]?.models[0]; + const successfulModel = result.groups[1]?.models[0]; + + expect(failedModel?.availabilityPct).toBe(0); + expect(failedModel?.latestTtfbMs).toBeNull(); + expect(failedModel?.latestTps).toBeNull(); + expect(failedModel?.timeline.find((bucket) => bucket.sampleCount > 0)).toMatchObject({ + sampleCount: 1, + ttfbMs: null, + tps: null, + }); + expect(successfulModel?.availabilityPct).toBe(100); + expect(successfulModel?.latestTtfbMs).toBe(200); + expect(successfulModel?.latestTps).toBe(50); + expect(successfulModel?.timeline.find((bucket) => bucket.sampleCount > 0)).toMatchObject({ + sampleCount: 1, + ttfbMs: 200, + tps: 50, + }); + }); + it("uses originalModel before redirected model for grouping", () => { const result = buildPublicStatusPayloadFromRequests({ rangeHours: 1, diff --git a/tests/unit/public-status/config-publisher.test.ts b/tests/unit/public-status/config-publisher.test.ts index b498880db..b537c69f4 100644 --- a/tests/unit/public-status/config-publisher.test.ts +++ b/tests/unit/public-status/config-publisher.test.ts @@ -255,6 +255,7 @@ describe("public-status config publisher", () => { snapshot: expect.objectContaining({ groups: [ expect.objectContaining({ + sourceGroupId: 2, sourceGroupName: "default", slug: "platform", displayName: "Platform", diff --git a/tests/unit/public-status/config-snapshot.test.ts b/tests/unit/public-status/config-snapshot.test.ts index ea2bcc7b3..2cbf2ab9a 100644 --- a/tests/unit/public-status/config-snapshot.test.ts +++ b/tests/unit/public-status/config-snapshot.test.ts @@ -44,6 +44,16 @@ interface ConfigSnapshotModule { get: (key: string) => Promise; }; }): Promise<{ siteTitle: string; siteDescription: string } | null>; + readCurrentInternalPublicStatusConfigSnapshot(input: { + redis: { + status: string; + get: (key: string) => Promise; + }; + allowLegacyFallback?: boolean; + }): Promise<{ + configVersion: string; + groups: unknown[]; + } | null>; publishPublicStatusConfigSnapshot(input: { reason: string; snapshot?: { @@ -167,6 +177,46 @@ describe("public-status config snapshot", () => { }); }); + it("can disable legacy config fallback for v2 rollup writers", async () => { + const mod = await importPublicStatusModule( + "@/lib/public-status/config-snapshot" + ); + + const redis = { + status: "ready", + get: vi.fn(async (key: string) => { + if (key === "public-status:v1:config-version:current") { + return "cfg-v1"; + } + if (key === "public-status:v1:config-internal:cfg-v1") { + return JSON.stringify({ + configVersion: "cfg-v1", + siteTitle: "Legacy", + siteDescription: "Legacy", + defaultIntervalMinutes: 5, + defaultRangeHours: 24, + groups: [], + }); + } + return null; + }), + }; + + await expect( + mod.readCurrentInternalPublicStatusConfigSnapshot({ + redis, + allowLegacyFallback: false, + }) + ).resolves.toBeNull(); + expect(redis.get).not.toHaveBeenCalledWith("public-status:v1:config-version:current"); + + await expect( + mod.readCurrentInternalPublicStatusConfigSnapshot({ + redis, + }) + ).resolves.toMatchObject({ configVersion: "cfg-v1" }); + }); + it("does not let an older configVersion overwrite the current pointer", async () => { const mod = await importPublicStatusModule( "@/lib/public-status/config-snapshot" diff --git a/tests/unit/public-status/no-db-import-guard.test.ts b/tests/unit/public-status/no-db-import-guard.test.ts index ea04617a4..018f21d12 100644 --- a/tests/unit/public-status/no-db-import-guard.test.ts +++ b/tests/unit/public-status/no-db-import-guard.test.ts @@ -13,6 +13,8 @@ const guardedFiles = [ "src/app/[locale]/layout.tsx", "src/lib/public-status/public-api-loader.ts", "src/lib/public-status/read-store.ts", + "src/lib/public-status/rollup-store.ts", + "src/lib/public-status/aggregation-core.ts", "src/lib/public-status/config-snapshot.ts", "src/lib/public-status/layout-metadata.ts", ]; @@ -34,6 +36,8 @@ const directTokenGuardFiles = new Set([ "src/app/[locale]/layout.tsx", "src/lib/public-status/public-api-loader.ts", "src/lib/public-status/read-store.ts", + "src/lib/public-status/rollup-store.ts", + "src/lib/public-status/aggregation-core.ts", ]); describe("public-status no-db import guard", () => { diff --git a/tests/unit/public-status/public-status-view.test.tsx b/tests/unit/public-status/public-status-view.test.tsx index 74d2707bc..72d8bd3e8 100644 --- a/tests/unit/public-status/public-status-view.test.tsx +++ b/tests/unit/public-status/public-status-view.test.tsx @@ -287,40 +287,46 @@ describe("public-status view", () => { })); global.fetch = fetchMock as typeof global.fetch; - const { container, unmount } = render( - - ); - - await act(async () => { - await Promise.resolve(); - }); - - expect(fetchMock).toHaveBeenCalledWith("/api/public-status?interval=5&rangeHours=24", { - cache: "no-store", - }); - - await act(async () => { - vi.advanceTimersByTime(30_000); - await Promise.resolve(); - }); - - expect(container.textContent).toContain("Refresh delayed"); - expect(container.textContent).toContain("Preparing first snapshot"); - - vi.useRealTimers(); - unmount(); + let unmount: (() => void) | undefined; + + try { + const { container, unmount: cleanup } = render( + + ); + unmount = cleanup; + + await act(async () => { + await Promise.resolve(); + }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/public-status?interval=5&rangeHours=24&include=meta%2Cdefaults%2Cgroups", + { cache: "no-store" } + ); + + await act(async () => { + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + }); + + expect(container.textContent).toContain("Refresh delayed"); + expect(container.textContent).toContain("Preparing first snapshot"); + } finally { + vi.useRealTimers(); + unmount?.(); + } }); it("falls back to shared model-prefix vendor icons when payload vendorIconKey is generic", () => { @@ -452,25 +458,130 @@ describe("public-status view", () => { })); global.fetch = fetchMock as typeof global.fetch; + let unmount: (() => void) | undefined; + + try { + const { container, unmount: cleanup } = render( + + ); + unmount = cleanup; + + await act(async () => { + await Promise.resolve(); + }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/public-status?groupSlug=platform&include=meta%2Cdefaults%2Cgroups", + { cache: "no-store" } + ); + + await act(async () => { + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + }); + + const text = container.textContent || ""; + expect(text).toContain("Platform Model"); + expect(text).not.toContain("OpenAI Model"); + expect(container.querySelectorAll('[data-testid="sortable-group-panel"]')).toHaveLength(1); + } finally { + vi.useRealTimers(); + unmount?.(); + } + }); + + it("updates summary state from polling payload even when timeline is reused", async () => { + vi.useFakeTimers(); + + const fetchMock = vi.fn(async () => ({ + status: 200, + json: async () => + buildRouteResponse({ + groups: [ + { + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: "Primary models", + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + latestState: "failed", + availabilityPct: 0, + latestTtfbMs: null, + latestTps: null, + timeline: [], + }, + ], + }, + ], + }), + })); + global.fetch = fetchMock as typeof global.fetch; + const { container, unmount } = render( { initialStatus="ready" intervalMinutes={5} rangeHours={24} - followServerDefaults={true} - filterSlug="platform" locale="en" timeZone="UTC" labels={buildLabels()} @@ -488,25 +597,96 @@ describe("public-status view", () => { /> ); - await act(async () => { - await Promise.resolve(); - }); + expect(container.textContent).toContain("Operational"); + expect(container.querySelector('[data-testid="public-status-timeline"]')?.textContent).toBe( + "1" + ); - expect(fetchMock).toHaveBeenCalledWith("/api/public-status?groupSlug=platform", { - cache: "no-store", - }); + try { + await act(async () => { + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + }); + + expect(container.textContent).toContain("Failed"); + expect(container.textContent).toContain("0.00%"); + expect(container.querySelector('[data-testid="public-status-timeline"]')?.textContent).toBe( + "1" + ); + } finally { + vi.useRealTimers(); + unmount(); + } + }); - await act(async () => { - vi.advanceTimersByTime(30_000); - await Promise.resolve(); - }); + it("uses server summary metrics when polling returns a new model without timeline", async () => { + vi.useFakeTimers(); + + const fetchMock = vi.fn(async () => ({ + status: 200, + json: async () => + buildRouteResponse({ + groups: [ + { + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: "Primary models", + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + latestState: "operational", + availabilityPct: 100, + latestTtfbMs: 420, + latestTps: null, + timeline: [], + }, + { + publicModelKey: "gpt-4.2", + label: "GPT-4.2", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + latestState: "failed", + availabilityPct: 0, + latestTtfbMs: null, + latestTps: null, + timeline: [], + }, + ], + }, + ], + }), + })); + global.fetch = fetchMock as typeof global.fetch; - const text = container.textContent || ""; - expect(text).toContain("Platform Model"); - expect(text).not.toContain("OpenAI Model"); - expect(container.querySelectorAll('[data-testid="sortable-group-panel"]')).toHaveLength(1); + const { container, unmount } = render( + + ); - vi.useRealTimers(); - unmount(); + try { + await act(async () => { + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + }); + + const text = container.textContent || ""; + expect(text).toContain("GPT-4.2"); + expect(text).toContain("Failed"); + expect(text).toContain("0.00%"); + } finally { + vi.useRealTimers(); + unmount(); + } }); }); diff --git a/tests/unit/public-status/read-store.test.ts b/tests/unit/public-status/read-store.test.ts index dfbb41cdb..fca5ca0c1 100644 --- a/tests/unit/public-status/read-store.test.ts +++ b/tests/unit/public-status/read-store.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { + LEGACY_PUBLIC_STATUS_REDIS_PREFIX, buildPublicStatusCurrentSnapshotKey, buildPublicStatusManifestKey, } from "@/lib/public-status/redis-contract"; @@ -138,6 +139,231 @@ describe("readPublicStatusPayload", () => { expect(triggerRebuildHint).toHaveBeenCalledWith("snapshot-missing"); }); + it("falls back to legacy v1 projections during v2 rollout and schedules a rebuild", async () => { + const triggerRebuildHint = vi.fn(); + const redis = createRedisReader({ + [buildPublicStatusManifestKey({ + configVersion: "current", + intervalMinutes: 5, + rangeHours: 24, + prefix: LEGACY_PUBLIC_STATUS_REDIS_PREFIX, + })]: { + configVersion: "cfg-v1", + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-v1", + sourceGeneration: "gen-v1", + coveredFrom: "2026-04-20T10:00:00.000Z", + coveredTo: "2026-04-21T10:00:00.000Z", + generatedAt: "2026-04-21T09:59:00.000Z", + freshUntil: "2026-04-21T10:04:00.000Z", + rebuildState: "idle", + lastCompleteGeneration: "gen-v1", + }, + [buildPublicStatusCurrentSnapshotKey({ + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-v1", + prefix: LEGACY_PUBLIC_STATUS_REDIS_PREFIX, + })]: { + sourceGeneration: "gen-v1", + generatedAt: "2026-04-21T09:59:00.000Z", + freshUntil: "2026-04-21T10:04:00.000Z", + groups: [ + { + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: null, + models: [], + }, + ], + }, + }); + + const payload = await readPublicStatusPayload({ + intervalMinutes: 5, + rangeHours: 24, + nowIso: "2026-04-21T10:00:00.000Z", + configVersion: "cfg-v2", + hasConfiguredGroups: true, + redis, + triggerRebuildHint, + }); + + expect(payload).toMatchObject({ + rebuildState: "stale", + sourceGeneration: "gen-v1", + generatedAt: "2026-04-21T09:59:00.000Z", + }); + expect(triggerRebuildHint.mock.calls.map(([reason]) => reason)).toEqual([ + "stale-generation", + "legacy-generation", + "config-version-mismatch", + ]); + }); + + it("keeps serving legacy projections while a new v2 rollup window is incomplete", async () => { + const triggerRebuildHint = vi.fn(); + const redis = createRedisReader({ + [buildPublicStatusManifestKey({ + configVersion: "cfg-v2", + intervalMinutes: 5, + rangeHours: 24, + })]: { + configVersion: "cfg-v2", + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-v2", + sourceGeneration: "gen-v2", + coveredFrom: "2026-04-20T10:00:00.000Z", + coveredTo: "2026-04-21T10:00:00.000Z", + generatedAt: "2026-04-21T10:00:00.000Z", + freshUntil: "2026-04-21T10:05:00.000Z", + rebuildState: "idle", + lastCompleteGeneration: "gen-v2", + rollupCoverageStartedAt: "2026-04-21T09:55:00.000Z", + rollupCoverageComplete: false, + rollupSampleCount: 1, + }, + [buildPublicStatusCurrentSnapshotKey({ + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-v2", + })]: { + sourceGeneration: "gen-v2", + generatedAt: "2026-04-21T10:00:00.000Z", + freshUntil: "2026-04-21T10:05:00.000Z", + groups: [ + { + publicGroupSlug: "openai-v2", + displayName: "OpenAI v2", + explanatoryCopy: null, + models: [], + }, + ], + }, + [buildPublicStatusManifestKey({ + configVersion: "current", + intervalMinutes: 5, + rangeHours: 24, + prefix: LEGACY_PUBLIC_STATUS_REDIS_PREFIX, + })]: { + configVersion: "cfg-v1", + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-v1", + sourceGeneration: "gen-v1", + coveredFrom: "2026-04-20T10:00:00.000Z", + coveredTo: "2026-04-21T10:00:00.000Z", + generatedAt: "2026-04-21T09:59:00.000Z", + freshUntil: "2026-04-21T10:04:00.000Z", + rebuildState: "idle", + lastCompleteGeneration: "gen-v1", + }, + [buildPublicStatusCurrentSnapshotKey({ + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-v1", + prefix: LEGACY_PUBLIC_STATUS_REDIS_PREFIX, + })]: { + sourceGeneration: "gen-v1", + generatedAt: "2026-04-21T09:59:00.000Z", + freshUntil: "2026-04-21T10:04:00.000Z", + groups: [ + { + publicGroupSlug: "openai-v1", + displayName: "OpenAI v1", + explanatoryCopy: null, + models: [], + }, + ], + }, + }); + + const payload = await readPublicStatusPayload({ + intervalMinutes: 5, + rangeHours: 24, + nowIso: "2026-04-21T10:00:00.000Z", + configVersion: "cfg-v2", + hasConfiguredGroups: true, + redis, + triggerRebuildHint, + }); + + expect(payload).toMatchObject({ + rebuildState: "stale", + sourceGeneration: "gen-v1", + groups: [expect.objectContaining({ publicGroupSlug: "openai-v1" })], + }); + expect(triggerRebuildHint.mock.calls.map(([reason]) => reason)).toEqual([ + "rollup-coverage-incomplete", + "legacy-generation", + "config-version-mismatch", + ]); + }); + + it("serves a stale v2 projection when rollup coverage is incomplete and legacy data is absent", async () => { + const triggerRebuildHint = vi.fn(); + const redis = createRedisReader({ + [buildPublicStatusManifestKey({ + configVersion: "cfg-v2", + intervalMinutes: 5, + rangeHours: 24, + })]: { + configVersion: "cfg-v2", + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-v2", + sourceGeneration: "gen-v2", + coveredFrom: "2026-04-20T10:00:00.000Z", + coveredTo: "2026-04-21T10:00:00.000Z", + generatedAt: "2026-04-21T10:00:00.000Z", + freshUntil: "2026-04-21T10:05:00.000Z", + rebuildState: "idle", + lastCompleteGeneration: "gen-v2", + rollupCoverageStartedAt: "2026-04-21T09:55:00.000Z", + rollupCoverageComplete: false, + rollupSampleCount: 1, + }, + [buildPublicStatusCurrentSnapshotKey({ + intervalMinutes: 5, + rangeHours: 24, + generation: "gen-v2", + })]: { + sourceGeneration: "gen-v2", + generatedAt: "2026-04-21T10:00:00.000Z", + freshUntil: "2026-04-21T10:05:00.000Z", + groups: [ + { + publicGroupSlug: "openai-v2", + displayName: "OpenAI v2", + explanatoryCopy: null, + models: [], + }, + ], + }, + }); + + const payload = await readPublicStatusPayload({ + intervalMinutes: 5, + rangeHours: 24, + nowIso: "2026-04-21T10:00:00.000Z", + configVersion: "cfg-v2", + hasConfiguredGroups: true, + redis, + triggerRebuildHint, + }); + + expect(payload).toMatchObject({ + rebuildState: "stale", + sourceGeneration: "gen-v2", + groups: [expect.objectContaining({ publicGroupSlug: "openai-v2" })], + }); + expect(triggerRebuildHint.mock.calls.map(([reason]) => reason)).toEqual([ + "rollup-coverage-incomplete", + ]); + }); + it("marks config-version drift as stale and strips unexpected fields from redis snapshots", async () => { const triggerRebuildHint = vi.fn(); const redis = createRedisReader({ diff --git a/tests/unit/public-status/rebuild-worker.test.ts b/tests/unit/public-status/rebuild-worker.test.ts index 9edc7e445..c9d3343b7 100644 --- a/tests/unit/public-status/rebuild-worker.test.ts +++ b/tests/unit/public-status/rebuild-worker.test.ts @@ -1,19 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { + PUBLIC_STATUS_REDIS_PREFIX, buildPublicStatusCurrentSnapshotKey, buildPublicStatusManifestKey, buildPublicStatusRebuildHintKey, + buildPublicStatusRollupCoverageStartKey, + buildPublicStatusRollupKey, } from "@/lib/public-status/redis-contract"; +import { buildPublicStatusRollupField } from "@/lib/public-status/rollup-store"; import { importPublicStatusModule } from "../../helpers/public-status-test-helpers"; const mockRedisSet = vi.hoisted(() => vi.fn()); const mockRedisDel = vi.hoisted(() => vi.fn()); const mockRedisGet = vi.hoisted(() => vi.fn()); +const mockRedisHgetall = vi.hoisted(() => vi.fn()); const mockRedisEval = vi.hoisted(() => vi.fn()); const mockRedisPttl = vi.hoisted(() => vi.fn()); const mockReadCurrentInternalPublicStatusConfigSnapshot = vi.hoisted(() => vi.fn()); -const mockQueryPublicStatusRequests = vi.hoisted(() => vi.fn()); -const mockBuildPublicStatusPayloadFromRequests = vi.hoisted(() => vi.fn()); const mockPublishCurrentPublicStatusConfigProjection = vi.hoisted(() => vi.fn()); async function importAggregationModule() { @@ -79,6 +82,7 @@ async function importRebuildWorkerModule() { vi.doMock("@/lib/redis", () => ({ getRedisClient: () => ({ get: mockRedisGet, + hgetall: mockRedisHgetall, pttl: mockRedisPttl, set: mockRedisSet, del: mockRedisDel, @@ -95,8 +99,6 @@ async function importRebuildWorkerModule() { })); vi.doMock("@/lib/public-status/aggregation", () => ({ getConfiguredPublicStatusGroups: (snapshot: { groups: unknown[] }) => snapshot.groups, - queryPublicStatusRequests: mockQueryPublicStatusRequests, - buildPublicStatusPayloadFromRequests: mockBuildPublicStatusPayloadFromRequests, })); return importPublicStatusModule<{ @@ -127,6 +129,7 @@ async function importRebuildHintsModule() { vi.doMock("@/lib/redis", () => ({ getRedisClient: () => ({ get: mockRedisGet, + hgetall: mockRedisHgetall, pttl: mockRedisPttl, set: mockRedisSet, del: mockRedisDel, @@ -156,6 +159,7 @@ describe("public-status rebuild worker", () => { beforeEach(() => { vi.clearAllMocks(); mockRedisGet.mockResolvedValue(null); + mockRedisHgetall.mockResolvedValue({}); mockRedisEval.mockResolvedValue(1); mockRedisPttl.mockResolvedValue(-1); mockPublishCurrentPublicStatusConfigProjection.mockResolvedValue({ @@ -422,13 +426,20 @@ describe("public-status rebuild worker", () => { }, ], }); - mockQueryPublicStatusRequests.mockResolvedValue([]); - mockBuildPublicStatusPayloadFromRequests.mockReturnValue({ - generatedAt: "2026-04-21T10:00:00.000Z", - coveredFrom: "2026-04-20T10:00:00.000Z", - coveredTo: "2026-04-21T10:00:00.000Z", - groups: [], + const rollupKey = buildPublicStatusRollupKey({ + bucketStartIso: "2026-04-21T09:55:00.000Z", }); + mockRedisHgetall.mockImplementation(async (key: string) => + key === rollupKey + ? { + [buildPublicStatusRollupField({ + groupId: "openai", + modelKey: "gpt-4.1", + metric: "success", + })]: "1", + } + : {} + ); mockRedisSet.mockReset(); mockRedisSet.mockResolvedValueOnce("OK"); @@ -453,10 +464,12 @@ describe("public-status rebuild worker", () => { const manifestValue = JSON.parse(String(versionedManifestCall?.[1])); expect(manifestValue.configVersion).toBe("cfg-1"); expect(manifestValue.lastCompleteGeneration).toBeTruthy(); + expect(manifestValue.rollupCoverageComplete).toBe(false); + expect(manifestValue.rollupSampleCount).toBe(1); expect(mockRedisEval).toHaveBeenCalledWith( expect.stringContaining("redis.call('DEL', KEYS[1])"), 1, - expect.stringContaining("public-status:v1:rebuild-lock:"), + expect.stringContaining(`${PUBLIC_STATUS_REDIS_PREFIX}:rebuild-lock:`), expect.any(String) ); expect(mockRedisDel).toHaveBeenCalled(); @@ -474,6 +487,63 @@ describe("public-status rebuild worker", () => { ); }); + it("marks rebuilt generations as fully covered once rollups cover the whole window", async () => { + const mod = await importRebuildWorkerModule(); + + mockReadCurrentInternalPublicStatusConfigSnapshot.mockResolvedValue({ + configVersion: "cfg-1", + generatedAt: "2026-04-21T10:00:00.000Z", + siteTitle: "Claude Code Hub Status", + siteDescription: "Request-derived public status", + defaultIntervalMinutes: 5, + defaultRangeHours: 24, + groups: [ + { + sourceGroupId: 42, + sourceGroupName: "openai", + slug: "openai", + displayName: "OpenAI", + description: "Primary fleet", + sortOrder: 1, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + }, + ], + }, + ], + }); + mockRedisGet.mockImplementation(async (key: string) => + key === buildPublicStatusRollupCoverageStartKey() ? "2026-04-20T10:00:00.000Z" : null + ); + mockRedisHgetall.mockResolvedValue({}); + mockRedisSet.mockReset(); + mockRedisSet.mockResolvedValueOnce("OK"); + + const result = await mod.rebuildPublicStatusProjection({ + intervalMinutes: 5, + rangeHours: 24, + now: new Date("2026-04-21T10:02:00.000Z"), + }); + + expect(result.status).toBe("updated"); + const versionedManifestCall = mockRedisSet.mock.calls.find( + (call) => + call[0] === + buildPublicStatusManifestKey({ + configVersion: "cfg-1", + intervalMinutes: 5, + rangeHours: 24, + }) + ); + const manifestValue = JSON.parse(String(versionedManifestCall?.[1])); + expect(manifestValue.rollupCoverageComplete).toBe(true); + expect(manifestValue.rollupCoverageStartedAt).toBe("2026-04-20T10:00:00.000Z"); + }); + it("re-publishes config projection before rebuild when redis config keys are missing", async () => { const mod = await importRebuildWorkerModule(); @@ -504,13 +574,6 @@ describe("public-status rebuild worker", () => { }, ], }); - mockQueryPublicStatusRequests.mockResolvedValue([]); - mockBuildPublicStatusPayloadFromRequests.mockReturnValue({ - generatedAt: "2026-04-21T10:00:00.000Z", - coveredFrom: "2026-04-20T10:00:00.000Z", - coveredTo: "2026-04-21T10:00:00.000Z", - groups: [], - }); mockRedisSet.mockReset(); mockRedisSet.mockResolvedValueOnce("OK"); @@ -524,6 +587,14 @@ describe("public-status rebuild worker", () => { expect(mockPublishCurrentPublicStatusConfigProjection).toHaveBeenCalledWith({ reason: "rebuild-bootstrap", }); + expect(mockReadCurrentInternalPublicStatusConfigSnapshot).toHaveBeenNthCalledWith(1, { + redis: expect.any(Object), + allowLegacyFallback: false, + }); + expect(mockReadCurrentInternalPublicStatusConfigSnapshot).toHaveBeenNthCalledWith(2, { + redis: expect.any(Object), + allowLegacyFallback: false, + }); }); it("writes rebuild hints with ttl and reason payload", async () => { @@ -547,6 +618,30 @@ describe("public-status rebuild worker", () => { ); }); + it("does not rewrite rebuild hints while an existing hint is still live", async () => { + const mod = await importRebuildHintsModule(); + + mockRedisPttl.mockResolvedValueOnce(120_000); + + await expect( + mod.schedulePublicStatusRebuild({ + intervalMinutes: 15, + rangeHours: 48, + reason: "stale-generation", + }) + ).resolves.toMatchObject({ + accepted: true, + rebuildState: "rebuilding", + key: buildPublicStatusRebuildHintKey({ + intervalMinutes: 15, + rangeHours: 48, + }), + }); + + expect(mockRedisSet).not.toHaveBeenCalled(); + expect(mockRedisGet).not.toHaveBeenCalled(); + }); + it("preserves manifest ttl when marking rebuildState as rebuilding", async () => { const mod = await importRebuildHintsModule(); @@ -566,7 +661,10 @@ describe("public-status rebuild worker", () => { rebuildState: "idle", }) ); - mockRedisPttl.mockResolvedValueOnce(2_592_000_000).mockResolvedValueOnce(-1); + mockRedisPttl + .mockResolvedValueOnce(-1) + .mockResolvedValueOnce(2_592_000_000) + .mockResolvedValueOnce(-1); await mod.schedulePublicStatusRebuild({ intervalMinutes: 5, @@ -575,13 +673,21 @@ describe("public-status rebuild worker", () => { }); expect(mockRedisSet).toHaveBeenCalledWith( - "public-status:v1:manifest:cfg-1:5m:24h", + buildPublicStatusManifestKey({ + configVersion: "cfg-1", + intervalMinutes: 5, + rangeHours: 24, + }), expect.stringContaining('"rebuildState":"rebuilding"'), "PX", 2_592_000_000 ); expect(mockRedisSet).toHaveBeenCalledWith( - "public-status:v1:manifest:current:5m:24h", + buildPublicStatusManifestKey({ + configVersion: "current", + intervalMinutes: 5, + rangeHours: 24, + }), expect.stringContaining('"rebuildState":"rebuilding"') ); }); diff --git a/tests/unit/public-status/redis-contract.test.ts b/tests/unit/public-status/redis-contract.test.ts index 95cdd4dda..8875580a4 100644 --- a/tests/unit/public-status/redis-contract.test.ts +++ b/tests/unit/public-status/redis-contract.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from "vitest"; import { importPublicStatusModule } from "../../helpers/public-status-test-helpers"; interface RedisContractModule { + PUBLIC_STATUS_REDIS_PREFIX: string; + LEGACY_PUBLIC_STATUS_REDIS_PREFIX: string; + PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES: number; buildGenerationFingerprint(input: { configVersion: string; intervalMinutes: number; @@ -17,9 +20,64 @@ interface RedisContractModule { } | null, nowIso: string ): { rebuildState: string; sourceGeneration: string | null }; + buildPublicStatusManifestKey(input: { + configVersion: string; + intervalMinutes: number; + rangeHours: number; + prefix?: string; + }): string; + buildPublicStatusRollupKey(input: { + bucketStartIso: string; + bucketMinutes?: number; + prefix?: string; + }): string; + buildPublicStatusRollupCoverageStartKey(input?: { + bucketMinutes?: number; + prefix?: string; + }): string; } describe("public-status redis contract", () => { + it("uses v2 keys by default while keeping explicit v1 builders for upgrade fallback", async () => { + const mod = await importPublicStatusModule( + "@/lib/public-status/redis-contract" + ); + + expect(mod.PUBLIC_STATUS_REDIS_PREFIX).toBe("public-status:v2"); + expect(mod.LEGACY_PUBLIC_STATUS_REDIS_PREFIX).toBe("public-status:v1"); + expect( + mod.buildPublicStatusManifestKey({ + configVersion: "cfg-1", + intervalMinutes: 5, + rangeHours: 24, + }) + ).toBe("public-status:v2:manifest:cfg-1:5m:24h"); + expect( + mod.buildPublicStatusManifestKey({ + configVersion: "cfg-1", + intervalMinutes: 5, + rangeHours: 24, + prefix: mod.LEGACY_PUBLIC_STATUS_REDIS_PREFIX, + }) + ).toBe("public-status:v1:manifest:cfg-1:5m:24h"); + }); + + it("builds one aligned 5m rollup key per base bucket", async () => { + const mod = await importPublicStatusModule( + "@/lib/public-status/redis-contract" + ); + + expect(mod.PUBLIC_STATUS_ROLLUP_BUCKET_MINUTES).toBe(5); + expect( + mod.buildPublicStatusRollupKey({ + bucketStartIso: "2026-04-21T10:07:31.000Z", + }) + ).toBe("public-status:v2:rollup:5m:2026-04-21T10%3A05%3A00.000Z"); + expect(mod.buildPublicStatusRollupCoverageStartKey()).toBe( + "public-status:v2:rollup:coverage-start:5m" + ); + }); + it("changes generation fingerprint when interval changes", async () => { const mod = await importPublicStatusModule( "@/lib/public-status/redis-contract" diff --git a/tests/unit/public-status/rollup-store.test.ts b/tests/unit/public-status/rollup-store.test.ts new file mode 100644 index 000000000..44c9f7a05 --- /dev/null +++ b/tests/unit/public-status/rollup-store.test.ts @@ -0,0 +1,537 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildPublicStatusPayloadFromRollups, + buildPublicStatusRollupField, + buildPublicStatusRollupIncrements, + buildPublicStatusRollupBucketStarts, + parsePublicStatusRollupField, + readPublicStatusRollupBuckets, + writePublicStatusRollupEvent, + type PublicStatusRollupBucket, +} from "@/lib/public-status/rollup-store"; +import { + buildPublicStatusRollupCoverageStartKey, + buildPublicStatusRollupKey, +} from "@/lib/public-status/redis-contract"; +import type { PublicStatusConfiguredGroup } from "@/lib/public-status/aggregation-core"; + +const groups: PublicStatusConfiguredGroup[] = [ + { + sourceGroupId: 42, + sourceGroupName: "openai", + publicGroupSlug: "openai-public", + displayName: "OpenAI", + explanatoryCopy: null, + sortOrder: 1, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + }, + ], + }, +]; + +describe("public-status rollup store", () => { + it("builds rollup increments by stable provider group id and public model key", () => { + const increments = buildPublicStatusRollupIncrements({ + groups, + event: { + createdAt: "2026-04-21T10:02:00.000Z", + originalModel: "gpt-4.1", + durationMs: 1200, + ttfbMs: 200, + outputTokens: 50, + providerChain: [ + { + id: 7, + name: "internal-provider", + endpointUrl: "https://private.example.com", + groupTag: "openai", + reason: "request_success", + statusCode: 200, + }, + ], + }, + }); + + expect(increments).toEqual( + expect.arrayContaining([ + { groupId: "42", modelKey: "gpt-4.1", metric: "success", value: 1 }, + { groupId: "42", modelKey: "gpt-4.1", metric: "ttfb_sum", value: 200 }, + { groupId: "42", modelKey: "gpt-4.1", metric: "ttfb_count", value: 1 }, + { groupId: "42", modelKey: "gpt-4.1", metric: "tps_sum", value: 50 }, + { groupId: "42", modelKey: "gpt-4.1", metric: "tps_count", value: 1 }, + ]) + ); + + for (const increment of increments) { + expect(increment.groupId).toBe("42"); + expect(JSON.stringify(increment)).not.toContain("private.example.com"); + } + }); + + it("excludes local/client failures from rollup counts", () => { + const increments = buildPublicStatusRollupIncrements({ + groups, + event: { + createdAt: "2026-04-21T10:02:00.000Z", + originalModel: "gpt-4.1", + providerChain: [ + { + id: 7, + name: "internal-provider", + groupTag: "openai", + reason: "client_abort", + statusCode: 499, + }, + ], + }, + }); + + expect(increments).toEqual([]); + }); + + it("marks unmatched events as ignored instead of retryable write failures", async () => { + const redis = { + status: "ready", + hincrbyfloat: vi.fn(), + pipeline: vi.fn(), + }; + + const result = await writePublicStatusRollupEvent({ + redis, + groups, + event: { + createdAt: "2026-04-21T10:02:00.000Z", + originalModel: "not-public-model", + providerChain: [ + { + id: 7, + name: "internal-provider", + groupTag: "openai", + reason: "request_success", + statusCode: 200, + }, + ], + }, + }); + + expect(result).toEqual({ + written: false, + retryable: false, + reason: "ignored", + incrementCount: 0, + key: null, + }); + expect(redis.pipeline).not.toHaveBeenCalled(); + }); + + it("records latency and throughput only for the group that actually succeeds", () => { + const fallbackGroups: PublicStatusConfiguredGroup[] = [ + groups[0]!, + { + sourceGroupId: 43, + sourceGroupName: "backup", + publicGroupSlug: "backup-public", + displayName: "Backup", + explanatoryCopy: null, + sortOrder: 2, + models: groups[0]!.models, + }, + ]; + + const increments = buildPublicStatusRollupIncrements({ + groups: fallbackGroups, + event: { + createdAt: "2026-04-21T10:02:00.000Z", + originalModel: "gpt-4.1", + durationMs: 1200, + ttfbMs: 200, + outputTokens: 50, + providerChain: [ + { + id: 7, + name: "failed-provider", + groupTag: "openai", + reason: "retry_failed", + statusCode: 500, + }, + { + id: 8, + name: "successful-provider", + groupTag: "backup", + reason: "request_success", + statusCode: 200, + }, + ], + }, + }); + + expect(increments).toEqual( + expect.arrayContaining([ + { groupId: "42", modelKey: "gpt-4.1", metric: "failure", value: 1 }, + { groupId: "43", modelKey: "gpt-4.1", metric: "success", value: 1 }, + { groupId: "43", modelKey: "gpt-4.1", metric: "ttfb_sum", value: 200 }, + { groupId: "43", modelKey: "gpt-4.1", metric: "ttfb_count", value: 1 }, + { groupId: "43", modelKey: "gpt-4.1", metric: "tps_sum", value: 50 }, + { groupId: "43", modelKey: "gpt-4.1", metric: "tps_count", value: 1 }, + ]) + ); + expect(increments).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ groupId: "42", metric: "ttfb_sum" }), + expect.objectContaining({ groupId: "42", metric: "ttfb_count" }), + expect.objectContaining({ groupId: "42", metric: "tps_sum" }), + expect.objectContaining({ groupId: "42", metric: "tps_count" }), + ]) + ); + }); + + it("writes one 5m bucket hash instead of endpoint multiplied keys", async () => { + const fields = new Map(); + const pipeline = { + hincrbyfloat: vi.fn((_key: string, field: string, increment: number) => { + fields.set(field, (fields.get(field) ?? 0) + increment); + }), + set: vi.fn(), + expire: vi.fn(), + exec: vi.fn(async () => [ + [null, "1"], + [null, "1"], + [null, "1"], + [null, "1"], + [null, "1"], + [null, "OK"], + [null, 1], + [null, 1], + ]), + }; + const redis = { + status: "ready", + hincrbyfloat: vi.fn(async (_key: string, field: string, increment: number) => { + fields.set(field, (fields.get(field) ?? 0) + increment); + }), + expire: vi.fn(), + pipeline: vi.fn(() => pipeline), + }; + + const result = await writePublicStatusRollupEvent({ + redis, + groups, + event: { + createdAt: "2026-04-21T10:02:00.000Z", + originalModel: "gpt-4.1", + durationMs: 1200, + ttfbMs: 200, + outputTokens: 50, + providerChain: [ + { + id: 7, + name: "internal-provider", + endpointUrl: "https://private.example.com", + groupTag: "openai", + reason: "request_success", + statusCode: 200, + }, + ], + }, + }); + + expect(result).toMatchObject({ + written: true, + retryable: false, + key: buildPublicStatusRollupKey({ bucketStartIso: "2026-04-21T10:02:00.000Z" }), + }); + expect(redis.hincrbyfloat).not.toHaveBeenCalled(); + expect(pipeline.hincrbyfloat.mock.calls.map(([key]) => key)).toEqual([ + result.key, + result.key, + result.key, + result.key, + result.key, + ]); + expect(pipeline.set).toHaveBeenCalledWith( + buildPublicStatusRollupCoverageStartKey(), + "2026-04-21T10:00:00.000Z", + "NX" + ); + expect(pipeline.expire).toHaveBeenCalledWith(result.key, 60 * 60 * 24 * 32); + expect(pipeline.expire).toHaveBeenCalledWith( + buildPublicStatusRollupCoverageStartKey(), + 60 * 60 * 24 * 32 + ); + expect(pipeline.exec).toHaveBeenCalledTimes(1); + expect(pipeline.hincrbyfloat.mock.calls.map((call) => call.join("|")).join("\n")).not.toContain( + "private.example.com" + ); + expect( + fields.get( + buildPublicStatusRollupField({ + groupId: 42, + modelKey: "gpt-4.1", + metric: "success", + }) + ) + ).toBe(1); + }); + + it("rejects the rollup write when a Redis pipeline command fails", async () => { + const pipelineError = new Error("ERR hash command failed"); + const pipeline = { + hincrbyfloat: vi.fn(), + set: vi.fn(), + expire: vi.fn(), + exec: vi.fn(async () => [ + [pipelineError, null], + [null, "1"], + [null, "1"], + [null, "1"], + [null, "1"], + [null, "OK"], + [null, 1], + [null, 1], + ]), + }; + const redis = { + status: "ready", + hincrbyfloat: vi.fn(), + pipeline: vi.fn(() => pipeline), + }; + + await expect( + writePublicStatusRollupEvent({ + redis, + groups, + event: { + createdAt: "2026-04-21T10:02:00.000Z", + originalModel: "gpt-4.1", + durationMs: 1200, + ttfbMs: 200, + outputTokens: 50, + providerChain: [ + { + id: 7, + name: "internal-provider", + groupTag: "openai", + reason: "request_success", + statusCode: 200, + }, + ], + }, + }) + ).rejects.toThrow("Public status rollup pipeline failed"); + }); + + it("rejects the rollup write when Redis pipeline returns no result", async () => { + const pipeline = { + hincrbyfloat: vi.fn(), + set: vi.fn(), + expire: vi.fn(), + exec: vi.fn(async () => null), + }; + const redis = { + status: "ready", + hincrbyfloat: vi.fn(), + pipeline: vi.fn(() => pipeline), + }; + + await expect( + writePublicStatusRollupEvent({ + redis, + groups, + event: { + createdAt: "2026-04-21T10:02:00.000Z", + originalModel: "gpt-4.1", + durationMs: 1200, + ttfbMs: 200, + outputTokens: 50, + providerChain: [ + { + id: 7, + name: "internal-provider", + groupTag: "openai", + reason: "request_success", + statusCode: 200, + }, + ], + }, + }) + ).rejects.toThrow("empty exec result"); + }); + + it("reads rollup buckets through batched Redis pipelines when available", async () => { + const bucketStarts = buildPublicStatusRollupBucketStarts({ + now: "2026-04-21T11:00:00.000Z", + rangeHours: 1, + intervalMinutes: 5, + }); + const pipelineExec = vi.fn(async () => + bucketStarts.map((_, index) => [ + null, + { + [buildPublicStatusRollupField({ + groupId: 42, + modelKey: "gpt-4.1", + metric: "success", + })]: String(index + 1), + }, + ]) + ); + const pipeline = { + hgetall: vi.fn(), + exec: pipelineExec, + }; + const redis = { + hgetall: vi.fn(), + pipeline: vi.fn(() => pipeline), + }; + + const buckets = await readPublicStatusRollupBuckets({ + redis, + bucketStarts, + }); + + expect(redis.pipeline).toHaveBeenCalledTimes(1); + expect(redis.hgetall).not.toHaveBeenCalled(); + expect(pipeline.hgetall).toHaveBeenCalledTimes(bucketStarts.length); + expect(buckets).toHaveLength(bucketStarts.length); + expect( + buckets[0]?.values.get( + buildPublicStatusRollupField({ + groupId: 42, + modelKey: "gpt-4.1", + metric: "success", + }) + ) + ).toBe(1); + }); + + it("builds interval snapshots from 5m rollups by stable group id", () => { + const bucketStarts = buildPublicStatusRollupBucketStarts({ + now: "2026-04-21T11:00:00.000Z", + rangeHours: 1, + intervalMinutes: 15, + }); + const makeBucket = ( + bucketStart: string, + entries: Array<{ groupId: string | number; metric: "success" | "failure"; value: number }> + ): PublicStatusRollupBucket => ({ + bucketStart, + values: new Map( + entries.map((entry) => [ + buildPublicStatusRollupField({ + groupId: entry.groupId, + modelKey: "gpt-4.1", + metric: entry.metric, + }), + entry.value, + ]) + ), + }); + + const result = buildPublicStatusPayloadFromRollups({ + rangeHours: 1, + intervalMinutes: 15, + now: "2026-04-21T11:00:00.000Z", + groups, + rollupBuckets: [ + makeBucket(bucketStarts[0]!, [{ groupId: 42, metric: "success", value: 1 }]), + makeBucket(bucketStarts[1]!, [{ groupId: 42, metric: "failure", value: 1 }]), + makeBucket(bucketStarts[2]!, [{ groupId: "openai", metric: "success", value: 1 }]), + ], + }); + + const model = result.groups[0]?.models[0]; + expect(result.coveredFrom).toBe("2026-04-21T10:00:00.000Z"); + expect(result.coveredTo).toBe("2026-04-21T11:00:00.000Z"); + expect(model?.timeline).toHaveLength(4); + expect(model?.timeline[0]).toMatchObject({ + bucketStart: "2026-04-21T10:00:00.000Z", + sampleCount: 2, + availabilityPct: 50, + state: "operational", + }); + expect(model?.availabilityPct).toBe(50); + }); + + it("rejects unsupported display intervals instead of silently rounding boundaries", () => { + expect(() => + buildPublicStatusRollupBucketStarts({ + now: "2026-04-21T11:00:00.000Z", + rangeHours: 1, + intervalMinutes: 16, + }) + ).toThrow("Unsupported public status rollup intervalMinutes: 16"); + expect(() => + buildPublicStatusPayloadFromRollups({ + rangeHours: 1, + intervalMinutes: 16, + now: "2026-04-21T11:00:00.000Z", + groups, + rollupBuckets: [], + }) + ).toThrow("Unsupported public status rollup intervalMinutes: 16"); + }); + + it("marks the latest partially failing bucket as degraded instead of fully operational", () => { + const bucketStarts = buildPublicStatusRollupBucketStarts({ + now: "2026-04-21T11:00:00.000Z", + rangeHours: 1, + intervalMinutes: 15, + }); + const makeBucket = ( + bucketStart: string, + entries: Array<{ metric: "success" | "failure"; value: number }> + ): PublicStatusRollupBucket => ({ + bucketStart, + values: new Map( + entries.map((entry) => [ + buildPublicStatusRollupField({ + groupId: 42, + modelKey: "gpt-4.1", + metric: entry.metric, + }), + entry.value, + ]) + ), + }); + + const result = buildPublicStatusPayloadFromRollups({ + rangeHours: 1, + intervalMinutes: 15, + now: "2026-04-21T11:00:00.000Z", + groups, + rollupBuckets: [ + makeBucket(bucketStarts[9]!, [ + { metric: "success", value: 1 }, + { metric: "failure", value: 2 }, + ]), + ], + }); + + const model = result.groups[0]?.models[0]; + expect(model?.latestState).toBe("degraded"); + expect(model?.timeline[3]).toMatchObject({ + availabilityPct: 33.33, + state: "operational", + sampleCount: 3, + }); + }); + + it("round-trips escaped rollup field parts", () => { + const field = buildPublicStatusRollupField({ + groupId: "group|42", + modelKey: "vendor/model|v1", + metric: "failure", + }); + + expect(parsePublicStatusRollupField(field)).toEqual({ + groupId: "group|42", + modelKey: "vendor/model|v1", + metric: "failure", + }); + }); +}); diff --git a/tests/unit/repository/message-public-status-rollup.test.ts b/tests/unit/repository/message-public-status-rollup.test.ts new file mode 100644 index 000000000..2046ec89e --- /dev/null +++ b/tests/unit/repository/message-public-status-rollup.test.ts @@ -0,0 +1,514 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockDbInsertValues = vi.hoisted(() => vi.fn()); +const mockDbInsertReturning = vi.hoisted(() => vi.fn()); +const mockDbUpdateSet = vi.hoisted(() => vi.fn()); +const mockDbUpdateWhere = vi.hoisted(() => vi.fn()); +const mockDbSelectLimit = vi.hoisted(() => vi.fn()); +const mockQueuePublicStatusRollupWrite = vi.hoisted(() => vi.fn()); +const mockGetConfiguredPublicStatusGroupsForRollupResolution = vi.hoisted(() => vi.fn()); +const mockGetEnvConfig = vi.hoisted(() => vi.fn()); + +vi.mock("@/drizzle/schema", () => ({ + keys: {}, + messageRequest: { + id: "id", + providerId: "providerId", + userId: "userId", + key: "key", + model: "model", + originalModel: "originalModel", + durationMs: "durationMs", + costUsd: "costUsd", + costMultiplier: "costMultiplier", + sessionId: "sessionId", + requestSequence: "requestSequence", + userAgent: "userAgent", + clientIp: "clientIp", + endpoint: "endpoint", + messagesCount: "messagesCount", + blockedBy: "blockedBy", + cacheTtlApplied: "cacheTtlApplied", + cacheCreationInputTokens: "cacheCreationInputTokens", + cacheCreation5mInputTokens: "cacheCreation5mInputTokens", + cacheCreation1hInputTokens: "cacheCreation1hInputTokens", + cacheReadInputTokens: "cacheReadInputTokens", + specialSettings: "specialSettings", + createdAt: "createdAt", + updatedAt: "updatedAt", + deletedAt: "deletedAt", + }, + providers: {}, + usageLedger: {}, + users: {}, +})); + +vi.mock("@/drizzle/db", () => ({ + db: { + insert: vi.fn(() => ({ + values: mockDbInsertValues, + })), + update: vi.fn(() => ({ + set: mockDbUpdateSet, + })), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn(() => ({ + limit: vi.fn(async () => []), + })), + limit: mockDbSelectLimit, + })), + })), + })), + }, +})); + +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: mockGetEnvConfig, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + warn: vi.fn(), + }, +})); + +vi.mock("@/lib/public-status/rollup-store", () => ({ + getConfiguredPublicStatusGroupsForRollupResolution: + mockGetConfiguredPublicStatusGroupsForRollupResolution, + queuePublicStatusRollupWrite: mockQueuePublicStatusRollupWrite, +})); + +vi.mock("@/repository/message-write-buffer", () => ({ + enqueueMessageRequestUpdate: vi.fn(), +})); + +function flushMicrotasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("repository/message public status rollup hook", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + mockGetEnvConfig.mockReturnValue({ MESSAGE_REQUEST_WRITE_MODE: "sync" }); + mockDbInsertValues.mockReturnValue({ returning: mockDbInsertReturning }); + mockDbUpdateSet.mockReturnValue({ where: mockDbUpdateWhere }); + mockDbUpdateWhere.mockResolvedValue(undefined); + mockDbSelectLimit.mockResolvedValue([]); + mockGetConfiguredPublicStatusGroupsForRollupResolution.mockResolvedValue({ + retryable: false, + groups: [ + { + sourceGroupId: 42, + sourceGroupName: "openai", + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: null, + sortOrder: 1, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + }, + ], + }, + ], + }); + mockQueuePublicStatusRollupWrite.mockResolvedValue({ + written: true, + retryable: false, + incrementCount: 1, + key: "public-status:v2:rollup:5m:2026-04-21T10%3A00%3A00.000Z", + }); + }); + + it("queues one rollup for duplicate terminal updates without double counting", async () => { + mockDbInsertReturning.mockResolvedValue([ + { + id: 101, + providerId: 1, + userId: 2, + key: "sk-1", + model: "gpt-4.1", + originalModel: "gpt-4.1", + durationMs: null, + costUsd: null, + costMultiplier: null, + sessionId: "session-1", + requestSequence: 1, + userAgent: null, + clientIp: null, + endpoint: "/v1/messages", + messagesCount: 1, + cacheTtlApplied: null, + cacheCreationInputTokens: null, + cacheCreation5mInputTokens: null, + cacheCreation1hInputTokens: null, + cacheReadInputTokens: null, + specialSettings: null, + createdAt: new Date("2026-04-21T10:02:00.000Z"), + updatedAt: new Date("2026-04-21T10:02:00.000Z"), + deletedAt: null, + }, + ]); + + const { createMessageRequest, updateMessageRequestDetails, updateMessageRequestDuration } = + await import("@/repository/message"); + + await createMessageRequest({ + provider_id: 1, + user_id: 2, + key: "sk-1", + model: "gpt-4.1", + original_model: "gpt-4.1", + }); + await updateMessageRequestDuration(101, 1200); + + const finalDetails = { + statusCode: 200, + ttfbMs: 200, + outputTokens: 50, + providerChain: [ + { + id: 1, + name: "provider-a", + groupTag: "openai", + reason: "request_success" as const, + statusCode: 200, + }, + ], + model: "gpt-4.1", + }; + + await Promise.all([ + updateMessageRequestDetails(101, finalDetails), + updateMessageRequestDetails(101, finalDetails), + ]); + await flushMicrotasks(); + + expect(mockQueuePublicStatusRollupWrite).toHaveBeenCalledTimes(1); + expect(mockQueuePublicStatusRollupWrite).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + createdAt: new Date("2026-04-21T10:02:00.000Z"), + durationMs: 1200, + originalModel: "gpt-4.1", + model: "gpt-4.1", + outputTokens: 50, + ttfbMs: 200, + }), + }) + ); + }); + + it("falls back to the persisted request seed when the in-memory seed is missing", async () => { + mockDbSelectLimit.mockResolvedValueOnce([ + { + createdAt: new Date("2026-04-21T10:03:00.000Z"), + model: "gpt-4.1", + originalModel: "gpt-4.1", + durationMs: 1500, + }, + ]); + + const { updateMessageRequestDetails } = await import("@/repository/message"); + + await updateMessageRequestDetails(202, { + statusCode: 200, + ttfbMs: 250, + outputTokens: 75, + providerChain: [ + { + id: 1, + name: "provider-a", + groupTag: "openai", + reason: "request_success", + statusCode: 200, + }, + ], + model: "gpt-4.1", + }); + await flushMicrotasks(); + + expect(mockQueuePublicStatusRollupWrite).toHaveBeenCalledTimes(1); + expect(mockQueuePublicStatusRollupWrite).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + createdAt: new Date("2026-04-21T10:03:00.000Z"), + durationMs: 1500, + originalModel: "gpt-4.1", + model: "gpt-4.1", + outputTokens: 75, + ttfbMs: 250, + }), + }) + ); + }); + + it("keeps the seed retryable when the first rollup write fails", async () => { + mockDbInsertReturning.mockResolvedValue([ + { + id: 303, + providerId: 1, + userId: 2, + key: "sk-1", + model: "gpt-4.1", + originalModel: "gpt-4.1", + durationMs: null, + costUsd: null, + costMultiplier: null, + sessionId: "session-1", + requestSequence: 1, + userAgent: null, + clientIp: null, + endpoint: "/v1/messages", + messagesCount: 1, + cacheTtlApplied: null, + cacheCreationInputTokens: null, + cacheCreation5mInputTokens: null, + cacheCreation1hInputTokens: null, + cacheReadInputTokens: null, + specialSettings: null, + createdAt: new Date("2026-04-21T10:04:00.000Z"), + updatedAt: new Date("2026-04-21T10:04:00.000Z"), + deletedAt: null, + }, + ]); + mockQueuePublicStatusRollupWrite + .mockResolvedValueOnce({ + written: false, + retryable: true, + reason: "redis-unavailable", + incrementCount: 1, + key: "rollup-key", + }) + .mockResolvedValueOnce({ + written: true, + retryable: false, + incrementCount: 1, + key: "rollup-key", + }); + + const { createMessageRequest, updateMessageRequestDetails, updateMessageRequestDuration } = + await import("@/repository/message"); + + await createMessageRequest({ + provider_id: 1, + user_id: 2, + key: "sk-1", + model: "gpt-4.1", + original_model: "gpt-4.1", + }); + await updateMessageRequestDuration(303, 1800); + + const finalDetails = { + statusCode: 200, + ttfbMs: 300, + outputTokens: 90, + providerChain: [ + { + id: 1, + name: "provider-a", + groupTag: "openai", + reason: "request_success" as const, + statusCode: 200, + }, + ], + model: "gpt-4.1", + }; + + await updateMessageRequestDetails(303, finalDetails); + await flushMicrotasks(); + await updateMessageRequestDetails(303, finalDetails); + await flushMicrotasks(); + + expect(mockQueuePublicStatusRollupWrite).toHaveBeenCalledTimes(2); + expect(mockQueuePublicStatusRollupWrite).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + event: expect.objectContaining({ + createdAt: new Date("2026-04-21T10:04:00.000Z"), + durationMs: 1800, + outputTokens: 90, + ttfbMs: 300, + }), + }) + ); + }); + + it("consumes the seed when the request is not part of the public status projection", async () => { + mockDbInsertReturning.mockResolvedValue([ + { + id: 404, + providerId: 1, + userId: 2, + key: "sk-1", + model: "private-model", + originalModel: "private-model", + durationMs: null, + costUsd: null, + costMultiplier: null, + sessionId: "session-1", + requestSequence: 1, + userAgent: null, + clientIp: null, + endpoint: "/v1/messages", + messagesCount: 1, + cacheTtlApplied: null, + cacheCreationInputTokens: null, + cacheCreation5mInputTokens: null, + cacheCreation1hInputTokens: null, + cacheReadInputTokens: null, + specialSettings: null, + createdAt: new Date("2026-04-21T10:05:00.000Z"), + updatedAt: new Date("2026-04-21T10:05:00.000Z"), + deletedAt: null, + }, + ]); + mockQueuePublicStatusRollupWrite.mockResolvedValue({ + written: false, + retryable: false, + reason: "ignored", + incrementCount: 0, + key: null, + }); + + const { createMessageRequest, updateMessageRequestDetails } = await import( + "@/repository/message" + ); + + await createMessageRequest({ + provider_id: 1, + user_id: 2, + key: "sk-1", + model: "private-model", + original_model: "private-model", + }); + + const finalDetails = { + statusCode: 200, + ttfbMs: 300, + outputTokens: 90, + providerChain: [ + { + id: 1, + name: "provider-a", + groupTag: "openai", + reason: "request_success" as const, + statusCode: 200, + }, + ], + model: "private-model", + }; + + await updateMessageRequestDetails(404, finalDetails); + await flushMicrotasks(); + await updateMessageRequestDetails(404, finalDetails); + await flushMicrotasks(); + + expect(mockQueuePublicStatusRollupWrite).toHaveBeenCalledTimes(1); + }); + + it("keeps the seed retryable when public status config is temporarily unavailable", async () => { + mockDbInsertReturning.mockResolvedValue([ + { + id: 505, + providerId: 1, + userId: 2, + key: "sk-1", + model: "gpt-4.1", + originalModel: "gpt-4.1", + durationMs: null, + costUsd: null, + costMultiplier: null, + sessionId: "session-1", + requestSequence: 1, + userAgent: null, + clientIp: null, + endpoint: "/v1/messages", + messagesCount: 1, + cacheTtlApplied: null, + cacheCreationInputTokens: null, + cacheCreation5mInputTokens: null, + cacheCreation1hInputTokens: null, + cacheReadInputTokens: null, + specialSettings: null, + createdAt: new Date("2026-04-21T10:06:00.000Z"), + updatedAt: new Date("2026-04-21T10:06:00.000Z"), + deletedAt: null, + }, + ]); + mockGetConfiguredPublicStatusGroupsForRollupResolution + .mockResolvedValueOnce({ groups: [], retryable: true }) + .mockResolvedValueOnce({ + retryable: false, + groups: [ + { + sourceGroupId: 42, + sourceGroupName: "openai", + publicGroupSlug: "openai", + displayName: "OpenAI", + explanatoryCopy: null, + sortOrder: 1, + models: [ + { + publicModelKey: "gpt-4.1", + label: "GPT-4.1", + vendorIconKey: "openai", + requestTypeBadge: "openaiCompatible", + }, + ], + }, + ], + }); + + const { createMessageRequest, updateMessageRequestDetails, updateMessageRequestDuration } = + await import("@/repository/message"); + + await createMessageRequest({ + provider_id: 1, + user_id: 2, + key: "sk-1", + model: "gpt-4.1", + original_model: "gpt-4.1", + }); + await updateMessageRequestDuration(505, 1900); + + const finalDetails = { + statusCode: 200, + ttfbMs: 320, + outputTokens: 95, + providerChain: [ + { + id: 1, + name: "provider-a", + groupTag: "openai", + reason: "request_success" as const, + statusCode: 200, + }, + ], + model: "gpt-4.1", + }; + + await updateMessageRequestDetails(505, finalDetails); + await flushMicrotasks(); + await updateMessageRequestDetails(505, finalDetails); + await flushMicrotasks(); + + expect(mockQueuePublicStatusRollupWrite).toHaveBeenCalledTimes(1); + expect(mockQueuePublicStatusRollupWrite).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + createdAt: new Date("2026-04-21T10:06:00.000Z"), + durationMs: 1900, + outputTokens: 95, + ttfbMs: 320, + }), + }) + ); + }); +});