Skip to content

codex 多个请求无法进行 Session 区分 #430

@ding113

Description

@ding113

现在相同用户同时的多个 codex Session,会被当成一个 Session,导致会话调度不够灵活。这是 codex 的 Session 生成处理逻辑不完善导致的。

此处的修改可以参考 Wei-Shaw/claude-relay-service 项目的实现:

Codex SessionID 和会话粘性调度机制

1. SessionID 的获取方式

Codex 的 SessionID 不是由服务端生成的,而是由 Codex 客户端(如 codex_vscode 或 codex_cli_rs)在请求中提供的

服务端从以下几个位置按优先级提取 SessionID: 1

提取优先级为:

  1. req.headers['session_id']
  2. req.headers['x-session-id']
  3. req.body?.session_id
  4. req.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
Loading

Notes

重要特点:

  1. SessionID 来源:由 Codex 客户端生成并通过请求头传递,服务端不生成
  2. 识别方式:通过请求头字段(session_id/x-session-id),不依赖会话内容
  3. 粘性实现:基于 SHA-256 哈希 + Redis 键值映射
  4. TTL 管理:支持可配置的过期时间(默认 1 小时)和智能续期机制
  5. 账户类型:同时支持 OpenAI 账户和 OpenAI-Responses 账户的会话粘性

这种设计确保了同一个 Codex 会话的所有请求都会被路由到同一个上游账户,保证了会话的连贯性和上下文缓存的有效性。

Citations

File: src/routes/openaiRoutes.js (L100-102)

    const sessionHash = sessionId
      ? crypto.createHash('sha256').update(sessionId).digest('hex')
      : null

File: 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 ||
      null

File: 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

No one assigned

    Labels

    Projects

    Status

    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions