From b6350ce501c3aee50594988346929de9cf8931ce Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 5 Feb 2026 16:36:52 +0800 Subject: [PATCH 01/31] feat: add Codex channel disclaimer (i18n, OpenAI terms) --- .../channels/modals/EditChannelModal.jsx | 35 ++++++++++++------- web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/fr.json | 1 + web/src/i18n/locales/ja.json | 1 + web/src/i18n/locales/ru.json | 1 + web/src/i18n/locales/vi.json | 1 + web/src/i18n/locales/zh.json | 1 + 7 files changed, 29 insertions(+), 12 deletions(-) diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 9f6d40343f70..1b316b5bdad5 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -1857,6 +1857,17 @@ const EditChannelModal = (props) => { disabled={isIonetLocked} /> + {inputs.type === 57 && ( + + )} + {inputs.type === 20 && ( { onClick={() => handleInputChange( 'header_override', - JSON.stringify( - { - '*': true, - 're:^X-Trace-.*$': true, - 'X-Foo': '{client_header:X-Foo}', - Authorization: 'Bearer {api_key}', - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0', - }, - null, - 2, - ), + JSON.stringify( + { + '*': true, + 're:^X-Trace-.*$': true, + 'X-Foo': '{client_header:X-Foo}', + Authorization: 'Bearer {api_key}', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0', + }, + null, + 2, + ), ) } > diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 5f1ac15e3f30..7317d36c0f21 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -155,6 +155,7 @@ "SSRF防护设置": "SSRF Protection Settings", "SSRF防护详细说明": "SSRF protection prevents malicious users from using your server to access internal network resources. Configure whitelists for trusted domains/IPs and restrict allowed ports. Applies to file downloads, webhooks, and notifications.", "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "The store field authorizes OpenAI to store request data for product evaluation and optimization. Disabled by default. Enabling may cause Codex to malfunction", + "免责声明:仅限个人使用,请勿分发或共享任何凭证。该渠道存在前置条件与使用门槛,请在充分了解流程与风险后使用,并遵守 OpenAI 的相关条款与政策。": "Disclaimer: Personal use only. Do not distribute or share any credentials. This channel has prerequisites and requires prior setup; use only if you understand the flow and risks, and comply with OpenAI’s terms and policies.", "Stripe 设置": "Stripe Settings", "Telegram": "Telegram", "Telegram Bot Token": "Telegram Bot Token", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 8fa6478cbfd0..2872c939bbe8 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -157,6 +157,7 @@ "SSRF防护设置": "Protection SSRF", "SSRF防护详细说明": "La protection SSRF empêche les utilisateurs malveillants d'utiliser votre serveur pour accéder aux ressources du réseau interne. Configurez des listes blanches pour les domaines/IP de confiance et limitez les ports autorisés. S'applique aux téléchargements de fichiers, aux webhooks et aux notifications.", "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "Le champ store autorise OpenAI à stocker les données de requête pour l'évaluation et l'optimisation du produit. Désactivé par défaut. L'activation peut causer un dysfonctionnement de Codex", + "免责声明:仅限个人使用,请勿分发或共享任何凭证。该渠道存在前置条件与使用门槛,请在充分了解流程与风险后使用,并遵守 OpenAI 的相关条款与政策。": "Avertissement : usage personnel uniquement. Ne distribuez ni ne partagez aucun identifiant. Ce canal a des prérequis et nécessite une configuration préalable ; utilisez‑le uniquement si vous comprenez la procédure et les risques, et respectez les conditions et politiques d’OpenAI.", "Stripe 设置": "Paramètres Stripe", "Telegram": "Telegram", "Telegram Bot Token": "Jeton du bot Telegram", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 1aa66c93a5de..3fecb8cef786 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -153,6 +153,7 @@ "SSRF防护设置": "SSRF保護設定", "SSRF防护详细说明": "SSRF保護の詳細説明", "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "storeフィールドは、製品の評価と最適化のためにOpenAIがリクエストデータを保存することを許可します。デフォルトでは無効です。有効にすると、Codexが正常に利用できなくなる場合があります", + "免责声明:仅限个人使用,请勿分发或共享任何凭证。该渠道存在前置条件与使用门槛,请在充分了解流程与风险后使用,并遵守 OpenAI 的相关条款与政策。": "免責事項:個人利用に限ります。認証情報を配布・共有しないでください。このチャネルには前提条件があり、事前の設定が必要です。手順とリスクを理解した上で利用し、OpenAI の利用規約および関連ポリシーを遵守してください。", "Stripe 设置": "Stripe 設定", "Telegram": "Telegram", "Telegram Bot Token": "Telegram Bot Token", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 2e9137946b93..54aa7e9dc4bc 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -160,6 +160,7 @@ "SSRF防护设置": "Настройки защиты SSRF", "SSRF防护详细说明": "Подробное описание защиты SSRF", "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "Поле store используется для авторизации OpenAI хранить данные запросов для оценки и оптимизации продукта. По умолчанию отключено, после включения может привести к неработоспособности Codex", + "免责声明:仅限个人使用,请勿分发或共享任何凭证。该渠道存在前置条件与使用门槛,请在充分了解流程与风险后使用,并遵守 OpenAI 的相关条款与政策。": "Предупреждение: только для личного использования. Не распространяйте и не передавайте учетные данные. Для этого канала требуются предварительные условия и начальная настройка; используйте его только если понимаете процедуру и риски, и соблюдайте условия и политики OpenAI.", "Stripe 设置": "Настройки Stripe", "Telegram": "Telegram", "Telegram Bot Token": "Токен бота Telegram", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index f6162b8a9961..3e62ea807513 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -154,6 +154,7 @@ "SSRF防护设置": "Cài đặt bảo vệ SSRF", "SSRF防护详细说明": "Bảo vệ SSRF ngăn chặn người dùng độc hại sử dụng máy chủ của bạn để truy cập tài nguyên mạng nội bộ. Cấu hình danh sách trắng cho các tên miền/IP đáng tin cậy và hạn chế các cổng được phép. Áp dụng cho tải xuống tệp, webhook và thông báo.", "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "Trường store ủy quyền cho OpenAI lưu trữ dữ liệu yêu cầu để đánh giá và tối ưu hóa sản phẩm. Tắt theo mặc định. Bật có thể khiến Codex hoạt động không chính xác", + "免责声明:仅限个人使用,请勿分发或共享任何凭证。该渠道存在前置条件与使用门槛,请在充分了解流程与风险后使用,并遵守 OpenAI 的相关条款与政策。": "Tuyên bố miễn trừ: Chỉ dùng cho mục đích cá nhân. Không phân phối hoặc chia sẻ bất kỳ thông tin xác thực nào. Kênh này có điều kiện tiên quyết và yêu cầu thiết lập trước; chỉ sử dụng khi bạn hiểu rõ quy trình và rủi ro, và tuân thủ điều khoản và chính sách của OpenAI.", "Stripe 设置": "Cài đặt Stripe", "Telegram": "Telegram", "Telegram Bot Token": "Telegram Bot Token", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 0ffdbc400295..b1938a432a2b 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -151,6 +151,7 @@ "SSRF防护设置": "SSRF防护设置", "SSRF防护详细说明": "SSRF防护可防止恶意用户利用您的服务器访问内网资源。您可以配置受信任域名/IP的白名单,并限制允许的端口。适用于文件下载、Webhook回调和通知功能。", "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用", + "免责声明:仅限个人使用,请勿分发或共享任何凭证。该渠道存在前置条件与使用门槛,请在充分了解流程与风险后使用,并遵守 OpenAI 的相关条款与政策。": "免责声明:仅限个人使用,请勿分发或共享任何凭证。该渠道存在前置条件与使用门槛,请在充分了解流程与风险后使用,并遵守 OpenAI 的相关条款与政策。", "Stripe 设置": "Stripe 设置", "Telegram": "Telegram", "Telegram Bot Token": "Telegram Bot Token", From b5b681398ab8b0aea7519c76cc75c544bdc166f1 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 5 Feb 2026 16:59:53 +0800 Subject: [PATCH 02/31] fix: restore log content column --- .../table/usage-logs/UsageLogsColumnDefs.jsx | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx index 941f47004635..d38dc61937a8 100644 --- a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx +++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx @@ -719,19 +719,15 @@ export const getLogsColumns = ({ other?.is_system_prompt_overwritten, 'openai', ); - // Do not add billing source here; keep details clean. - const summary = [content, text ? `${t('详情')}:${text}` : null] - .filter(Boolean) - .join('\n'); return ( - - {summary} - + + {content} + ); }, }, From fca80a57ad2a29bb7fc1413ea28dc114e5f47d39 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 5 Feb 2026 19:11:58 +0800 Subject: [PATCH 03/31] fix: Claude stream block index/type transitions --- service/convert.go | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/service/convert.go b/service/convert.go index 26534e4095b2..97d1144dc7bb 100644 --- a/service/convert.go +++ b/service/convert.go @@ -252,8 +252,9 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon claudeResponses = append(claudeResponses, resp) // 首块包含工具 delta,则追加 input_json_delta if toolCall.Function.Arguments != "" { + idx := 0 claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ - Index: &info.ClaudeConvertInfo.Index, + Index: &idx, Type: "content_block_delta", Delta: &dto.ClaudeMediaMessage{ Type: "input_json_delta", @@ -270,16 +271,22 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon content := openAIResponse.Choices[0].Delta.GetContentString() if reasoning != "" { + if info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeTools || info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeText { + claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + info.ClaudeConvertInfo.Index++ + } + idx := info.ClaudeConvertInfo.Index claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ - Index: &info.ClaudeConvertInfo.Index, + Index: &idx, Type: "content_block_start", ContentBlock: &dto.ClaudeMediaMessage{ Type: "thinking", Thinking: common.GetPointer[string](""), }, }) + idx2 := idx claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ - Index: &info.ClaudeConvertInfo.Index, + Index: &idx2, Type: "content_block_delta", Delta: &dto.ClaudeMediaMessage{ Type: "thinking_delta", @@ -288,16 +295,22 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon }) info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeThinking } else if content != "" { + if info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeTools || info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeThinking { + claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + info.ClaudeConvertInfo.Index++ + } + idx := info.ClaudeConvertInfo.Index claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ - Index: &info.ClaudeConvertInfo.Index, + Index: &idx, Type: "content_block_start", ContentBlock: &dto.ClaudeMediaMessage{ Type: "text", Text: common.GetPointer[string](""), }, }) + idx2 := idx claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ - Index: &info.ClaudeConvertInfo.Index, + Index: &idx2, Type: "content_block_delta", Delta: &dto.ClaudeMediaMessage{ Type: "text_delta", @@ -422,8 +435,13 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon if reasoning != "" || textContent != "" { if reasoning != "" { if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeThinking { + if info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeText || info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeTools { + claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + info.ClaudeConvertInfo.Index++ + } + idx := info.ClaudeConvertInfo.Index claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ - Index: &info.ClaudeConvertInfo.Index, + Index: &idx, Type: "content_block_start", ContentBlock: &dto.ClaudeMediaMessage{ Type: "thinking", @@ -442,8 +460,9 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) info.ClaudeConvertInfo.Index++ } + idx := info.ClaudeConvertInfo.Index claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ - Index: &info.ClaudeConvertInfo.Index, + Index: &idx, Type: "content_block_start", ContentBlock: &dto.ClaudeMediaMessage{ Type: "text", @@ -462,7 +481,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon } } - claudeResponse.Index = &info.ClaudeConvertInfo.Index + claudeResponse.Index = common.GetPointer[int](info.ClaudeConvertInfo.Index) if !isEmpty && claudeResponse.Delta != nil { claudeResponses = append(claudeResponses, &claudeResponse) } From 7314c974f3ee3148db921b6527e322089be87be7 Mon Sep 17 00:00:00 2001 From: Seefs Date: Thu, 5 Feb 2026 19:32:26 +0800 Subject: [PATCH 04/31] fix: Claude stream block index/type transitions --- relay/common/relay_info.go | 3 ++ service/convert.go | 89 +++++++++++++++++++++++++++----------- 2 files changed, 67 insertions(+), 25 deletions(-) diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index f5c1d769e0fa..ade3876ffc31 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -37,6 +37,9 @@ type ClaudeConvertInfo struct { Usage *dto.Usage FinishReason string Done bool + + ToolCallBaseIndex int + ToolCallMaxIndexOffset int } type RerankerInfo struct { diff --git a/service/convert.go b/service/convert.go index 97d1144dc7bb..52824374e094 100644 --- a/service/convert.go +++ b/service/convert.go @@ -207,6 +207,44 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon } var claudeResponses []*dto.ClaudeResponse + // stopOpenBlocks emits the required content_block_stop event(s) for the currently open block(s) + // according to Anthropic's SSE streaming state machine: + // content_block_start -> content_block_delta* -> content_block_stop (per index). + // + // For text/thinking, there is at most one open block at info.ClaudeConvertInfo.Index. + // For tools, OpenAI tool_calls can stream multiple parallel tool_use blocks (indexed from 0), + // so we may have multiple open blocks and must stop each one explicitly. + stopOpenBlocks := func() { + switch info.ClaudeConvertInfo.LastMessagesType { + case relaycommon.LastMessageTypeText, relaycommon.LastMessageTypeThinking: + claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + case relaycommon.LastMessageTypeTools: + base := info.ClaudeConvertInfo.ToolCallBaseIndex + for offset := 0; offset <= info.ClaudeConvertInfo.ToolCallMaxIndexOffset; offset++ { + claudeResponses = append(claudeResponses, generateStopBlock(base+offset)) + } + } + } + // stopOpenBlocksAndAdvance closes the currently open block(s) and advances the content block index + // to the next available slot for subsequent content_block_start events. + // + // This prevents invalid streams where a content_block_delta (e.g. thinking_delta) is emitted for an + // index whose active content_block type is different (the typical cause of "Mismatched content block type"). + stopOpenBlocksAndAdvance := func() { + if info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeNone { + return + } + stopOpenBlocks() + switch info.ClaudeConvertInfo.LastMessagesType { + case relaycommon.LastMessageTypeTools: + info.ClaudeConvertInfo.Index = info.ClaudeConvertInfo.ToolCallBaseIndex + info.ClaudeConvertInfo.ToolCallMaxIndexOffset + 1 + info.ClaudeConvertInfo.ToolCallBaseIndex = 0 + info.ClaudeConvertInfo.ToolCallMaxIndexOffset = 0 + default: + info.ClaudeConvertInfo.Index++ + } + info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeNone + } if info.SendResponseCount == 1 { msg := &dto.ClaudeMediaMessage{ Id: openAIResponse.Id, @@ -228,6 +266,8 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon //}) if openAIResponse.IsToolCall() { info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeTools + info.ClaudeConvertInfo.ToolCallBaseIndex = 0 + info.ClaudeConvertInfo.ToolCallMaxIndexOffset = 0 var toolCall dto.ToolCallResponse if len(openAIResponse.Choices) > 0 && len(openAIResponse.Choices[0].Delta.ToolCalls) > 0 { toolCall = openAIResponse.Choices[0].Delta.ToolCalls[0] @@ -271,9 +311,8 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon content := openAIResponse.Choices[0].Delta.GetContentString() if reasoning != "" { - if info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeTools || info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeText { - claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) - info.ClaudeConvertInfo.Index++ + if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeThinking { + stopOpenBlocksAndAdvance() } idx := info.ClaudeConvertInfo.Index claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ @@ -295,9 +334,8 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon }) info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeThinking } else if content != "" { - if info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeTools || info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeThinking { - claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) - info.ClaudeConvertInfo.Index++ + if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeText { + stopOpenBlocksAndAdvance() } idx := info.ClaudeConvertInfo.Index claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ @@ -324,7 +362,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon // 如果首块就带 finish_reason,需要立即发送停止块 if len(openAIResponse.Choices) > 0 && openAIResponse.Choices[0].FinishReason != nil && *openAIResponse.Choices[0].FinishReason != "" { info.FinishReason = *openAIResponse.Choices[0].FinishReason - claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + stopOpenBlocks() oaiUsage := openAIResponse.Usage if oaiUsage == nil { oaiUsage = info.ClaudeConvertInfo.Usage @@ -355,7 +393,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon // no choices // 可能为非标准的 OpenAI 响应,判断是否已经完成 if info.ClaudeConvertInfo.Done { - claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + stopOpenBlocks() oaiUsage := info.ClaudeConvertInfo.Usage if oaiUsage != nil { claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ @@ -389,18 +427,25 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon if len(chosenChoice.Delta.ToolCalls) > 0 { toolCalls := chosenChoice.Delta.ToolCalls if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeTools { - claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) - info.ClaudeConvertInfo.Index++ + stopOpenBlocksAndAdvance() + info.ClaudeConvertInfo.ToolCallBaseIndex = info.ClaudeConvertInfo.Index + info.ClaudeConvertInfo.ToolCallMaxIndexOffset = 0 } info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeTools + base := info.ClaudeConvertInfo.ToolCallBaseIndex + maxOffset := info.ClaudeConvertInfo.ToolCallMaxIndexOffset for i, toolCall := range toolCalls { - blockIndex := info.ClaudeConvertInfo.Index + offset := 0 if toolCall.Index != nil { - blockIndex = *toolCall.Index - } else if len(toolCalls) > 1 { - blockIndex = info.ClaudeConvertInfo.Index + i + offset = *toolCall.Index + } else { + offset = i } + if offset > maxOffset { + maxOffset = offset + } + blockIndex := base + offset idx := blockIndex if toolCall.Function.Name != "" { @@ -426,19 +471,16 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon }, }) } - - info.ClaudeConvertInfo.Index = blockIndex } + info.ClaudeConvertInfo.ToolCallMaxIndexOffset = maxOffset + info.ClaudeConvertInfo.Index = base + maxOffset } else { reasoning := chosenChoice.Delta.GetReasoningContent() textContent := chosenChoice.Delta.GetContentString() if reasoning != "" || textContent != "" { if reasoning != "" { if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeThinking { - if info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeText || info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeTools { - claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) - info.ClaudeConvertInfo.Index++ - } + stopOpenBlocksAndAdvance() idx := info.ClaudeConvertInfo.Index claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ Index: &idx, @@ -456,10 +498,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon } } else { if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeText { - if info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeThinking || info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeTools { - claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) - info.ClaudeConvertInfo.Index++ - } + stopOpenBlocksAndAdvance() idx := info.ClaudeConvertInfo.Index claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ Index: &idx, @@ -487,7 +526,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon } if doneChunk || info.ClaudeConvertInfo.Done { - claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + stopOpenBlocks() oaiUsage := openAIResponse.Usage if oaiUsage == nil { oaiUsage = info.ClaudeConvertInfo.Usage From 2567cff6c8f5a5f40154427aeb032d6678090db2 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 5 Feb 2026 21:48:05 +0800 Subject: [PATCH 05/31] fix(oauth): enhance error handling and transaction management for OAuth user creation and binding - Improve error handling in DeleteCustomOAuthProvider to log and return errors when fetching binding counts. - Refactor user creation and OAuth binding logic to use transactions for atomic operations, ensuring data integrity. - Add unique constraints to UserOAuthBinding model to prevent duplicate bindings. - Enhance GitHub OAuth provider error logging for non-200 responses. - Update AccountManagement component to provide clearer error messages on API failures. --- controller/custom_oauth.go | 7 +- controller/oauth.go | 70 ++++++++++++++----- model/custom_oauth_provider.go | 7 +- model/user.go | 59 ++++++++++++++++ model/user_oauth_binding.go | 34 +++++++-- oauth/github.go | 12 ++++ .../personal/cards/AccountManagement.jsx | 6 +- 7 files changed, 168 insertions(+), 27 deletions(-) diff --git a/controller/custom_oauth.go b/controller/custom_oauth.go index a4acfc38ad3f..f26a55891292 100644 --- a/controller/custom_oauth.go +++ b/controller/custom_oauth.go @@ -296,7 +296,12 @@ func DeleteCustomOAuthProvider(c *gin.Context) { } // Check if there are any user bindings - count, _ := model.GetBindingCountByProviderId(id) + count, err := model.GetBindingCountByProviderId(id) + if err != nil { + common.SysError("Failed to get binding count for provider " + strconv.Itoa(id) + ": " + err.Error()) + common.ApiErrorMsg(c, "检查用户绑定时发生错误,请稍后重试") + return + } if count > 0 { common.ApiErrorMsg(c, "该 OAuth 提供商还有用户绑定,无法删除。请先解除所有用户绑定。") return diff --git a/controller/oauth.go b/controller/oauth.go index 58cb40d5c662..65e18f9daa17 100644 --- a/controller/oauth.go +++ b/controller/oauth.go @@ -11,6 +11,7 @@ import ( "github.com/QuantumNous/new-api/oauth" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) // providerParams returns map with Provider key for i18n templates @@ -256,27 +257,62 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o inviterId, _ = model.GetUserIdByAffCode(affCode.(string)) } - if err := user.Insert(inviterId); err != nil { - return nil, err - } - - // For custom providers, create the binding after user is created + // Use transaction to ensure user creation and OAuth binding are atomic if genericProvider, ok := provider.(*oauth.GenericOAuthProvider); ok { - binding := &model.UserOAuthBinding{ - UserId: user.Id, - ProviderId: genericProvider.GetProviderId(), - ProviderUserId: oauthUser.ProviderUserID, - } - if err := model.CreateUserOAuthBinding(binding); err != nil { - common.SysError(fmt.Sprintf("[OAuth] Failed to create binding for user %d: %s", user.Id, err.Error())) - // Don't fail the registration, just log the error + // Custom provider: create user and binding in a transaction + err := model.DB.Transaction(func(tx *gorm.DB) error { + // Create user + if err := user.InsertWithTx(tx, inviterId); err != nil { + return err + } + + // Create OAuth binding + binding := &model.UserOAuthBinding{ + UserId: user.Id, + ProviderId: genericProvider.GetProviderId(), + ProviderUserId: oauthUser.ProviderUserID, + } + if err := model.CreateUserOAuthBindingWithTx(tx, binding); err != nil { + return err + } + + return nil + }) + if err != nil { + return nil, err } + + // Perform post-transaction tasks (logs, sidebar config, inviter rewards) + user.FinalizeOAuthUserCreation(inviterId) } else { - // Built-in provider: set the provider user ID on the user model - provider.SetProviderUserID(user, oauthUser.ProviderUserID) - if err := user.Update(false); err != nil { - common.SysError(fmt.Sprintf("[OAuth] Failed to update provider ID for user %d: %s", user.Id, err.Error())) + // Built-in provider: create user and update provider ID in a transaction + err := model.DB.Transaction(func(tx *gorm.DB) error { + // Create user + if err := user.InsertWithTx(tx, inviterId); err != nil { + return err + } + + // Set the provider user ID on the user model and update + provider.SetProviderUserID(user, oauthUser.ProviderUserID) + if err := tx.Model(user).Updates(map[string]interface{}{ + "github_id": user.GitHubId, + "discord_id": user.DiscordId, + "oidc_id": user.OidcId, + "linux_do_id": user.LinuxDOId, + "wechat_id": user.WeChatId, + "telegram_id": user.TelegramId, + }).Error; err != nil { + return err + } + + return nil + }) + if err != nil { + return nil, err } + + // Perform post-transaction tasks + user.FinalizeOAuthUserCreation(inviterId) } return user, nil diff --git a/model/custom_oauth_provider.go b/model/custom_oauth_provider.go index 884e87b06ee6..43c69833a982 100644 --- a/model/custom_oauth_provider.go +++ b/model/custom_oauth_provider.go @@ -97,13 +97,18 @@ func DeleteCustomOAuthProvider(id int) error { } // IsSlugTaken checks if a slug is already taken by another provider +// Returns true on DB errors (fail-closed) to prevent slug conflicts func IsSlugTaken(slug string, excludeId int) bool { var count int64 query := DB.Model(&CustomOAuthProvider{}).Where("slug = ?", slug) if excludeId > 0 { query = query.Where("id != ?", excludeId) } - query.Count(&count) + res := query.Count(&count) + if res.Error != nil { + // Fail-closed: treat DB errors as slug being taken to prevent conflicts + return true + } return count > 0 } diff --git a/model/user.go b/model/user.go index 47508a0bb821..e0c9c686fdbb 100644 --- a/model/user.go +++ b/model/user.go @@ -429,6 +429,65 @@ func (user *User) Insert(inviterId int) error { return nil } +// InsertWithTx inserts a new user within an existing transaction. +// This is used for OAuth registration where user creation and binding need to be atomic. +// Post-creation tasks (sidebar config, logs, inviter rewards) are handled after the transaction commits. +func (user *User) InsertWithTx(tx *gorm.DB, inviterId int) error { + var err error + if user.Password != "" { + user.Password, err = common.Password2Hash(user.Password) + if err != nil { + return err + } + } + user.Quota = common.QuotaForNewUser + user.AffCode = common.GetRandomString(4) + + // 初始化用户设置 + if user.Setting == "" { + defaultSetting := dto.UserSetting{} + user.SetSetting(defaultSetting) + } + + result := tx.Create(user) + if result.Error != nil { + return result.Error + } + + return nil +} + +// FinalizeOAuthUserCreation performs post-transaction tasks for OAuth user creation. +// This should be called after the transaction commits successfully. +func (user *User) FinalizeOAuthUserCreation(inviterId int) { + // 用户创建成功后,根据角色初始化边栏配置 + var createdUser User + if err := DB.Where("id = ?", user.Id).First(&createdUser).Error; err == nil { + defaultSidebarConfig := generateDefaultSidebarConfigForRole(createdUser.Role) + if defaultSidebarConfig != "" { + currentSetting := createdUser.GetSetting() + currentSetting.SidebarModules = defaultSidebarConfig + createdUser.SetSetting(currentSetting) + createdUser.Update(false) + common.SysLog(fmt.Sprintf("为新用户 %s (角色: %d) 初始化边栏配置", createdUser.Username, createdUser.Role)) + } + } + + if common.QuotaForNewUser > 0 { + RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser))) + } + if inviterId != 0 { + if common.QuotaForInvitee > 0 { + _ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true) + RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee))) + } + if common.QuotaForInviter > 0 { + RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", logger.LogQuota(common.QuotaForInviter))) + _ = inviteUser(inviterId) + } + } +} + func (user *User) Update(updatePassword bool) error { var err error if updatePassword { diff --git a/model/user_oauth_binding.go b/model/user_oauth_binding.go index 7b2acd474907..492166251e86 100644 --- a/model/user_oauth_binding.go +++ b/model/user_oauth_binding.go @@ -3,18 +3,17 @@ package model import ( "errors" "time" + + "gorm.io/gorm" ) // UserOAuthBinding stores the binding relationship between users and custom OAuth providers type UserOAuthBinding struct { Id int `json:"id" gorm:"primaryKey"` - UserId int `json:"user_id" gorm:"index;not null"` // User ID - ProviderId int `json:"provider_id" gorm:"index;not null"` // Custom OAuth provider ID - ProviderUserId string `json:"provider_user_id" gorm:"type:varchar(256);not null"` // User ID from OAuth provider + UserId int `json:"user_id" gorm:"not null;uniqueIndex:ux_user_provider"` // User ID - one binding per user per provider + ProviderId int `json:"provider_id" gorm:"not null;uniqueIndex:ux_user_provider;uniqueIndex:ux_provider_userid"` // Custom OAuth provider ID + ProviderUserId string `json:"provider_user_id" gorm:"type:varchar(256);not null;uniqueIndex:ux_provider_userid"` // User ID from OAuth provider - one OAuth account per provider CreatedAt time.Time `json:"created_at"` - - // Composite unique index to prevent duplicate bindings - // One OAuth account can only be bound to one user } func (UserOAuthBinding) TableName() string { @@ -82,6 +81,29 @@ func CreateUserOAuthBinding(binding *UserOAuthBinding) error { return DB.Create(binding).Error } +// CreateUserOAuthBindingWithTx creates a new OAuth binding within a transaction +func CreateUserOAuthBindingWithTx(tx *gorm.DB, binding *UserOAuthBinding) error { + if binding.UserId == 0 { + return errors.New("user ID is required") + } + if binding.ProviderId == 0 { + return errors.New("provider ID is required") + } + if binding.ProviderUserId == "" { + return errors.New("provider user ID is required") + } + + // Check if this provider user ID is already taken (use tx to check within the same transaction) + var count int64 + tx.Model(&UserOAuthBinding{}).Where("provider_id = ? AND provider_user_id = ?", binding.ProviderId, binding.ProviderUserId).Count(&count) + if count > 0 { + return errors.New("this OAuth account is already bound to another user") + } + + binding.CreatedAt = time.Now() + return tx.Create(binding).Error +} + // UpdateUserOAuthBinding updates an existing OAuth binding (e.g., rebind to different OAuth account) func UpdateUserOAuthBinding(userId, providerId int, newProviderUserId string) error { // Check if the new provider user ID is already taken by another user diff --git a/oauth/github.go b/oauth/github.go index e38f8a784de0..314118a3765c 100644 --- a/oauth/github.go +++ b/oauth/github.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "strconv" "time" @@ -122,6 +123,17 @@ func (p *GitHubProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*O logger.LogDebug(ctx, "[OAuth-GitHub] GetUserInfo response status: %d", res.StatusCode) + // Check for non-200 status codes before attempting to decode + if res.StatusCode != http.StatusOK { + body, _ := io.ReadAll(res.Body) + bodyStr := string(body) + if len(bodyStr) > 500 { + bodyStr = bodyStr[:500] + "..." + } + logger.LogError(ctx, fmt.Sprintf("[OAuth-GitHub] GetUserInfo failed: status=%d, body=%s", res.StatusCode, bodyStr)) + return nil, NewOAuthErrorWithRaw(i18n.MsgOAuthGetUserErr, map[string]any{"Provider": "GitHub"}, fmt.Sprintf("status %d", res.StatusCode)) + } + var githubUser gitHubUser err = json.NewDecoder(res.Body).Decode(&githubUser) if err != nil { diff --git a/web/src/components/settings/personal/cards/AccountManagement.jsx b/web/src/components/settings/personal/cards/AccountManagement.jsx index 1499c170fd0d..bc27630ba0d7 100644 --- a/web/src/components/settings/personal/cards/AccountManagement.jsx +++ b/web/src/components/settings/personal/cards/AccountManagement.jsx @@ -107,9 +107,11 @@ const AccountManagement = ({ const res = await API.get('/api/user/oauth/bindings'); if (res.data.success) { setCustomOAuthBindings(res.data.data || []); + } else { + showError(res.data.message || t('获取绑定信息失败')); } } catch (error) { - // ignore + showError(error.response?.data?.message || error.message || t('获取绑定信息失败')); } }; @@ -131,7 +133,7 @@ const AccountManagement = ({ showError(res.data.message); } } catch (error) { - showError(t('操作失败')); + showError(error.response?.data?.message || error.message || t('操作失败')); } finally { setCustomOAuthLoading((prev) => ({ ...prev, [providerId]: false })); } From e8d26e52d80d45f637e1fab3db11dfd250cd8e78 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 5 Feb 2026 22:03:30 +0800 Subject: [PATCH 06/31] refactor(oauth): update UpdateCustomOAuthProviderRequest to use pointers for optional fields - Change fields in UpdateCustomOAuthProviderRequest struct to use pointers for optional values, allowing for better handling of nil cases. - Update UpdateCustomOAuthProvider function to check for nil before assigning optional fields, ensuring existing values are preserved when not provided. --- controller/custom_oauth.go | 42 ++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/controller/custom_oauth.go b/controller/custom_oauth.go index f26a55891292..e2245f880bbd 100644 --- a/controller/custom_oauth.go +++ b/controller/custom_oauth.go @@ -166,21 +166,21 @@ func CreateCustomOAuthProvider(c *gin.Context) { // UpdateCustomOAuthProviderRequest is the request structure for updating a custom OAuth provider type UpdateCustomOAuthProviderRequest struct { - Name string `json:"name"` - Slug string `json:"slug"` - Enabled bool `json:"enabled"` - ClientId string `json:"client_id"` - ClientSecret string `json:"client_secret"` // Optional: if empty, keep existing - AuthorizationEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` - UserInfoEndpoint string `json:"user_info_endpoint"` - Scopes string `json:"scopes"` - UserIdField string `json:"user_id_field"` - UsernameField string `json:"username_field"` - DisplayNameField string `json:"display_name_field"` - EmailField string `json:"email_field"` - WellKnown string `json:"well_known"` - AuthStyle int `json:"auth_style"` + Name string `json:"name"` + Slug string `json:"slug"` + Enabled *bool `json:"enabled"` // Optional: if nil, keep existing + ClientId string `json:"client_id"` + ClientSecret string `json:"client_secret"` // Optional: if empty, keep existing + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserInfoEndpoint string `json:"user_info_endpoint"` + Scopes string `json:"scopes"` + UserIdField string `json:"user_id_field"` + UsernameField string `json:"username_field"` + DisplayNameField string `json:"display_name_field"` + EmailField string `json:"email_field"` + WellKnown *string `json:"well_known"` // Optional: if nil, keep existing + AuthStyle *int `json:"auth_style"` // Optional: if nil, keep existing } // UpdateCustomOAuthProvider updates an existing custom OAuth provider @@ -227,7 +227,9 @@ func UpdateCustomOAuthProvider(c *gin.Context) { if req.Slug != "" { provider.Slug = req.Slug } - provider.Enabled = req.Enabled + if req.Enabled != nil { + provider.Enabled = *req.Enabled + } if req.ClientId != "" { provider.ClientId = req.ClientId } @@ -258,8 +260,12 @@ func UpdateCustomOAuthProvider(c *gin.Context) { if req.EmailField != "" { provider.EmailField = req.EmailField } - provider.WellKnown = req.WellKnown - provider.AuthStyle = req.AuthStyle + if req.WellKnown != nil { + provider.WellKnown = *req.WellKnown + } + if req.AuthStyle != nil { + provider.AuthStyle = *req.AuthStyle + } if err := model.UpdateCustomOAuthProvider(provider); err != nil { common.ApiError(c, err) From 2504818b5af1ae721990ed2ab07e517239ebfbb1 Mon Sep 17 00:00:00 2001 From: Papersnake Date: Fri, 6 Feb 2026 09:12:45 +0800 Subject: [PATCH 07/31] feat: add claude-opus-4-6 --- relay/channel/aws/constants.go | 8 +++++++- relay/channel/claude/constants.go | 2 ++ relay/channel/vertex/adaptor.go | 1 + setting/ratio_setting/cache_ratio.go | 4 ++++ setting/ratio_setting/model_ratio.go | 1 + 5 files changed, 15 insertions(+), 1 deletion(-) diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go index 4b78b398531c..848bb7eac77f 100644 --- a/relay/channel/aws/constants.go +++ b/relay/channel/aws/constants.go @@ -3,7 +3,7 @@ package aws import "strings" var awsModelIDMap = map[string]string{ - "claude-3-sonnet-20240229": "anthropic.claude-3-sonnet-20240229-v1:0", + "claude-3-sonnet-20240229": "anthropic.claude-3-sonnet-20240229-v1:0", "claude-3-opus-20240229": "anthropic.claude-3-opus-20240229-v1:0", "claude-3-haiku-20240307": "anthropic.claude-3-haiku-20240307-v1:0", "claude-3-5-sonnet-20240620": "anthropic.claude-3-5-sonnet-20240620-v1:0", @@ -16,6 +16,7 @@ var awsModelIDMap = map[string]string{ "claude-sonnet-4-5-20250929": "anthropic.claude-sonnet-4-5-20250929-v1:0", "claude-haiku-4-5-20251001": "anthropic.claude-haiku-4-5-20251001-v1:0", "claude-opus-4-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0", + "claude-opus-4-6": "anthropic.claude-opus-4-6-v1:0", // Nova models "nova-micro-v1:0": "amazon.nova-micro-v1:0", "nova-lite-v1:0": "amazon.nova-lite-v1:0", @@ -79,6 +80,11 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{ "ap": true, "eu": true, }, + "anthropic.claude-opus-4-6-v1:0": { + "us": true, + "ap": true, + "eu": true, + }, "anthropic.claude-haiku-4-5-20251001-v1:0": { "us": true, "ap": true, diff --git a/relay/channel/claude/constants.go b/relay/channel/claude/constants.go index b5dcc5af439c..797d1a90af69 100644 --- a/relay/channel/claude/constants.go +++ b/relay/channel/claude/constants.go @@ -20,6 +20,8 @@ var ModelList = []string{ "claude-sonnet-4-5-20250929-thinking", "claude-opus-4-5-20251101", "claude-opus-4-5-20251101-thinking", + "claude-opus-4-6", + "claude-opus-4-6-thinking", } var ChannelName = "claude" diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index b9fbd19a4f99..7c48907ec7e8 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -42,6 +42,7 @@ var claudeModelMap = map[string]string{ "claude-sonnet-4-5-20250929": "claude-sonnet-4-5@20250929", "claude-haiku-4-5-20251001": "claude-haiku-4-5@20251001", "claude-opus-4-5-20251101": "claude-opus-4-5@20251101", + "claude-opus-4-6": "claude-opus-4-6", } const anthropicVersion = "vertex-2023-10-16" diff --git a/setting/ratio_setting/cache_ratio.go b/setting/ratio_setting/cache_ratio.go index 665c2f5932fa..ec85de11c03b 100644 --- a/setting/ratio_setting/cache_ratio.go +++ b/setting/ratio_setting/cache_ratio.go @@ -60,6 +60,8 @@ var defaultCacheRatio = map[string]float64{ "claude-sonnet-4-5-20250929-thinking": 0.1, "claude-opus-4-5-20251101": 0.1, "claude-opus-4-5-20251101-thinking": 0.1, + "claude-opus-4-6": 0.1, + "claude-opus-4-6-thinking": 0.1, } var defaultCreateCacheRatio = map[string]float64{ @@ -82,6 +84,8 @@ var defaultCreateCacheRatio = map[string]float64{ "claude-sonnet-4-5-20250929-thinking": 1.25, "claude-opus-4-5-20251101": 1.25, "claude-opus-4-5-20251101-thinking": 1.25, + "claude-opus-4-6": 1.25, + "claude-opus-4-6-thinking": 1.25, } //var defaultCreateCacheRatio = map[string]float64{} diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 61f6d044fe71..e1a2b8ca0611 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -142,6 +142,7 @@ var defaultModelRatio = map[string]float64{ "claude-sonnet-4-20250514": 1.5, "claude-sonnet-4-5-20250929": 1.5, "claude-opus-4-5-20251101": 2.5, + "claude-opus-4-6": 2.5, "claude-3-opus-20240229": 7.5, // $15 / 1M tokens "claude-opus-4-20250514": 7.5, "claude-opus-4-1-20250805": 7.5, From ff71786d8d837371f9f40eab41683e5ef89641e8 Mon Sep 17 00:00:00 2001 From: Papersnake Date: Fri, 6 Feb 2026 09:51:15 +0800 Subject: [PATCH 08/31] fix: aws claude --- relay/channel/aws/constants.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go index 848bb7eac77f..54ab32c8c88d 100644 --- a/relay/channel/aws/constants.go +++ b/relay/channel/aws/constants.go @@ -16,7 +16,7 @@ var awsModelIDMap = map[string]string{ "claude-sonnet-4-5-20250929": "anthropic.claude-sonnet-4-5-20250929-v1:0", "claude-haiku-4-5-20251001": "anthropic.claude-haiku-4-5-20251001-v1:0", "claude-opus-4-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0", - "claude-opus-4-6": "anthropic.claude-opus-4-6-v1:0", + "claude-opus-4-6": "anthropic.claude-opus-4-6-v1", // Nova models "nova-micro-v1:0": "amazon.nova-micro-v1:0", "nova-lite-v1:0": "amazon.nova-lite-v1:0", @@ -80,7 +80,7 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{ "ap": true, "eu": true, }, - "anthropic.claude-opus-4-6-v1:0": { + "anthropic.claude-opus-4-6-v1": { "us": true, "ap": true, "eu": true, From c9f5de7048d6399500d4c3e886400bcda06f3d52 Mon Sep 17 00:00:00 2001 From: Papersnake Date: Fri, 6 Feb 2026 11:01:23 +0800 Subject: [PATCH 09/31] feat: support adaptive thinking --- relay/channel/aws/dto.go | 1 + relay/channel/claude/constants.go | 5 ++++- relay/channel/claude/relay-claude.go | 12 +++++++++++- relay/channel/vertex/dto.go | 4 ++++ relay/claude_handler.go | 12 +++++++++++- setting/ratio_setting/cache_ratio.go | 8 ++++++++ setting/ratio_setting/model_ratio.go | 4 ++++ setting/reasoning/suffix.go | 2 +- 8 files changed, 44 insertions(+), 4 deletions(-) diff --git a/relay/channel/aws/dto.go b/relay/channel/aws/dto.go index b060a593bf9d..4a942714d434 100644 --- a/relay/channel/aws/dto.go +++ b/relay/channel/aws/dto.go @@ -26,6 +26,7 @@ type AwsClaudeRequest struct { Tools any `json:"tools,omitempty"` ToolChoice any `json:"tool_choice,omitempty"` Thinking *dto.Thinking `json:"thinking,omitempty"` + OutputConfig json.RawMessage `json:"output_config,omitempty"` } func formatRequest(requestBody io.Reader, requestHeader http.Header) (*AwsClaudeRequest, error) { diff --git a/relay/channel/claude/constants.go b/relay/channel/claude/constants.go index 797d1a90af69..2da61d0c7a97 100644 --- a/relay/channel/claude/constants.go +++ b/relay/channel/claude/constants.go @@ -21,7 +21,10 @@ var ModelList = []string{ "claude-opus-4-5-20251101", "claude-opus-4-5-20251101-thinking", "claude-opus-4-6", - "claude-opus-4-6-thinking", + "claude-opus-4-6-max", + "claude-opus-4-6-high", + "claude-opus-4-6-medium", + "claude-opus-4-6-low", } var ChannelName = "claude" diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 6d90daa08a0f..bdb376edd182 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -17,6 +17,7 @@ import ( "github.com/QuantumNous/new-api/relay/reasonmap" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/setting/reasoning" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" @@ -141,7 +142,16 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe claudeRequest.MaxTokens = uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(textRequest.Model)) } - if model_setting.GetClaudeSettings().ThinkingAdapterEnabled && + if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" && + strings.HasPrefix(textRequest.Model, "claude-opus-4-6") { + claudeRequest.Model = baseModel + claudeRequest.Thinking = &dto.Thinking{ + Type: "adaptive", + } + claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel)) + claudeRequest.TopP = 0 + claudeRequest.Temperature = common.GetPointer[float64](1.0) + } else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled && strings.HasSuffix(textRequest.Model, "-thinking") { // 因为BudgetTokens 必须大于1024 diff --git a/relay/channel/vertex/dto.go b/relay/channel/vertex/dto.go index 68044ff328b1..2ddafa31b3d7 100644 --- a/relay/channel/vertex/dto.go +++ b/relay/channel/vertex/dto.go @@ -1,6 +1,8 @@ package vertex import ( + "encoding/json" + "github.com/QuantumNous/new-api/dto" ) @@ -17,6 +19,7 @@ type VertexAIClaudeRequest struct { Tools any `json:"tools,omitempty"` ToolChoice any `json:"tool_choice,omitempty"` Thinking *dto.Thinking `json:"thinking,omitempty"` + OutputConfig json.RawMessage `json:"output_config,omitempty"` } func copyRequest(req *dto.ClaudeRequest, version string) *VertexAIClaudeRequest { @@ -33,5 +36,6 @@ func copyRequest(req *dto.ClaudeRequest, version string) *VertexAIClaudeRequest Tools: req.Tools, ToolChoice: req.ToolChoice, Thinking: req.Thinking, + OutputConfig: req.OutputConfig, } } diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 7e05116daf95..fe63fcbd8911 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -2,6 +2,7 @@ package relay import ( "bytes" + "encoding/json" "fmt" "io" "net/http" @@ -14,6 +15,7 @@ import ( "github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/setting/reasoning" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" @@ -49,7 +51,15 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ request.MaxTokens = uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(request.Model)) } - if model_setting.GetClaudeSettings().ThinkingAdapterEnabled && + if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(request.Model); ok && effortLevel != "" && + strings.HasPrefix(request.Model, "claude-opus-4-6") { + request.Model = baseModel + request.Thinking = &dto.Thinking{ + Type: "adaptive", + } + request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel)) + info.UpstreamModelName = request.Model + } else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled && strings.HasSuffix(request.Model, "-thinking") { if request.Thinking == nil { // 因为BudgetTokens 必须大于1024 diff --git a/setting/ratio_setting/cache_ratio.go b/setting/ratio_setting/cache_ratio.go index ec85de11c03b..626267537da7 100644 --- a/setting/ratio_setting/cache_ratio.go +++ b/setting/ratio_setting/cache_ratio.go @@ -62,6 +62,10 @@ var defaultCacheRatio = map[string]float64{ "claude-opus-4-5-20251101-thinking": 0.1, "claude-opus-4-6": 0.1, "claude-opus-4-6-thinking": 0.1, + "claude-opus-4-6-max": 0.1, + "claude-opus-4-6-high": 0.1, + "claude-opus-4-6-medium": 0.1, + "claude-opus-4-6-low": 0.1, } var defaultCreateCacheRatio = map[string]float64{ @@ -86,6 +90,10 @@ var defaultCreateCacheRatio = map[string]float64{ "claude-opus-4-5-20251101-thinking": 1.25, "claude-opus-4-6": 1.25, "claude-opus-4-6-thinking": 1.25, + "claude-opus-4-6-max": 1.25, + "claude-opus-4-6-high": 1.25, + "claude-opus-4-6-medium": 1.25, + "claude-opus-4-6-low": 1.25, } //var defaultCreateCacheRatio = map[string]float64{} diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index e1a2b8ca0611..6b7d70e770df 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -143,6 +143,10 @@ var defaultModelRatio = map[string]float64{ "claude-sonnet-4-5-20250929": 1.5, "claude-opus-4-5-20251101": 2.5, "claude-opus-4-6": 2.5, + "claude-opus-4-6-max": 2.5, + "claude-opus-4-6-high": 2.5, + "claude-opus-4-6-medium": 2.5, + "claude-opus-4-6-low": 2.5, "claude-3-opus-20240229": 7.5, // $15 / 1M tokens "claude-opus-4-20250514": 7.5, "claude-opus-4-1-20250805": 7.5, diff --git a/setting/reasoning/suffix.go b/setting/reasoning/suffix.go index da3bdc7d3efb..fb66c6019a5d 100644 --- a/setting/reasoning/suffix.go +++ b/setting/reasoning/suffix.go @@ -6,7 +6,7 @@ import ( "github.com/samber/lo" ) -var EffortSuffixes = []string{"-high", "-medium", "-low", "-minimal"} +var EffortSuffixes = []string{"-max", "-high", "-medium", "-low", "-minimal"} // TrimEffortSuffix -> modelName level(low) exists func TrimEffortSuffix(modelName string) (string, string, bool) { From 729610beb01e01de7631536acf8a1e63d81054bb Mon Sep 17 00:00:00 2001 From: Papersnake Date: Fri, 6 Feb 2026 11:56:38 +0800 Subject: [PATCH 10/31] fix: set temperature to 1 --- relay/claude_handler.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 7e05116daf95..10b42cd980d9 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -2,6 +2,7 @@ package relay import ( "bytes" + "encoding/json" "fmt" "io" "net/http" @@ -14,6 +15,7 @@ import ( "github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/model_setting" + "github.com/QuantumNous/new-api/setting/reasoning" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" @@ -49,7 +51,17 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ request.MaxTokens = uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(request.Model)) } - if model_setting.GetClaudeSettings().ThinkingAdapterEnabled && + if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(request.Model); ok && effortLevel != "" && + strings.HasPrefix(request.Model, "claude-opus-4-6") { + request.Model = baseModel + request.Thinking = &dto.Thinking{ + Type: "adaptive", + } + request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel)) + request.TopP = 0 + request.Temperature = common.GetPointer[float64](1.0) + info.UpstreamModelName = request.Model + } else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled && strings.HasSuffix(request.Model, "-thinking") { if request.Thinking == nil { // 因为BudgetTokens 必须大于1024 From b580b8bd1dbfd250e014a2020ebcb2801ed6ebba Mon Sep 17 00:00:00 2001 From: Seefs Date: Fri, 6 Feb 2026 14:46:29 +0800 Subject: [PATCH 11/31] fix: add paragraph breaks between reasoning summary chunks in chat2responses stream --- relay/channel/openai/chat_via_responses.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/relay/channel/openai/chat_via_responses.go b/relay/channel/openai/chat_via_responses.go index d00b53907653..e50363b5e393 100644 --- a/relay/channel/openai/chat_via_responses.go +++ b/relay/channel/openai/chat_via_responses.go @@ -106,6 +106,8 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo toolCallArgsByID := make(map[string]string) toolCallNameSent := make(map[string]bool) toolCallCanonicalIDByItemID := make(map[string]string) + hasSentReasoningSummary := false + needsReasoningSummarySeparator := false //reasoningSummaryTextByKey := make(map[string]string) sendStartIfNeeded := func() bool { @@ -154,6 +156,17 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo if delta == "" { return true } + if needsReasoningSummarySeparator { + if strings.HasPrefix(delta, "\n\n") { + needsReasoningSummarySeparator = false + } else if strings.HasPrefix(delta, "\n") { + delta = "\n" + delta + needsReasoningSummarySeparator = false + } else { + delta = "\n\n" + delta + needsReasoningSummarySeparator = false + } + } if !sendStartIfNeeded() { return false } @@ -177,6 +190,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError) return false } + hasSentReasoningSummary = true return true } @@ -282,6 +296,9 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo } case "response.reasoning_summary_text.done": + if hasSentReasoningSummary { + needsReasoningSummarySeparator = true + } //case "response.reasoning_summary_part.added", "response.reasoning_summary_part.done": // key := responsesStreamIndexKey(strings.TrimSpace(streamResp.ItemID), streamResp.SummaryIndex) From a1695b76577a168ffe3e3bccf5a377c1aeae6e7a Mon Sep 17 00:00:00 2001 From: Seefs Date: Fri, 6 Feb 2026 14:47:23 +0800 Subject: [PATCH 12/31] feat: gpt-5.3-codex --- relay/channel/codex/constants.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/channel/codex/constants.go b/relay/channel/codex/constants.go index 8cdb2c38a2c8..c4f66ae17f61 100644 --- a/relay/channel/codex/constants.go +++ b/relay/channel/codex/constants.go @@ -8,7 +8,7 @@ import ( var baseModelList = []string{ "gpt-5", "gpt-5-codex", "gpt-5-codex-mini", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-max", "gpt-5.1-codex-mini", - "gpt-5.2", "gpt-5.2-codex", + "gpt-5.2", "gpt-5.2-codex", "gpt-5.3-codex", } var ModelList = withCompactModelSuffix(baseModelList) From ab5456eb1049aa8a0f3e51f359907ec7fff38b4b Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Fri, 6 Feb 2026 15:10:05 +0800 Subject: [PATCH 13/31] =?UTF-8?q?=F0=9F=94=92=20fix(security):=20sanitize?= =?UTF-8?q?=20AI-generated=20HTML=20to=20prevent=20XSS=20in=20playground?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mitigate XSS vulnerabilities in the playground where AI-generated content is rendered without sanitization, allowing potential script injection via prompt injection attacks. MarkdownRenderer.jsx: - Replace dangerouslySetInnerHTML with a sandboxed iframe for HTML preview - Use sandbox="allow-same-origin" to block script execution while allowing CSS rendering and iframe height auto-sizing - Add SandboxedHtmlPreview component with automatic height adjustment CodeViewer.jsx: - Add escapeHtml() utility to encode HTML entities before rendering - Rewrite highlightJson() to process tokens iteratively, escaping each token and structural text before wrapping in syntax highlighting spans - Escape non-JSON and very-large content paths that previously bypassed sanitization - Update linkRegex to correctly match URLs containing & entities These changes only affect the playground (AI output rendering). Admin- configured content (home page, about page, footer, notices) remains unaffected as they use separate code paths and are within the trusted admin boundary. --- .../common/markdown/MarkdownRenderer.jsx | 45 +++++++++++++++- web/src/components/playground/CodeViewer.jsx | 53 +++++++++++++------ 2 files changed, 82 insertions(+), 16 deletions(-) diff --git a/web/src/components/common/markdown/MarkdownRenderer.jsx b/web/src/components/common/markdown/MarkdownRenderer.jsx index 05419f8cc228..6a71c695f845 100644 --- a/web/src/components/common/markdown/MarkdownRenderer.jsx +++ b/web/src/components/common/markdown/MarkdownRenderer.jsx @@ -93,6 +93,49 @@ export function Mermaid(props) { ); } +function SandboxedHtmlPreview({ code }) { + const iframeRef = useRef(null); + const [iframeHeight, setIframeHeight] = useState(150); + + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe) return; + + const handleLoad = () => { + try { + const doc = iframe.contentDocument || iframe.contentWindow?.document; + if (doc) { + const height = + doc.documentElement.scrollHeight || doc.body.scrollHeight; + setIframeHeight(Math.min(Math.max(height + 16, 60), 600)); + } + } catch { + // sandbox restrictions may prevent access, that's fine + } + }; + + iframe.addEventListener('load', handleLoad); + return () => iframe.removeEventListener('load', handleLoad); + }, [code]); + + return ( +