-
-
Notifications
You must be signed in to change notification settings - Fork 182
Description
现在相同用户同时的多个 codex Session,会被当成一个 Session,导致会话调度不够灵活。这是 codex 的 Session 生成处理逻辑不完善导致的。
此处的修改可以参考 Wei-Shaw/claude-relay-service 项目的实现:
Codex SessionID 和会话粘性调度机制
1. SessionID 的获取方式
Codex 的 SessionID 不是由服务端生成的,而是由 Codex 客户端(如 codex_vscode 或 codex_cli_rs)在请求中提供的。
服务端从以下几个位置按优先级提取 SessionID: 1
提取优先级为:
req.headers['session_id']req.headers['x-session-id']req.body?.session_idreq.body?.conversation_id
2. Codex 请求的 Session 识别
请求验证要求
对于 Codex CLI 的请求,系统会进行严格的身份验证,确保 session_id 的存在: 2
Codex CLI 的 session_id 必须:
- 存在于请求头中
- 长度大于 20 个字符
User-Agent 识别
系统通过 User-Agent 模式识别 Codex 请求: 3
3. 会话粘性调度实现机制
是通过请求字段,而非会话内容
会话粘性调度完全通过请求字段(headers)实现,而不是通过会话内容。具体流程如下:
步骤 1: 生成会话哈希
从请求头提取 sessionId 后,使用 SHA-256 算法生成会话哈希: 4
步骤 2: 检查已有的会话映射
调度器首先检查该 sessionHash 是否已经有映射的账户: 5
步骤 3: 创建新的会话映射
如果是新会话,系统会创建新的映射关系: 6
步骤 4: Redis 存储实现
会话映射通过 Redis 存储,键格式为 unified_openai_session_mapping:{sessionHash}: 7
步骤 5: TTL 自动续期
系统支持智能 TTL 续期,当剩余时间低于阈值时自动延长: 8
架构流程图
graph TD
A[Codex Client 请求] -->|携带 session_id header| B[提取 SessionID]
B --> C{SessionID 存在?}
C -->|是| D[SHA-256 哈希]
C -->|否| E[无会话粘性]
D --> F[生成 SessionHash]
F --> G{Redis 中有映射?}
G -->|是| H[验证账户可用性]
G -->|否| I[选择新账户]
H -->|可用| J[使用已映射账户]
H -->|不可用| I
I --> K[创建新映射]
K --> L[存入 Redis with TTL]
J --> M[续期 TTL]
M --> N[转发到上游账户]
L --> N
Notes
重要特点:
- SessionID 来源:由 Codex 客户端生成并通过请求头传递,服务端不生成
- 识别方式:通过请求头字段(session_id/x-session-id),不依赖会话内容
- 粘性实现:基于 SHA-256 哈希 + Redis 键值映射
- TTL 管理:支持可配置的过期时间(默认 1 小时)和智能续期机制
- 账户类型:同时支持 OpenAI 账户和 OpenAI-Responses 账户的会话粘性
这种设计确保了同一个 Codex 会话的所有请求都会被路由到同一个上游账户,保证了会话的连贯性和上下文缓存的有效性。
Citations
File: src/routes/openaiRoutes.js (L100-102)
const sessionHash = sessionId
? crypto.createHash('sha256').update(sessionId).digest('hex')
: nullFile: src/routes/openaiRoutes.js (L238-244)
// 从请求头或请求体中提取会话 ID
const sessionId =
req.headers['session_id'] ||
req.headers['x-session-id'] ||
req.body?.session_id ||
req.body?.conversation_id ||
nullFile: src/validators/clients/codexCliValidator.js (L41-51)
// 1. 基础 User-Agent 检查
// Codex CLI 的 UA 格式:
// - codex_vscode/0.35.0 (Windows 10.0.26100; x86_64) unknown (Cursor; 0.4.10)
// - codex_cli_rs/0.38.0 (Ubuntu 22.4.0; x86_64) WindowsTerminal
const codexCliPattern = /^(codex_vscode|codex_cli_rs)\/[\d.]+/i
const uaMatch = userAgent.match(codexCliPattern)
if (!uaMatch) {
logger.debug(`Codex CLI validation failed - UA mismatch: ${userAgent}`)
return false
}File: src/validators/clients/codexCliValidator.js (L74-78)
// 4. 检查 session_id - 必须存在且长度大于20
if (!sessionId || sessionId.length <= 20) {
logger.debug(`Codex CLI validation failed - session_id missing or too short: ${sessionId}`)
return false
}File: src/services/unifiedOpenAIScheduler.js (L279-304)
// 如果有会话哈希,检查是否有已映射的账户
if (sessionHash) {
const mappedAccount = await this._getSessionMapping(sessionHash)
if (mappedAccount) {
// 验证映射的账户是否仍然可用
const isAvailable = await this._isAccountAvailable(
mappedAccount.accountId,
mappedAccount.accountType
)
if (isAvailable) {
// 🚀 智能会话续期(续期 unified 映射键,按配置)
await this._extendSessionMappingTTL(sessionHash)
logger.info(
`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`
)
// 更新账户的最后使用时间
await openaiAccountService.recordUsage(mappedAccount.accountId, 0)
return mappedAccount
} else {
logger.warn(
`⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account`
)
await this._deleteSessionMapping(sessionHash)
}
}
}File: src/services/unifiedOpenAIScheduler.js (L334-344)
// 如果有会话哈希,建立新的映射
if (sessionHash) {
await this._setSessionMapping(
sessionHash,
selectedAccount.accountId,
selectedAccount.accountType
)
logger.info(
`🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`
)
}File: src/services/unifiedOpenAIScheduler.js (L590-599)
// 💾 设置会话映射
async _setSessionMapping(sessionHash, accountId, accountType) {
const client = redis.getClientSafe()
const mappingData = JSON.stringify({ accountId, accountType })
// 依据配置设置TTL(小时)
const appConfig = require('../../config/config')
const ttlHours = appConfig.session?.stickyTtlHours || 1
const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60))
await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData)
}File: src/services/unifiedOpenAIScheduler.js (L607-646)
// 🔁 续期统一调度会话映射TTL(针对 unified_openai_session_mapping:* 键),遵循会话配置
async _extendSessionMappingTTL(sessionHash) {
try {
const client = redis.getClientSafe()
const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}`
const remainingTTL = await client.ttl(key)
if (remainingTTL === -2) {
return false
}
if (remainingTTL === -1) {
return true
}
const appConfig = require('../../config/config')
const ttlHours = appConfig.session?.stickyTtlHours || 1
const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0
if (!renewalThresholdMinutes) {
return true
}
const fullTTL = Math.max(1, Math.floor(ttlHours * 60 * 60))
const threshold = Math.max(0, Math.floor(renewalThresholdMinutes * 60))
if (remainingTTL < threshold) {
await client.expire(key, fullTTL)
logger.debug(
`🔄 Renewed unified OpenAI session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}m, renewed to ${ttlHours}h)`
)
} else {
logger.debug(
`✅ Unified OpenAI session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}m)`
)
}
return true
} catch (error) {
logger.error('❌ Failed to extend unified OpenAI session TTL:', error)
return false
}
}Metadata
Metadata
Assignees
Labels
Projects
Status