mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:06:18 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f5321b0ef | ||
|
|
c7d7bf47d6 | ||
|
|
ebc30b6026 | ||
|
|
d5a7af2d7d | ||
|
|
81a3e26e27 | ||
|
|
64db4a270d | ||
|
|
ca027ecb90 | ||
|
|
21e6944abb | ||
|
|
4ea3d4830f | ||
|
|
944ef096b3 | ||
|
|
18a493e805 |
@@ -1188,6 +1188,110 @@ async function handleOnboardUser(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 retrieveUserQuota 请求
|
||||||
|
* POST /v1internal:retrieveUserQuota
|
||||||
|
*
|
||||||
|
* 功能:查询用户在各个Gemini模型上的配额使用情况
|
||||||
|
* 请求体:{ "project": "项目ID" }
|
||||||
|
* 响应:{ "buckets": [...] }
|
||||||
|
*/
|
||||||
|
async function handleRetrieveUserQuota(req, res) {
|
||||||
|
try {
|
||||||
|
// 1. 权限检查
|
||||||
|
if (!ensureGeminiPermission(req, res)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 会话哈希
|
||||||
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
|
// 3. 账户选择
|
||||||
|
const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash'
|
||||||
|
const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||||
|
req.apiKey,
|
||||||
|
sessionHash,
|
||||||
|
requestedModel
|
||||||
|
)
|
||||||
|
const { accountId, accountType } = schedulerResult
|
||||||
|
|
||||||
|
// 4. 账户类型验证 - v1internal 路由只支持 OAuth 账户
|
||||||
|
if (accountType === 'gemini-api') {
|
||||||
|
logger.error(`❌ v1internal routes do not support Gemini API accounts. Account: ${accountId}`)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
message:
|
||||||
|
'This endpoint only supports Gemini OAuth accounts. Gemini API Key accounts are not compatible with v1internal format.',
|
||||||
|
type: 'invalid_account_type'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 获取账户
|
||||||
|
const account = await geminiAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: 'Gemini account not found',
|
||||||
|
type: 'account_not_found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const { accessToken, refreshToken, projectId } = account
|
||||||
|
|
||||||
|
// 6. 从请求体提取项目字段(注意:字段名是 "project",不是 "cloudaicompanionProject")
|
||||||
|
const requestProject = req.body.project
|
||||||
|
|
||||||
|
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||||
|
logger.info(`RetrieveUserQuota request (${version})`, {
|
||||||
|
requestedProject: requestProject || null,
|
||||||
|
accountProject: projectId || null,
|
||||||
|
apiKeyId: req.apiKey?.id || 'unknown'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 7. 解析账户的代理配置
|
||||||
|
const proxyConfig = parseProxyConfig(account)
|
||||||
|
|
||||||
|
// 8. 获取OAuth客户端
|
||||||
|
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||||
|
|
||||||
|
// 9. 智能处理项目ID(与其他 v1internal 接口保持一致)
|
||||||
|
const effectiveProject = projectId || requestProject || null
|
||||||
|
|
||||||
|
logger.info('📋 retrieveUserQuota项目ID处理逻辑', {
|
||||||
|
accountProjectId: projectId,
|
||||||
|
requestProject,
|
||||||
|
effectiveProject,
|
||||||
|
decision: projectId ? '使用账户配置' : requestProject ? '使用请求参数' : '不使用项目ID'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 10. 构建请求体(注入 effectiveProject)
|
||||||
|
const requestBody = { ...req.body }
|
||||||
|
if (effectiveProject) {
|
||||||
|
requestBody.project = effectiveProject
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. 调用底层服务转发请求
|
||||||
|
const response = await geminiAccountService.forwardToCodeAssist(
|
||||||
|
client,
|
||||||
|
'retrieveUserQuota',
|
||||||
|
requestBody,
|
||||||
|
proxyConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json(response)
|
||||||
|
} catch (error) {
|
||||||
|
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||||
|
logger.error(`Error in retrieveUserQuota endpoint (${version})`, {
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 countTokens 请求
|
* 处理 countTokens 请求
|
||||||
*/
|
*/
|
||||||
@@ -2698,6 +2802,7 @@ module.exports = {
|
|||||||
handleSimpleEndpoint,
|
handleSimpleEndpoint,
|
||||||
handleLoadCodeAssist,
|
handleLoadCodeAssist,
|
||||||
handleOnboardUser,
|
handleOnboardUser,
|
||||||
|
handleRetrieveUserQuota,
|
||||||
handleCountTokens,
|
handleCountTokens,
|
||||||
handleGenerateContent,
|
handleGenerateContent,
|
||||||
handleStreamGenerateContent,
|
handleStreamGenerateContent,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const {
|
|||||||
handleStreamGenerateContent,
|
handleStreamGenerateContent,
|
||||||
handleLoadCodeAssist,
|
handleLoadCodeAssist,
|
||||||
handleOnboardUser,
|
handleOnboardUser,
|
||||||
|
handleRetrieveUserQuota,
|
||||||
handleCountTokens,
|
handleCountTokens,
|
||||||
handleStandardGenerateContent,
|
handleStandardGenerateContent,
|
||||||
handleStandardStreamGenerateContent,
|
handleStandardStreamGenerateContent,
|
||||||
@@ -68,7 +69,7 @@ router.get('/usage', authenticateApiKey, handleUsage)
|
|||||||
router.get('/key-info', authenticateApiKey, handleKeyInfo)
|
router.get('/key-info', authenticateApiKey, handleKeyInfo)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// v1internal 独有路由(listExperiments)
|
// v1internal 独有路由
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,6 +82,12 @@ router.post(
|
|||||||
handleSimpleEndpoint('listExperiments')
|
handleSimpleEndpoint('listExperiments')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /v1internal:retrieveUserQuota
|
||||||
|
* 获取用户配额信息(Gemini CLI 0.22.2+ 需要)
|
||||||
|
*/
|
||||||
|
router.post('/v1internal\\:retrieveUserQuota', authenticateApiKey, handleRetrieveUserQuota)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /v1beta/models/:modelName:listExperiments
|
* POST /v1beta/models/:modelName:listExperiments
|
||||||
* 带模型参数的实验列表(只有 geminiRoutes 定义此路由)
|
* 带模型参数的实验列表(只有 geminiRoutes 定义此路由)
|
||||||
|
|||||||
@@ -274,7 +274,9 @@ const handleResponses = async (req, res) => {
|
|||||||
'text_formatting',
|
'text_formatting',
|
||||||
'truncation',
|
'truncation',
|
||||||
'text',
|
'text',
|
||||||
'service_tier'
|
'service_tier',
|
||||||
|
'prompt_cache_retention',
|
||||||
|
'safety_identifier'
|
||||||
]
|
]
|
||||||
fieldsToRemove.forEach((field) => {
|
fieldsToRemove.forEach((field) => {
|
||||||
delete req.body[field]
|
delete req.body[field]
|
||||||
|
|||||||
@@ -343,8 +343,8 @@ class BedrockRelayService {
|
|||||||
res.write(`event: ${claudeEvent.type}\n`)
|
res.write(`event: ${claudeEvent.type}\n`)
|
||||||
res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`)
|
res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`)
|
||||||
|
|
||||||
// 提取使用统计
|
// 提取使用统计 (usage is reported in message_delta per Claude API spec)
|
||||||
if (claudeEvent.type === 'message_stop' && claudeEvent.data.usage) {
|
if (claudeEvent.type === 'message_delta' && claudeEvent.data.usage) {
|
||||||
totalUsage = claudeEvent.data.usage
|
totalUsage = claudeEvent.data.usage
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,8 +576,10 @@ class BedrockRelayService {
|
|||||||
return {
|
return {
|
||||||
type: 'message_start',
|
type: 'message_start',
|
||||||
data: {
|
data: {
|
||||||
type: 'message',
|
type: 'message_start',
|
||||||
|
message: {
|
||||||
id: `msg_${Date.now()}_bedrock`,
|
id: `msg_${Date.now()}_bedrock`,
|
||||||
|
type: 'message',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: [],
|
content: [],
|
||||||
model: this.defaultModel,
|
model: this.defaultModel,
|
||||||
@@ -587,21 +589,45 @@ class BedrockRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bedrockChunk.type === 'content_block_start') {
|
||||||
|
return {
|
||||||
|
type: 'content_block_start',
|
||||||
|
data: {
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: bedrockChunk.index || 0,
|
||||||
|
content_block: bedrockChunk.content_block || { type: 'text', text: '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (bedrockChunk.type === 'content_block_delta') {
|
if (bedrockChunk.type === 'content_block_delta') {
|
||||||
return {
|
return {
|
||||||
type: 'content_block_delta',
|
type: 'content_block_delta',
|
||||||
data: {
|
data: {
|
||||||
|
type: 'content_block_delta',
|
||||||
index: bedrockChunk.index || 0,
|
index: bedrockChunk.index || 0,
|
||||||
delta: bedrockChunk.delta || {}
|
delta: bedrockChunk.delta || {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bedrockChunk.type === 'content_block_stop') {
|
||||||
|
return {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
data: {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: bedrockChunk.index || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (bedrockChunk.type === 'message_delta') {
|
if (bedrockChunk.type === 'message_delta') {
|
||||||
return {
|
return {
|
||||||
type: 'message_delta',
|
type: 'message_delta',
|
||||||
data: {
|
data: {
|
||||||
|
type: 'message_delta',
|
||||||
delta: bedrockChunk.delta || {},
|
delta: bedrockChunk.delta || {},
|
||||||
usage: bedrockChunk.usage || {}
|
usage: bedrockChunk.usage || {}
|
||||||
}
|
}
|
||||||
@@ -612,7 +638,7 @@ class BedrockRelayService {
|
|||||||
return {
|
return {
|
||||||
type: 'message_stop',
|
type: 'message_stop',
|
||||||
data: {
|
data: {
|
||||||
usage: bedrockChunk.usage || {}
|
type: 'message_stop'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ const OAUTH_CONFIG = {
|
|||||||
AUTHORIZE_URL: 'https://claude.ai/oauth/authorize',
|
AUTHORIZE_URL: 'https://claude.ai/oauth/authorize',
|
||||||
TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token',
|
TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token',
|
||||||
CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
|
CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
|
||||||
REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback',
|
REDIRECT_URI: 'https://platform.claude.com/oauth/code/callback',
|
||||||
SCOPES: 'org:create_api_key user:profile user:inference',
|
SCOPES: 'org:create_api_key user:profile user:inference user:sessions:claude_code',
|
||||||
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
|
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ function generateState() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成随机的 code verifier(PKCE)
|
* 生成随机的 code verifier(PKCE)
|
||||||
|
* 符合 RFC 7636 标准:32字节随机数 → base64url编码 → 43字符
|
||||||
* @returns {string} base64url 编码的随机字符串
|
* @returns {string} base64url 编码的随机字符串
|
||||||
*/
|
*/
|
||||||
function generateCodeVerifier() {
|
function generateCodeVerifier() {
|
||||||
|
|||||||
Reference in New Issue
Block a user