feat: 支持按 API Key 控制 1M 上下文窗口访问

改进 PR #1016 的全局拦截逻辑,将 1M 上下文窗口(context-1m)的拦截
从全局一刀切改为按 API Key 精细控制。

新增 `allow1mContext` 布尔字段,默认 false(向后兼容)。管理员可在
创建/编辑 API Key 时为需要的用户单独开启 1M 上下文窗口权限。

双层保护机制:
- 第一层:API Key 未启用 allow1mContext 时直接返回 403
- 第二层:已启用但调度到的账户类型不支持 1M(非 Bedrock)时返回 403

修改文件:
- src/models/redis.js: 新增 allow1mContext 布尔字段解析
- src/services/apiKeyService.js: 创建 Key 时支持 allow1mContext 参数
- src/middleware/auth.js: 将 allow1mContext 附加到 req.apiKey
- src/routes/admin/apiKeys.js: 创建/批量创建/更新/批量更新接口支持
- src/routes/api.js: 替换全局拦截为按 Key + 账户类型双层检查
- CreateApiKeyModal.vue: 新增"允许 1M 上下文"复选框
- EditApiKeyModal.vue: 新增复选框,支持从已有数据加载
- BatchEditApiKeyModal.vue: 新增三态单选(启用/禁用/不修改)
This commit is contained in:
yptse123
2026-03-02 17:08:17 +08:00
parent bdbe728e9a
commit 5d3456911b
8 changed files with 121 additions and 6 deletions

View File

