mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-04-19 13:38:41 +00:00
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:
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
Reference in New Issue
Block a user