feat: 完善 Antigravity OAuth 功能与权限校验

新增功能:
- 实现 Antigravity OAuth 账户支持与路径分流
- 支持 /antigravity/api 路径自动分流到 Antigravity OAuth 账户
- 支持 gemini-antigravity 平台类型的账户创建和管理

修复问题:
- 修复 OAuthFlow 组件中 gemini-antigravity 平台授权页面空白的问题
- 修复 EditApiKeyModal 中 Redis 返回字符串格式 permissions 导致的 400 错误
- 统一使用 hasPermission 函数进行权限校验,支持数组格式

优化改进:
- 添加 Antigravity 调试环境变量说明
This commit is contained in:
52227
2025-12-29 14:23:43 +08:00
parent 3f98267738
commit c67d2bce9d
6 changed files with 58 additions and 61 deletions

View File

@@ -63,6 +63,9 @@ CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-20
# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152 # ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152
# ANTHROPIC_DEBUG_TOOLS_DUMP=true # ANTHROPIC_DEBUG_TOOLS_DUMP=true
# #
# (可选)工具失败继续:当 tool_result 标记 is_error=true 时,提示模型不要中断任务(仅 /antigravity/api 分流生效)
# ANTHROPIC_TOOL_ERROR_CONTINUE=true
#
# 可选Antigravity 上游请求 Dump会在项目根目录写入 jsonl 文件,便于核对最终发往上游的 payload含 tools/schema 清洗后的结果) # 可选Antigravity 上游请求 Dump会在项目根目录写入 jsonl 文件,便于核对最终发往上游的 payload含 tools/schema 清洗后的结果)
# - antigravity-upstream-requests-dump.jsonl # - antigravity-upstream-requests-dump.jsonl
# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true # ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true

View File