@@ -1313,6 +1313,7 @@ const authenticateApiKey = async (req, res, next) => {
restrictedModels: validation.keyData.restrictedModels, restrictedModels: validation.keyData.restrictedModels,
enableClientRestriction: validation.keyData.enableClientRestriction, enableClientRestriction: validation.keyData.enableClientRestriction,
allowedClients: validation.keyData.allowedClients, allowedClients: validation.keyData.allowedClients,
allow1mContext: validation.keyData.allow1mContext,
dailyCostLimit: validation.keyData.dailyCostLimit, dailyCostLimit: validation.keyData.dailyCostLimit,
dailyCost: validation.keyData.dailyCost, dailyCost: validation.keyData.dailyCost,
totalCostLimit: validation.keyData.totalCostLimit, totalCostLimit: validation.keyData.totalCostLimit,

View File

@@ -773,7 +773,7 @@ class RedisClient {
const parsed = { ...data } const parsed = { ...data }
// 布尔字段 // 布尔字段
const boolFields = ['isActive', 'enableModelRestriction', 'isDeleted'] const boolFields = ['isActive', 'enableModelRestriction', 'isDeleted', 'allow1mContext']
for (const field of boolFields) { for (const field of boolFields) {
if (parsed[field] !== undefined) { if (parsed[field] !== undefined) {
parsed[field] = parsed[field] === 'true' parsed[field] = parsed[field] === 'true'

View File

@@ -1483,6 +1483,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
restrictedModels, restrictedModels,
enableClientRestriction, enableClientRestriction,
allowedClients, allowedClients,
allow1mContext,
dailyCostLimit, dailyCostLimit,
totalCostLimit, totalCostLimit,
weeklyOpusCostLimit, weeklyOpusCostLimit,
@@ -1562,6 +1563,10 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
return res.status(400).json({ error: 'Allowed clients must be an array' }) return res.status(400).json({ error: 'Allowed clients must be an array' })
} }
if (allow1mContext !== undefined && typeof allow1mContext !== 'boolean') {
return res.status(400).json({ error: 'allow1mContext must be a boolean' })
}
// 验证标签字段 // 验证标签字段
if (tags !== undefined && !Array.isArray(tags)) { if (tags !== undefined && !Array.isArray(tags)) {
return res.status(400).json({ error: 'Tags must be an array' }) return res.status(400).json({ error: 'Tags must be an array' })
@@ -1662,6 +1667,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
restrictedModels, restrictedModels,
enableClientRestriction, enableClientRestriction,
allowedClients, allowedClients,
allow1mContext,
dailyCostLimit, dailyCostLimit,
totalCostLimit, totalCostLimit,
weeklyOpusCostLimit, weeklyOpusCostLimit,
@@ -1713,6 +1719,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
restrictedModels, restrictedModels,
enableClientRestriction, enableClientRestriction,
allowedClients, allowedClients,
allow1mContext,
dailyCostLimit, dailyCostLimit,
totalCostLimit, totalCostLimit,
weeklyOpusCostLimit, weeklyOpusCostLimit,
@@ -1778,6 +1785,7 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => {
restrictedModels, restrictedModels,
enableClientRestriction, enableClientRestriction,
allowedClients, allowedClients,
allow1mContext,
dailyCostLimit, dailyCostLimit,
totalCostLimit, totalCostLimit,
weeklyOpusCostLimit, weeklyOpusCostLimit,
@@ -1939,6 +1947,9 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => {
if (updates.serviceRates !== undefined) { if (updates.serviceRates !== undefined) {
finalUpdates.serviceRates = updates.serviceRates finalUpdates.serviceRates = updates.serviceRates
} }
if (updates.allow1mContext !== undefined) {
finalUpdates.allow1mContext = updates.allow1mContext
}
if (updates.weeklyResetDay !== undefined) { if (updates.weeklyResetDay !== undefined) {
const day = Number(updates.weeklyResetDay) const day = Number(updates.weeklyResetDay)
if (Number.isInteger(day) && day >= 1 && day <= 7) { if (Number.isInteger(day) && day >= 1 && day <= 7) {
@@ -2079,6 +2090,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
restrictedModels, restrictedModels,
enableClientRestriction, enableClientRestriction,
allowedClients, allowedClients,
allow1mContext,
expiresAt, expiresAt,
dailyCostLimit, dailyCostLimit,
totalCostLimit, totalCostLimit,
@@ -2212,6 +2224,13 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
updates.allowedClients = allowedClients updates.allowedClients = allowedClients
} }
if (allow1mContext !== undefined) {
if (typeof allow1mContext !== 'boolean') {
return res.status(400).json({ error: 'allow1mContext must be a boolean' })
}
updates.allow1mContext = allow1mContext
}
// 处理过期时间字段 // 处理过期时间字段
if (expiresAt !== undefined) { if (expiresAt !== undefined) {
if (expiresAt === null) { if (expiresAt === null) {

View File

@@ -197,13 +197,14 @@ async function handleMessagesRequest(req, res) {
} }
} }
// 拦截 1M 上下文窗口请求anthropic-beta 包含 context-1m // 检测 1M 上下文窗口请求anthropic-beta 包含 context-1m
const betaHeader = (req.headers['anthropic-beta'] || '').toLowerCase() const betaHeader = (req.headers['anthropic-beta'] || '').toLowerCase()
if (betaHeader.includes('context-1m')) { const is1mContextRequest = betaHeader.includes('context-1m')
if (is1mContextRequest && !req.apiKey.allow1mContext) {
return res.status(403).json({ return res.status(403).json({
error: { error: {
type: 'forbidden', type: 'forbidden',
message: '暂不支持 1M 上下文窗口,请切换为非 [1m] 模型' message: '该 API Key 未启用 1M 上下文窗口,请联系管理员开启或切换为非 [1m] 模型'
} }
}) })
} }
@@ -388,6 +389,16 @@ async function handleMessagesRequest(req, res) {
throw error throw error
} }
// 1M 上下文窗口:检查调度到的账户类型是否支持
if (is1mContextRequest && accountType !== 'bedrock') {
return res.status(403).json({
error: {
type: 'forbidden',
message: `1M 上下文窗口仅支持 Bedrock 账户类型,当前调度到 ${accountType},请绑定 Bedrock 账户`
}
})
}
// 🔗 在成功调度后建立会话绑定(仅 claude-official 类型) // 🔗 在成功调度后建立会话绑定(仅 claude-official 类型)
// claude-official 只接受1) 新会话 2) 已绑定的会话 // claude-official 只接受1) 新会话 2) 已绑定的会话
if ( if (

View File

@@ -153,6 +153,7 @@ class ApiKeyService {
restrictedModels = [], restrictedModels = [],
enableClientRestriction = false, enableClientRestriction = false,
allowedClients = [], allowedClients = [],
allow1mContext = false,
dailyCostLimit = 0, dailyCostLimit = 0,
totalCostLimit = 0, totalCostLimit = 0,
weeklyOpusCostLimit = 0, weeklyOpusCostLimit = 0,
@@ -197,6 +198,7 @@ class ApiKeyService {
restrictedModels: JSON.stringify(restrictedModels || []), restrictedModels: JSON.stringify(restrictedModels || []),
enableClientRestriction: String(enableClientRestriction || false), enableClientRestriction: String(enableClientRestriction || false),
allowedClients: JSON.stringify(allowedClients || []), allowedClients: JSON.stringify(allowedClients || []),
allow1mContext: String(allow1mContext || false),
dailyCostLimit: String(dailyCostLimit || 0), dailyCostLimit: String(dailyCostLimit || 0),
totalCostLimit: String(totalCostLimit || 0), totalCostLimit: String(totalCostLimit || 0),
weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0), weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0),

View File

@@ -322,6 +322,32 @@
</div> </div>
</div> </div>
<!-- 允许 1M 上下文 -->
<div>
<div class="mb-3 flex items-center gap-4">
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300"
>允许 1M 上下文</label
>
<div class="flex gap-4">
<label class="flex cursor-pointer items-center">
<input v-model="form.allow1mContext" class="mr-2" type="radio" :value="true" />
<span class="text-sm text-gray-700 dark:text-gray-300">启用</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.allow1mContext" class="mr-2" type="radio" :value="false" />
<span class="text-sm text-gray-700 dark:text-gray-300">禁用</span>
</label>
<label class="flex cursor-pointer items-center">
<input v-model="form.allow1mContext" class="mr-2" type="radio" :value="null" />
<span class="text-sm text-gray-700 dark:text-gray-300">不修改</span>
</label>
</div>
</div>
<p class="ml-0 text-xs text-gray-500 dark:text-gray-400">
启用后允许使用 [1m] 模型需要 Bedrock 账户支持
</p>
</div>
<!-- 服务权限 --> <!-- 服务权限 -->
<div> <div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300" <label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
@@ -557,7 +583,8 @@ const form = reactive({
bedrockAccountId: '', bedrockAccountId: '',
droidAccountId: '', droidAccountId: '',
tags: [], tags: [],
isActive: null // null表示不修改 isActive: null, // null表示不修改
allow1mContext: null // null表示不修改
}) })
const UNCHANGED_OPTION_VALUE = '__KEEP_ORIGINAL__' const UNCHANGED_OPTION_VALUE = '__KEEP_ORIGINAL__'
@@ -842,6 +869,11 @@ const batchUpdateApiKeys = async () => {
updates.isActive = form.isActive updates.isActive = form.isActive
} }
// 1M 上下文
if (form.allow1mContext !== null) {
updates.allow1mContext = form.allow1mContext
}
// 标签处理 // 标签处理
if (tagOperation.value !== 'none') { if (tagOperation.value !== 'none') {
updates.tags = form.tags updates.tags = form.tags

View File

@@ -938,6 +938,27 @@
</div> </div>
</div> </div>
<!-- 允许 1M 上下文 -->
<div>
<div class="mb-2 flex items-center">
<input
id="allow1mContext"
v-model="form.allow1mContext"
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500"
type="checkbox"
/>
<label
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
for="allow1mContext"
>
允许 1M 上下文
</label>
</div>
<p class="ml-6 text-xs text-gray-500 dark:text-gray-400">
启用后允许使用 [1m] 模型需要 Bedrock 账户支持
</p>
</div>
<div class="flex gap-3 pt-2"> <div class="flex gap-3 pt-2">
<button <button
class="flex-1 rounded-lg bg-gray-100 px-4 py-2.5 text-sm font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600" class="flex-1 rounded-lg bg-gray-100 px-4 py-2.5 text-sm font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
@@ -1114,6 +1135,7 @@ const form = reactive({
modelInput: '', modelInput: '',
enableClientRestriction: false, enableClientRestriction: false,
allowedClients: [], allowedClients: [],
allow1mContext: false,
tags: [] tags: []
}) })
@@ -1542,7 +1564,8 @@ const createApiKey = async () => {
enableModelRestriction: form.enableModelRestriction, enableModelRestriction: form.enableModelRestriction,
restrictedModels: form.restrictedModels, restrictedModels: form.restrictedModels,
enableClientRestriction: form.enableClientRestriction, enableClientRestriction: form.enableClientRestriction,
allowedClients: form.allowedClients allowedClients: form.allowedClients,
allow1mContext: form.allow1mContext
} }
// 处理Claude账户绑定区分OAuth和Console // 处理Claude账户绑定区分OAuth和Console

View File

@@ -776,6 +776,27 @@
</div> </div>
</div> </div>
<!-- 允许 1M 上下文 -->
<div>
<div class="mb-2 flex items-center">
<input
id="editAllow1mContext"
v-model="form.allow1mContext"
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
/>
<label
class="ml-2 cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
for="editAllow1mContext"
>
允许 1M 上下文
</label>
</div>
<p class="ml-6 text-xs text-gray-500 dark:text-gray-400">
启用后允许使用 [1m] 模型需要 Bedrock 账户支持
</p>
</div>
<div class="flex gap-3 pt-4"> <div class="flex gap-3 pt-4">
<button <button
class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600" class="flex-1 rounded-xl bg-gray-100 px-6 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
@@ -948,6 +969,7 @@ const form = reactive({
modelInput: '', modelInput: '',
enableClientRestriction: false, enableClientRestriction: false,
allowedClients: [], allowedClients: [],
allow1mContext: false,
tags: [], tags: [],
isActive: true, isActive: true,
ownerId: '' // 新增所有者ID ownerId: '' // 新增所有者ID
@@ -1131,6 +1153,9 @@ const updateApiKey = async () => {
data.enableClientRestriction = form.enableClientRestriction data.enableClientRestriction = form.enableClientRestriction
data.allowedClients = form.allowedClients data.allowedClients = form.allowedClients
// 1M 上下文
data.allow1mContext = form.allow1mContext
// 活跃状态 // 活跃状态
data.isActive = form.isActive data.isActive = form.isActive
@@ -1448,6 +1473,8 @@ onMounted(async () => {
props.apiKey.enableModelRestriction === true || props.apiKey.enableModelRestriction === 'true' props.apiKey.enableModelRestriction === true || props.apiKey.enableModelRestriction === 'true'
form.enableClientRestriction = form.enableClientRestriction =
props.apiKey.enableClientRestriction === true || props.apiKey.enableClientRestriction === 'true' props.apiKey.enableClientRestriction === true || props.apiKey.enableClientRestriction === 'true'
form.allow1mContext =
props.apiKey.allow1mContext === true || props.apiKey.allow1mContext === 'true'
// 初始化活跃状态,默认为 true强制转换为布尔值因为Redis返回字符串 // 初始化活跃状态,默认为 true强制转换为布尔值因为Redis返回字符串
form.isActive = form.isActive =
props.apiKey.isActive === undefined || props.apiKey.isActive === undefined ||