feat(account): 支持账号级别拦截预热请求
- 新增 intercept_warmup_requests 配置项,存储在 credentials 字段 - 启用后,标题生成、Warmup 等预热请求返回 mock 响应,不消耗上游 token - 前端支持所有账号类型(OAuth、Setup Token、API Key)的开关配置 - 修复 OAuth 凭证刷新时丢失非 token 配置的问题
This commit is contained in:
@@ -241,16 +241,20 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update account credentials
|
// Copy existing credentials to preserve non-token settings (e.g., intercept_warmup_requests)
|
||||||
newCredentials := map[string]interface{}{
|
newCredentials := make(map[string]interface{})
|
||||||
"access_token": tokenInfo.AccessToken,
|
for k, v := range account.Credentials {
|
||||||
"token_type": tokenInfo.TokenType,
|
newCredentials[k] = v
|
||||||
"expires_in": tokenInfo.ExpiresIn,
|
|
||||||
"expires_at": tokenInfo.ExpiresAt,
|
|
||||||
"refresh_token": tokenInfo.RefreshToken,
|
|
||||||
"scope": tokenInfo.Scope,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update token-related fields
|
||||||
|
newCredentials["access_token"] = tokenInfo.AccessToken
|
||||||
|
newCredentials["token_type"] = tokenInfo.TokenType
|
||||||
|
newCredentials["expires_in"] = tokenInfo.ExpiresIn
|
||||||
|
newCredentials["expires_at"] = tokenInfo.ExpiresAt
|
||||||
|
newCredentials["refresh_token"] = tokenInfo.RefreshToken
|
||||||
|
newCredentials["scope"] = tokenInfo.Scope
|
||||||
|
|
||||||
updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
|
updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
|
||||||
Credentials: newCredentials,
|
Credentials: newCredentials,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sub2api/internal/middleware"
|
"sub2api/internal/middleware"
|
||||||
@@ -127,6 +128,16 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查预热请求拦截(在账号选择后、转发前检查)
|
||||||
|
if account.IsInterceptWarmupEnabled() && isWarmupRequest(body) {
|
||||||
|
if req.Stream {
|
||||||
|
sendMockWarmupStream(c, req.Model)
|
||||||
|
} else {
|
||||||
|
sendMockWarmupResponse(c, req.Model)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 获取账号并发槽位
|
// 3. 获取账号并发槽位
|
||||||
accountReleaseFunc, err := h.acquireAccountSlotWithWait(c, account, req.Stream, &streamStarted)
|
accountReleaseFunc, err := h.acquireAccountSlotWithWait(c, account, req.Stream, &streamStarted)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -489,3 +500,89 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isWarmupRequest 检测是否为预热请求(标题生成、Warmup等)
|
||||||
|
func isWarmupRequest(body []byte) bool {
|
||||||
|
// 快速检查:如果body不包含关键字,直接返回false
|
||||||
|
bodyStr := string(body)
|
||||||
|
if !strings.Contains(bodyStr, "title") && !strings.Contains(bodyStr, "Warmup") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析完整请求
|
||||||
|
var req struct {
|
||||||
|
Messages []struct {
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
} `json:"messages"`
|
||||||
|
System []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"system"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 messages 中的标题提示模式
|
||||||
|
for _, msg := range req.Messages {
|
||||||
|
for _, content := range msg.Content {
|
||||||
|
if content.Type == "text" {
|
||||||
|
if strings.Contains(content.Text, "Please write a 5-10 word title for the following conversation:") ||
|
||||||
|
content.Text == "Warmup" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 system 中的标题提取模式
|
||||||
|
for _, system := range req.System {
|
||||||
|
if strings.Contains(system.Text, "nalyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMockWarmupStream 发送流式 mock 响应(用于预热请求拦截)
|
||||||
|
func sendMockWarmupStream(c *gin.Context, model string) {
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Header("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
events := []string{
|
||||||
|
`event: message_start` + "\n" + `data: {"message":{"content":[],"id":"msg_mock_warmup","model":"` + model + `","role":"assistant","stop_reason":null,"stop_sequence":null,"type":"message","usage":{"input_tokens":10,"output_tokens":0}},"type":"message_start"}`,
|
||||||
|
`event: content_block_start` + "\n" + `data: {"content_block":{"text":"","type":"text"},"index":0,"type":"content_block_start"}`,
|
||||||
|
`event: content_block_delta` + "\n" + `data: {"delta":{"text":"New","type":"text_delta"},"index":0,"type":"content_block_delta"}`,
|
||||||
|
`event: content_block_delta` + "\n" + `data: {"delta":{"text":" Conversation","type":"text_delta"},"index":0,"type":"content_block_delta"}`,
|
||||||
|
`event: content_block_stop` + "\n" + `data: {"index":0,"type":"content_block_stop"}`,
|
||||||
|
`event: message_delta` + "\n" + `data: {"delta":{"stop_reason":"end_turn","stop_sequence":null},"type":"message_delta","usage":{"input_tokens":10,"output_tokens":2}}`,
|
||||||
|
`event: message_stop` + "\n" + `data: {"type":"message_stop"}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, event := range events {
|
||||||
|
_, _ = c.Writer.WriteString(event + "\n\n")
|
||||||
|
c.Writer.Flush()
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMockWarmupResponse 发送非流式 mock 响应(用于预热请求拦截)
|
||||||
|
func sendMockWarmupResponse(c *gin.Context, model string) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"id": "msg_mock_warmup",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": model,
|
||||||
|
"content": []gin.H{{"type": "text", "text": "New Conversation"}},
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"usage": gin.H{
|
||||||
|
"input_tokens": 10,
|
||||||
|
"output_tokens": 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -263,3 +263,17 @@ func (a *Account) ShouldHandleErrorCode(statusCode int) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsInterceptWarmupEnabled 检查是否启用预热请求拦截
|
||||||
|
// 启用后,标题生成、Warmup等预热请求将返回mock响应,不消耗上游token
|
||||||
|
func (a *Account) IsInterceptWarmupEnabled() bool {
|
||||||
|
if a.Credentials == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if v, ok := a.Credentials["intercept_warmup_requests"]; ok {
|
||||||
|
if enabled, ok := v.(bool); ok {
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -418,6 +418,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Intercept Warmup Requests (all account types) -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.interceptWarmupRequests') }}</label>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.interceptWarmupRequestsDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="interceptWarmupRequests = !interceptWarmupRequests"
|
||||||
|
:class="[
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||||
|
interceptWarmupRequests ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||||
|
interceptWarmupRequests ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.accounts.proxy') }}</label>
|
<label class="input-label">{{ t('admin.accounts.proxy') }}</label>
|
||||||
<ProxySelector
|
<ProxySelector
|
||||||
@@ -590,6 +615,7 @@ const allowedModels = ref<string[]>([])
|
|||||||
const customErrorCodesEnabled = ref(false)
|
const customErrorCodesEnabled = ref(false)
|
||||||
const selectedErrorCodes = ref<number[]>([])
|
const selectedErrorCodes = ref<number[]>([])
|
||||||
const customErrorCodeInput = ref<number | null>(null)
|
const customErrorCodeInput = ref<number | null>(null)
|
||||||
|
const interceptWarmupRequests = ref(false)
|
||||||
|
|
||||||
// Common models for whitelist
|
// Common models for whitelist
|
||||||
const commonModels = [
|
const commonModels = [
|
||||||
@@ -758,6 +784,7 @@ const resetForm = () => {
|
|||||||
customErrorCodesEnabled.value = false
|
customErrorCodesEnabled.value = false
|
||||||
selectedErrorCodes.value = []
|
selectedErrorCodes.value = []
|
||||||
customErrorCodeInput.value = null
|
customErrorCodeInput.value = null
|
||||||
|
interceptWarmupRequests.value = false
|
||||||
oauth.resetState()
|
oauth.resetState()
|
||||||
oauthFlowRef.value?.reset()
|
oauthFlowRef.value?.reset()
|
||||||
}
|
}
|
||||||
@@ -801,6 +828,11 @@ const handleSubmit = async () => {
|
|||||||
credentials.custom_error_codes = [...selectedErrorCodes.value]
|
credentials.custom_error_codes = [...selectedErrorCodes.value]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add intercept warmup requests setting
|
||||||
|
if (interceptWarmupRequests.value) {
|
||||||
|
credentials.intercept_warmup_requests = true
|
||||||
|
}
|
||||||
|
|
||||||
form.credentials = credentials
|
form.credentials = credentials
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
@@ -847,11 +879,17 @@ const handleExchangeCode = async () => {
|
|||||||
|
|
||||||
const extra = oauth.buildExtraInfo(tokenInfo)
|
const extra = oauth.buildExtraInfo(tokenInfo)
|
||||||
|
|
||||||
|
// Merge interceptWarmupRequests into credentials
|
||||||
|
const credentials = {
|
||||||
|
...tokenInfo,
|
||||||
|
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
|
||||||
|
}
|
||||||
|
|
||||||
await adminAPI.accounts.create({
|
await adminAPI.accounts.create({
|
||||||
name: form.name,
|
name: form.name,
|
||||||
platform: form.platform,
|
platform: form.platform,
|
||||||
type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token'
|
type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token'
|
||||||
credentials: tokenInfo,
|
credentials,
|
||||||
extra,
|
extra,
|
||||||
proxy_id: form.proxy_id,
|
proxy_id: form.proxy_id,
|
||||||
concurrency: form.concurrency,
|
concurrency: form.concurrency,
|
||||||
@@ -901,11 +939,17 @@ const handleCookieAuth = async (sessionKey: string) => {
|
|||||||
const extra = oauth.buildExtraInfo(tokenInfo)
|
const extra = oauth.buildExtraInfo(tokenInfo)
|
||||||
const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name
|
const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name
|
||||||
|
|
||||||
|
// Merge interceptWarmupRequests into credentials
|
||||||
|
const credentials = {
|
||||||
|
...tokenInfo,
|
||||||
|
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
|
||||||
|
}
|
||||||
|
|
||||||
await adminAPI.accounts.create({
|
await adminAPI.accounts.create({
|
||||||
name: accountName,
|
name: accountName,
|
||||||
platform: form.platform,
|
platform: form.platform,
|
||||||
type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token'
|
type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token'
|
||||||
credentials: tokenInfo,
|
credentials,
|
||||||
extra,
|
extra,
|
||||||
proxy_id: form.proxy_id,
|
proxy_id: form.proxy_id,
|
||||||
concurrency: form.concurrency,
|
concurrency: form.concurrency,
|
||||||
|
|||||||
@@ -286,6 +286,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Intercept Warmup Requests (all account types) -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.interceptWarmupRequests') }}</label>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.interceptWarmupRequestsDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="interceptWarmupRequests = !interceptWarmupRequests"
|
||||||
|
:class="[
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||||
|
interceptWarmupRequests ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||||
|
interceptWarmupRequests ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.accounts.proxy') }}</label>
|
<label class="input-label">{{ t('admin.accounts.proxy') }}</label>
|
||||||
<ProxySelector
|
<ProxySelector
|
||||||
@@ -401,6 +426,7 @@ const allowedModels = ref<string[]>([])
|
|||||||
const customErrorCodesEnabled = ref(false)
|
const customErrorCodesEnabled = ref(false)
|
||||||
const selectedErrorCodes = ref<number[]>([])
|
const selectedErrorCodes = ref<number[]>([])
|
||||||
const customErrorCodeInput = ref<number | null>(null)
|
const customErrorCodeInput = ref<number | null>(null)
|
||||||
|
const interceptWarmupRequests = ref(false)
|
||||||
|
|
||||||
// Common models for whitelist
|
// Common models for whitelist
|
||||||
const commonModels = [
|
const commonModels = [
|
||||||
@@ -459,6 +485,10 @@ watch(() => props.account, (newAccount) => {
|
|||||||
form.status = newAccount.status as 'active' | 'inactive'
|
form.status = newAccount.status as 'active' | 'inactive'
|
||||||
form.group_ids = newAccount.group_ids || []
|
form.group_ids = newAccount.group_ids || []
|
||||||
|
|
||||||
|
// Load intercept warmup requests setting (applies to all account types)
|
||||||
|
const credentials = newAccount.credentials as Record<string, unknown> | undefined
|
||||||
|
interceptWarmupRequests.value = credentials?.intercept_warmup_requests === true
|
||||||
|
|
||||||
// Initialize API Key fields for apikey type
|
// Initialize API Key fields for apikey type
|
||||||
if (newAccount.type === 'apikey' && newAccount.credentials) {
|
if (newAccount.type === 'apikey' && newAccount.credentials) {
|
||||||
const credentials = newAccount.credentials as Record<string, unknown>
|
const credentials = newAccount.credentials as Record<string, unknown>
|
||||||
@@ -630,6 +660,23 @@ const handleSubmit = async () => {
|
|||||||
newCredentials.custom_error_codes = [...selectedErrorCodes.value]
|
newCredentials.custom_error_codes = [...selectedErrorCodes.value]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add intercept warmup requests setting
|
||||||
|
if (interceptWarmupRequests.value) {
|
||||||
|
newCredentials.intercept_warmup_requests = true
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePayload.credentials = newCredentials
|
||||||
|
} else {
|
||||||
|
// For oauth/setup-token types, only update intercept_warmup_requests if changed
|
||||||
|
const currentCredentials = props.account.credentials as Record<string, unknown> || {}
|
||||||
|
const newCredentials: Record<string, unknown> = { ...currentCredentials }
|
||||||
|
|
||||||
|
if (interceptWarmupRequests.value) {
|
||||||
|
newCredentials.intercept_warmup_requests = true
|
||||||
|
} else {
|
||||||
|
delete newCredentials.intercept_warmup_requests
|
||||||
|
}
|
||||||
|
|
||||||
updatePayload.credentials = newCredentials
|
updatePayload.credentials = newCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -696,6 +696,8 @@ export default {
|
|||||||
enterErrorCode: 'Enter error code (100-599)',
|
enterErrorCode: 'Enter error code (100-599)',
|
||||||
invalidErrorCode: 'Please enter a valid HTTP error code (100-599)',
|
invalidErrorCode: 'Please enter a valid HTTP error code (100-599)',
|
||||||
errorCodeExists: 'This error code is already selected',
|
errorCodeExists: 'This error code is already selected',
|
||||||
|
interceptWarmupRequests: 'Intercept Warmup Requests',
|
||||||
|
interceptWarmupRequestsDesc: 'When enabled, warmup requests like title generation will return mock responses without consuming upstream tokens',
|
||||||
proxy: 'Proxy',
|
proxy: 'Proxy',
|
||||||
noProxy: 'No Proxy',
|
noProxy: 'No Proxy',
|
||||||
concurrency: 'Concurrency',
|
concurrency: 'Concurrency',
|
||||||
|
|||||||
@@ -786,6 +786,8 @@ export default {
|
|||||||
enterErrorCode: '输入错误码 (100-599)',
|
enterErrorCode: '输入错误码 (100-599)',
|
||||||
invalidErrorCode: '请输入有效的 HTTP 错误码 (100-599)',
|
invalidErrorCode: '请输入有效的 HTTP 错误码 (100-599)',
|
||||||
errorCodeExists: '该错误码已被选中',
|
errorCodeExists: '该错误码已被选中',
|
||||||
|
interceptWarmupRequests: '拦截预热请求',
|
||||||
|
interceptWarmupRequestsDesc: '启用后,标题生成等预热请求将返回 mock 响应,不消耗上游 token',
|
||||||
proxy: '代理',
|
proxy: '代理',
|
||||||
noProxy: '无代理',
|
noProxy: '无代理',
|
||||||
concurrency: '并发数',
|
concurrency: '并发数',
|
||||||
|
|||||||
Reference in New Issue
Block a user