@@ -122,12 +122,18 @@ async function handleMessagesRequest(req, res) {
try { try {
const startTime = Date.now() const startTime = Date.now()
// Claude 服务权限校验,阻止未授权的 Key const forcedVendor = req._anthropicVendor || null
if (!apiKeyService.hasPermission(req.apiKey.permissions, 'claude')) { const requiredService =
forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude'
if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) {
return res.status(403).json({ return res.status(403).json({
error: { error: {
type: 'permission_error', type: 'permission_error',
message: '此 API Key 无权访问 Claude 服务' message:
requiredService === 'gemini'
? '此 API Key 无权访问 Gemini 服务'
: '此 API Key 无权访问 Claude 服务'
} }
}) })
} }
@@ -176,7 +182,6 @@ async function handleMessagesRequest(req, res) {
} }
} }
const forcedVendor = req._anthropicVendor || null
logger.api('📥 /v1/messages request received', { logger.api('📥 /v1/messages request received', {
model: req.body.model || null, model: req.body.model || null,
forcedVendor, forcedVendor,
@@ -192,34 +197,10 @@ async function handleMessagesRequest(req, res) {
// /v1/messages 的扩展:按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱) // /v1/messages 的扩展:按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') { if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') {
const permissions = req.apiKey?.permissions || 'all'
if (permissions !== 'all' && permissions !== 'gemini') {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Gemini 服务'
}
})
}
const baseModel = (req.body.model || '').trim() const baseModel = (req.body.model || '').trim()
return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel }) return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel })
} }
// Claude 服务权限校验,阻止未授权的 Key默认路径保持不变
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
return res.status(403).json({
error: {
type: 'permission_error',
message: '此 API Key 无权访问 Claude 服务'
}
})
}
// 检查是否为流式请求 // 检查是否为流式请求
const isStream = req.body.stream === true const isStream = req.body.stream === true
@@ -1250,8 +1231,7 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
//(通过 v1internal:fetchAvailableModels避免依赖静态 modelService 列表。 //(通过 v1internal:fetchAvailableModels避免依赖静态 modelService 列表。
const forcedVendor = req._anthropicVendor || null const forcedVendor = req._anthropicVendor || null
if (forcedVendor === 'antigravity') { if (forcedVendor === 'antigravity') {
const permissions = req.apiKey?.permissions || 'all' if (!apiKeyService.hasPermission(req.apiKey?.permissions, 'gemini')) {
if (permissions !== 'all' && permissions !== 'gemini') {
return res.status(403).json({ return res.status(403).json({
error: { error: {
type: 'permission_error', type: 'permission_error',
@@ -1444,34 +1424,25 @@ router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, re
router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => { router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => {
// 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱) // 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱)
const forcedVendor = req._anthropicVendor || null const forcedVendor = req._anthropicVendor || null
if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') { const requiredService =
const permissions = req.apiKey?.permissions || 'all' forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude'
if (permissions !== 'all' && permissions !== 'gemini') {
if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) {
return res.status(403).json({ return res.status(403).json({
error: { error: {
type: 'permission_error', type: 'permission_error',
message: 'This API key does not have permission to access Gemini' message:
requiredService === 'gemini'
? 'This API key does not have permission to access Gemini'
: 'This API key does not have permission to access Claude'
} }
}) })
} }
if (requiredService === 'gemini') {
return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor }) return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor })
} }
// 检查权限
if (
req.apiKey.permissions &&
req.apiKey.permissions !== 'all' &&
req.apiKey.permissions !== 'claude'
) {
return res.status(403).json({
error: {
type: 'permission_error',
message: 'This API key does not have permission to access Claude'
}
})
}
// 🔗 会话绑定验证(与 messages 端点保持一致) // 🔗 会话绑定验证(与 messages 端点保持一致)
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body) const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
const sessionValidation = await claudeRelayConfigService.validateNewSession( const sessionValidation = await claudeRelayConfigService.validateNewSession(

View File

@@ -46,11 +46,11 @@ async function routeToBackend(req, res, requestedModel) {
logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`) logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`)
// 检查权限 // 检查权限
const permissions = req.apiKey.permissions || 'all' const { permissions } = req.apiKey
if (backend === 'claude') { if (backend === 'claude') {
// Claude 后端:通过 OpenAI 兼容层 // Claude 后端:通过 OpenAI 兼容层
if (permissions !== 'all' && permissions !== 'claude') { if (!apiKeyService.hasPermission(permissions, 'claude')) {
return res.status(403).json({ return res.status(403).json({
error: { error: {
message: 'This API key does not have permission to access Claude', message: 'This API key does not have permission to access Claude',
@@ -62,7 +62,7 @@ async function routeToBackend(req, res, requestedModel) {
await handleChatCompletion(req, res, req.apiKey) await handleChatCompletion(req, res, req.apiKey)
} else if (backend === 'openai') { } else if (backend === 'openai') {
// OpenAI 后端 // OpenAI 后端
if (permissions !== 'all' && permissions !== 'openai') { if (!apiKeyService.hasPermission(permissions, 'openai')) {
return res.status(403).json({ return res.status(403).json({
error: { error: {
message: 'This API key does not have permission to access OpenAI', message: 'This API key does not have permission to access OpenAI',

View File

@@ -21,7 +21,10 @@ const SYSTEM_REMINDER_PREFIX = '<system-reminder>'
const TOOLS_DUMP_ENV = 'ANTHROPIC_DEBUG_TOOLS_DUMP' const TOOLS_DUMP_ENV = 'ANTHROPIC_DEBUG_TOOLS_DUMP'
const TOOLS_DUMP_FILENAME = 'anthropic-tools-dump.jsonl' const TOOLS_DUMP_FILENAME = 'anthropic-tools-dump.jsonl'
const TEXT_TOOL_FALLBACK_ENV = 'ANTHROPIC_TEXT_TOOL_FALLBACK' const TEXT_TOOL_FALLBACK_ENV = 'ANTHROPIC_TEXT_TOOL_FALLBACK'
const TOOL_ERROR_CONTINUE_ENV = 'ANTHROPIC_TOOL_ERROR_CONTINUE'
const THOUGHT_SIGNATURE_FALLBACK = 'skip_thought_signature_validator' const THOUGHT_SIGNATURE_FALLBACK = 'skip_thought_signature_validator'
const TOOL_ERROR_CONTINUE_PROMPT =
'Tool calls may fail (e.g., missing prerequisites). When a tool result indicates an error, do not stop: briefly explain the cause and continue with an alternative approach or the remaining steps.'
function ensureAntigravityProjectId(account) { function ensureAntigravityProjectId(account) {
if (account.projectId) { if (account.projectId) {
@@ -710,12 +713,13 @@ function convertAnthropicMessagesToGeminiContents(
if (vendor === 'antigravity') { if (vendor === 'antigravity') {
const toolCallId = typeof toolUseId === 'string' && toolUseId ? toolUseId : undefined const toolCallId = typeof toolUseId === 'string' && toolUseId ? toolUseId : undefined
const result = parsedResponse !== null ? parsedResponse : raw || '' const result = parsedResponse !== null ? parsedResponse : raw || ''
const response = part.is_error === true ? { result, is_error: true } : { result }
parts.push({ parts.push({
functionResponse: { functionResponse: {
...(toolCallId ? { id: toolCallId } : {}), ...(toolCallId ? { id: toolCallId } : {}),
name: toolName, name: toolName,
response: { result } response
} }
}) })
} else { } else {
@@ -761,6 +765,10 @@ function buildGeminiRequestFromAnthropic(body, baseModel, { vendor = null } = {}
) )
const systemParts = buildSystemParts(body.system) const systemParts = buildSystemParts(body.system)
if (vendor === 'antigravity' && isEnvEnabled(process.env[TOOL_ERROR_CONTINUE_ENV])) {
systemParts.push({ text: TOOL_ERROR_CONTINUE_PROMPT })
}
const temperature = typeof body.temperature === 'number' ? body.temperature : 1 const temperature = typeof body.temperature === 'number' ? body.temperature : 1
const maxTokens = Number.isFinite(body.max_tokens) ? body.max_tokens : 4096 const maxTokens = Number.isFinite(body.max_tokens) ? body.max_tokens : 4096

View File

@@ -287,7 +287,7 @@
</div> </div>
<!-- Gemini OAuth流程 --> <!-- Gemini OAuth流程 -->
<div v-else-if="platform === 'gemini'"> <div v-else-if="platform === 'gemini' || platform === 'gemini-antigravity'">
<div <div
class="rounded-lg border border-green-200 bg-green-50 p-6 dark:border-green-700 dark:bg-green-900/30" class="rounded-lg border border-green-200 bg-green-50 p-6 dark:border-green-700 dark:bg-green-900/30"
> >

View File

@@ -1233,13 +1233,28 @@ onMounted(async () => {
form.totalCostLimit = props.apiKey.totalCostLimit || '' form.totalCostLimit = props.apiKey.totalCostLimit || ''
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || '' form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
// 处理权限数据,兼容旧格式(字符串)和新格式(数组) // 处理权限数据,兼容旧格式(字符串)和新格式(数组)
const perms = props.apiKey.permissions // 有效的权限值
const VALID_PERMS = ['claude', 'gemini', 'openai', 'droid']
let perms = props.apiKey.permissions
// 如果是字符串,尝试 JSON.parseRedis 可能返回 "[]" 或 "[\"gemini\"]"
if (typeof perms === 'string') {
if (perms === 'all' || perms === '') {
perms = []
} else if (perms.startsWith('[')) {
try {
perms = JSON.parse(perms)
} catch {
perms = VALID_PERMS.includes(perms) ? [perms] : []
}
} else if (VALID_PERMS.includes(perms)) {
perms = [perms]
} else {
perms = []
}
}
if (Array.isArray(perms)) { if (Array.isArray(perms)) {
form.permissions = perms // 过滤掉无效值(如 "[]"
} else if (perms === 'all' || !perms) { form.permissions = perms.filter((p) => VALID_PERMS.includes(p))
form.permissions = []
} else if (typeof perms === 'string') {
form.permissions = [perms]
} else { } else {
form.permissions = [] form.permissions = []
} }