Skip to content

enhancement: 额度管理 #321

@piexian

Description

@piexian

当前现状

一、现状诊断

1.1 三类桶的行为差异

桶类型 额度格式 调用后立即扣减? 可信度 可作为账本?
grok-3 totalTokens/remainingTokens + low/high ✅ 第 1 次就扣 🟢 高 ✅ 是
grok-4 totalTokens/remainingTokens + low/high ✅ 第 1 次就扣 🟢 高 ✅ 是
grok-4-1-thinking-1129 totalQueries/remainingQueries ❌ 连续 8 次仍 8/8 🔴 低 ❌ 仅探针
grok-420 totalQueries/remainingQueries ❌ 连续 8 次仍 8/8 🔴 低 ❌ 仅探针

1.2 模型到真实扣费桶映射

graph LR
    subgraph "grok-3 桶 (80 tokens)"
        G3[grok-3]
        G3T[grok-3-thinking]
        G3M[grok-3-mini]
    end
    subgraph "grok-3 HIGH(图片/视频)"
        GIF[grok-imagine-1.0-fast]
        GI[grok-imagine-1.0]
        GIV[grok-imagine-1.0-video]
    end
    subgraph "grok-4 桶 (40 tokens)"
        G4[grok-4]
        G4T[grok-4-thinking]
    end
    subgraph "混合扣费(同时扣 grok-3 + grok-4)"
        G41F[grok-4.1-fast]
        G41E[grok-4.1-expert]
    end
    subgraph "独立上游"
        GIE[grok-imagine-1.0-edit]
    end
    subgraph "不可观测扣费"
        G41M[grok-4.1-mini]
        G41TH[grok-4.1-thinking]
        G420[grok-4.20-beta]
    end

    GIF -->|"-4 tokens, -1 high"| G3
    GI -->|"-4 tokens, -1 high"| G3
    GIV -->|"-4 tokens, -1 high"| G3
    G41F -->|"-1 token"| G3
    G41F -->|"-1 token"| G4
    G41E -->|"-4 tokens, -1 high"| G3
    G41E -->|"-4 tokens, -1 high"| G4
    GIE -->|"独立,无可观测桶"| GIE
Loading

1.3 每次调用的真实消耗量

模型 扣费桶 remainingTokens low high
grok-3 grok-3 -1 -1 0
grok-3-thinking grok-3 -1 -1 0
grok-3-mini grok-3 -1 -1 -1
grok-4 grok-4 -4 -4 -1
grok-4-thinking grok-4 -4 -4 -1
grok-4.1-fast grok-3 + grok-4 -1 各 -1 0
grok-4.1-expert grok-3 + grok-4 -4 各 -4 -1
grok-imagine-1.0 grok-3 -4 -4 -1
grok-imagine-1.0-fast grok-3 -4 -4 -1
grok-imagine-1.0-video grok-3 -4 -4 -1
grok-imagine-1.0-edit 独立 (imagine-image-edit) 无可观测扣减 - -

二、核心问题

❌ 问题 1:单一 quota 无法反映真实额度

当前所有模型共用一个 quota 字段,但实测显示 grok-3 有 80 tokens、grok-4 只有 40 tokens,且扣减速率完全不同。

❌ 问题 2:上游 429 无法区分来源

Caution

所有模型的 429 响应 完全相同{"error":{"code":8,"message":"Too many requests","details":[]}},无 retry-after,无 x-ratelimit-* 头。无法靠响应体区分是 grok-3 打满、grok-4 打满、还是 4.1/420 限流。

❌ 问题 3:grok-4.1/420 查询桶不可信

grok-4-1-thinking-1129grok-420/rest/rate-limits 始终返回 8/8,但实际请求在第 9 次或首次就可能 429。


期望改进

分层优化方案

拆分桶结构

token_state:
  grok3:                          # 真实账本
    remaining_tokens: int         # 总额度 80
    low_remaining: int            # lowEffort 剩余
    high_remaining: int           # highEffort 剩余
    synced_at: datetime           # 上次同步时间

  grok4:                          # 真实账本
    remaining_tokens: int         # 总额度 40
    low_remaining: int
    high_remaining: int
    synced_at: datetime

  probes:                         # 仅探针,不作为扣费依据
    grok41_queries: int           # grok-4-1-thinking-1129 查询桶
    grok420_queries: int          # grok-420 查询桶
    synced_at: datetime

  model_cooldowns:                # 模型级冷却计时器
    "grok-4.1-mini":          until_ts
    "grok-4.1-thinking":      until_ts
    "grok-4.20-beta":         until_ts
    "grok-imagine-1.0-edit":  until_ts   # 独立上游,探测型冷却

请求前预检规则

┌─────────────────────────────────────────────────────┐
│                   收到请求 (model_alias)              │
└────────────────────────┬────────────────────────────┘
                         ▼
              ┌──── 模型分类 ────┐
              │                  │
    ┌─────────┴─┐  ┌────────┐  ┌┴──────────┐  ┌──────────┐
    │ grok-3 系  │  │grok-4系│  │ 4.1混合扣费│  │ 不可观测  │
    │ 3/3t/3m   │  │ 4/4t   │  │ fast/exp  │  │mini/th/420│
    └─────┬─────┘  └───┬────┘  └─────┬─────┘  └─────┬────┘
          ▼            ▼             ▼               ▼
    检查 grok3 桶  检查 grok4 桶  检查 grok3       检查冷却
    tokens>0?     tokens>0?     AND grok4 桶     until_ts 过期?
    low>=1?       low>=1?       都 >0?           最近成功过?
                  high>=1?

各模型具体预检阈值:

模型 grok-3 tokens grok-3 low grok-3 high grok-4 tokens grok-4 low grok-4 high 冷却检查
grok-3 ≥1 ≥1 - - - - -
grok-3-thinking ≥1 ≥1 - - - - -
grok-3-mini ≥1 ≥1 ≥1 - - - -
grok-4 - - - ≥1 ≥1 ≥1 -
grok-4-thinking - - - ≥1 ≥1 ≥1 -
grok-4.1-fast ≥1 ≥1 - ≥1 ≥1 - -
grok-4.1-expert ≥4 ≥4 ≥1 ≥4 ≥4 ≥1 -
grok-4.1-mini - - - - - -
grok-4.1-thinking - - - - - -
grok-4.20-beta - - - - - -

成功后本地扣费

  • grok-3 系tokens -= 1, low -= 1(grok-3-mini 额外 high -= 1
  • grok-4 系tokens -= 4, low -= 4, high -= 1
  • grok-4.1-fastgrok3.tokens -= 1, grok3.low -= 1 + grok4.tokens -= 1, grok4.low -= 1
  • grok-4.1-expertgrok3.tokens -= 4, grok3.low -= 4, grok3.high -= 1 + grok4.tokens -= 4, grok4.low -= 4, grok4.high -= 1
  • grok-4.1-mini / thinking / 4.20-beta:❌ 不做本地扣费

429 处理策略

请求模型 429 时的动作
grok-3 / 3-thinking / 3-mini 标记 grok-3 桶冷却
grok-4 / 4-thinking 标记 grok-4 桶冷却
grok-4.1-fast / expert 优先做 token + model_alias 冷却;同时观察 grok-3/grok-4 桶变化
grok-4.1-mini token + "grok-4.1-mini" 冷却
grok-4.1-thinking token + "grok-4.1-thinking" 冷却
grok-4.20-beta token + "grok-4.20-beta" 冷却

Important

对 grok-4.1-mini/thinking/4.20-beta 的 429,绝不能做 token 全局失效或查询桶减一,只能做 token + model 维度的冷却。

冷却时长建议

场景 冷却时长
grok-3/grok-4 桶打满 取「窗口剩余时间」,兜底 120 秒
grok-4.1-mini 连续 429 60 秒短冷却,再试一次仍 429 则翻倍至 300 秒
grok-4.1-thinking 首次 429 300 秒,较长冷却
grok-4.20-beta 首次 429 300 秒,实测等 3 分钟仍 429

结构化日志

每次请求记录以下字段:

{
  "ts": "2026-03-13T06:00:00Z",
  "token_tail": "x0tea9SY",
  "requested_model": "grok-4.1-fast",
  "reported_model": "grok-3",
  "status": 200,
  "grok3_before": {"tokens": 60, "low": 60, "high": 15},
  "grok3_after":  {"tokens": 59, "low": 59, "high": 15},
  "grok4_before": {"tokens": 21, "low": 21, "high": 5},
  "grok4_after":  {"tokens": 20, "low": 20, "high": 5},
  "applied_cooldown_scope": null
}

Token 选择算法改造

选 token 时:
  1. 根据 requested_model 确定需要检查的桶类型
  2. 过滤掉正在冷却的 token
  3. 按桶剩余量排序,优先选余量最大的 token
  4. 对于 grok-4.1-fast/expert,需要同时满足 grok-3 + grok-4 两个桶

低频校准任务

  • 5 分钟 对活跃 token 调用 /rest/rate-limits 同步 grok-3 / grok-4 真实桶
  • 不要高频刷新 grok-4.1 / grok-420 查询桶(无实际意义)
  • 同步后校正本地账本的漂移

持续观测

  • 对 grok-4.1-mini 持续积累样本:是否存在隐性扣费桶?什么条件下恢复可用?
  • 对 grok-4.20-beta 观测窗口重置周期
  • 记录 reported_modelrequested_model 的偏差,为后续映射优化积累数据

预期收益

指标 优化前 优化后
grok-3/4 额度利用率 因单一 quota 被错误标记不可用而浪费 分桶后精确到 ±1 token
grok-4.1 误判率 查询桶 8/8 误以为可用 → 429 冷却机制拦截,减少无效请求
429 后恢复速度 全局冷却,所有模型不可用 按桶/按模型冷却,互不影响
混合模型调度 不知道 fast/expert 同时消耗两个桶 预检同时卡两个桶,避免打空

Warning

  1. grok-4.1-mini 的「前 8 次成功,第 9 次 429」行为可能是隐性速率限制而非额度耗尽,冷却后可能恢复
  2. grok-4.1-fast 响应 reported_model=grok-3 但同时扣 grok-4 桶,说明上游存在多步内部调用链,未来扣费行为可能变化
  3. 方案基于单次实测快照,上游策略随时可能调整,建议保留结构化日志持续验证

前端额度显示优化

现状问题

当前前端 (token.html + token.js) 的额度展示存在以下问题:

位置 现状 问题
统计区 只有一个 Chat 剩余 数字(所有 token 的 quota 求和) 无法区分 grok-3 和 grok-4 的剩余量
表格列 只有一列 额度,显示单一 item.quota 数字 无法看出哪个桶已满、哪个桶还有余量
编辑弹窗 只有一个 额度 输入框 管理员无法分别调整不同桶的额度
后端模型 TokenInfo.quota 只有一个 int 字段 所有桶压缩成一个数字

后端数据结构改造

TokenInfo 模型扩展

class BucketQuota(BaseModel):
    """单桶额度"""
    remaining_tokens: int = 0
    total_tokens: int = 0
    low_remaining: int = 0
    high_remaining: int = 0

class TokenInfo(BaseModel):
    # ... 现有字段保留 ...
    quota: int = BASIC__DEFAULT_QUOTA            # 保留兼容,作为 grok-3 桶剩余
    
    # 新增分桶字段
    grok3_quota: Optional[BucketQuota] = None
    grok4_quota: Optional[BucketQuota] = None
    grok41_queries: Optional[int] = None         # grok-4-1 查询桶(探针)
    grok420_queries: Optional[int] = None        # grok-420 查询桶(探针)
    
    # 模型级冷却
    model_cooldowns: Dict[str, int] = {}         # model_alias -> until_ts

API 响应示例

{
  "token": "sso...",
  "status": "active",
  "quota": 49,
  "grok3_quota": {
    "remaining_tokens": 49,
    "total_tokens": 80,
    "low_remaining": 49,
    "high_remaining": 12
  },
  "grok4_quota": {
    "remaining_tokens": 9,
    "total_tokens": 40,
    "low_remaining": 9,
    "high_remaining": 2
  },
  "grok41_queries": 8,
  "grok420_queries": 8,
  "model_cooldowns": {
    "grok-4.1-mini": 1710312000000
  }
}

统计区改造

改为 3 行 × 4 列 = 12 卡片:

Row 1: Token 统计(保留不变)
┌─────────────┐  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐
│ Token 总数   │  │ Token 正常   │  │ Token 限流   │  │ Token 失效   │
└─────────────┘  └─────────────┘  └─────────────┘  └─────────────┘

Row 2: Chat 桶额度
┌─────────────┐  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐
│ Grok-3 剩余  │  │ Grok-4 剩余  │  │ Grok-4.1    │  │ 总调用次数   │
│  980/1600   │  │  180/800    │  │   探针 8/8   │  │    1,234    │
│ ██████░░░░  │  │ ██░░░░░░░░  │  │  ⚠️ 仅参考   │  │             │
└─────────────┘  └─────────────┘  └─────────────┘  └─────────────┘

Row 3: Image / Video / Edit
┌─────────────┐  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐
│ Image 剩余   │  │ Video 剩余   │  │ Image Edit  │  │  (空/预留) │
│  240/320    │  │  240/320    │  │  探测型冷却   │  │             │
│ = G3 high汇总│  │ = G3 high汇总│  │  ⚠️ 独立上游  │  │             │
└─────────────┘  └─────────────┘  └─────────────┘  └─────────────┘

Important

  • Image 剩余 = 所有 active token 的 grok3_quota.high_remaining 汇总(因为 grok-imagine-1.0 / grok-imagine-1.0-fast 走 grok-3 HIGH,每次 -1 high
  • Video 剩余 = 同 Image,因为 grok-imagine-1.0-video 同样走 grok-3 HIGH
  • Image Edit = imagine-image-edit 为独立上游(model.py 中 grok_model="imagine-image-edit"),无可观测的真实桶。需要像 grok-4.1-mini 一样做模型级探测型冷却

对应 HTML 改造 (token.html):

<!-- Row 2: Chat 桶额度 -->
<div class="stat-card">
  <div class="stat-value" id="stat-grok3-quota">-</div>
  <div class="stat-label">Grok-3 剩余</div>
  <div class="quota-bar" id="stat-grok3-bar"></div>
</div>
<div class="stat-card">
  <div class="stat-value text-blue-600" id="stat-grok4-quota">-</div>
  <div class="stat-label">Grok-4 剩余</div>
  <div class="quota-bar" id="stat-grok4-bar"></div>
</div>
<div class="stat-card">
  <div class="stat-value text-amber-500" id="stat-grok41-quota">-</div>
  <div class="stat-label">Grok-4.1 探针</div>
  <div class="stat-hint">⚠️ 仅参考</div>
</div>
<div class="stat-card">
  <div class="stat-value" id="stat-total-calls">-</div>
  <div class="stat-label">总调用次数</div>
</div>

<!-- Row 3: Image / Video / Edit -->
<div class="stat-card">
  <div class="stat-value text-purple-600" id="stat-image-quota">-</div>
  <div class="stat-label">Image 剩余</div>
  <div class="stat-hint">= Grok-3 high 桶</div>
</div>
<div class="stat-card">
  <div class="stat-value text-indigo-600" id="stat-video-quota">-</div>
  <div class="stat-label">Video 剩余</div>
  <div class="stat-hint">= Grok-3 high 桶</div>
</div>
<div class="stat-card">
  <div class="stat-value text-orange-500" id="stat-edit-quota">-</div>
  <div class="stat-label">Image Edit</div>
  <div class="stat-hint">⚠️ 独立上游</div>
</div>
<div class="stat-card"></div>

表格额度列改造

从单一数字改为多桶进度条:

│ Token       │ 类型     │ 状态   │ 额度                    │ 备注 │ 操作    │
│ sso...9SY   │ ssoBasic │ active │ G3: █████░░░ 49/80      │  -   │ 🔄 ✏️ 🗑️│
│             │          │        │ G4: ██░░░░░░  9/40      │      │         │
│ sso...CIQ   │ ssoBasic │ active │ G3: ███████░ 60/80      │  -   │ 🔄 ✏️ 🗑️│
│             │          │        │ G4: ████░░░░ 20/40      │      │         │

对应 JS 改造 (renderTable 中 tdQuota 部分):

// Quota (多桶展示)
const tdQuota = document.createElement('td');
tdQuota.className = 'text-left text-xs';

if (item.grok3_quota || item.grok4_quota) {
  let html = '<div class="quota-multi">';
  
  if (item.grok3_quota) {
    const g3 = item.grok3_quota;
    const g3pct = g3.total_tokens ? Math.round(g3.remaining_tokens / g3.total_tokens * 100) : 0;
    html += `<div class="quota-row">
      <span class="quota-label">G3</span>
      <div class="quota-bar-mini">
        <div class="quota-fill" style="width:${g3pct}%"></div>
      </div>
      <span class="quota-num">${g3.remaining_tokens}/${g3.total_tokens}</span>
    </div>`;
  }
  
  if (item.grok4_quota) {
    const g4 = item.grok4_quota;
    const g4pct = g4.total_tokens ? Math.round(g4.remaining_tokens / g4.total_tokens * 100) : 0;
    html += `<div class="quota-row">
      <span class="quota-label">G4</span>
      <div class="quota-bar-mini">
        <div class="quota-fill quota-fill-blue" style="width:${g4pct}%"></div>
      </div>
      <span class="quota-num">${g4.remaining_tokens}/${g4.total_tokens}</span>
    </div>`;
  }
  
  html += '</div>';
  tdQuota.innerHTML = html;
} else {
  // 降级:显示旧的单一 quota
  tdQuota.className = 'text-center font-mono text-xs';
  tdQuota.innerText = item.quota;
}

新增 CSS 样式

/* token.css: 分桶额度展示 */
.quota-multi {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 140px;
}
.quota-row {
  display: flex;
  align-items: center;
  gap: 6px;
}
.quota-label {
  font-size: 10px;
  font-weight: 600;
  color: var(--accents-5);
  width: 20px;
  flex-shrink: 0;
}
.quota-bar-mini {
  flex: 1;
  height: 6px;
  background: #f3f4f6;
  border-radius: 3px;
  overflow: hidden;
  min-width: 60px;
}
.quota-fill {
  height: 100%;
  background: #10b981;
  border-radius: 3px;
  transition: width 0.3s ease;
}
.quota-fill-blue {
  background: #3b82f6;
}
.quota-num {
  font-size: 10px;
  font-family: 'Geist Mono', monospace;
  color: var(--accents-5);
  min-width: 50px;
  text-align: right;
}

/* 统计区进度条 */
.stat-card .quota-bar {
  margin-top: 6px;
  height: 4px;
  background: #f3f4f6;
  border-radius: 2px;
  overflow: hidden;
}
.stat-card .quota-bar .fill {
  height: 100%;
  border-radius: 2px;
  transition: width 0.3s ease;
}
.stat-hint {
  font-size: 10px;
  color: var(--accents-4);
  margin-top: 4px;
}

updateStats 逻辑改造

function updateStats(data) {
  // ... 现有 count 逻辑保留 ...

  // 分桶额度汇总
  let grok3Total = 0, grok3Remaining = 0, grok3HighTotal = 0, grok3HighRemaining = 0;
  let grok4Total = 0, grok4Remaining = 0;
  let grok41Queries = 0;
  let editCooldownCount = 0;
  
  flatTokens.forEach(t => {
    if (t.status !== 'active') return;
    
    if (t.grok3_quota) {
      grok3Total += t.grok3_quota.total_tokens;
      grok3Remaining += t.grok3_quota.remaining_tokens;
      grok3HighTotal += (t.grok3_quota.high_total || 0);    // 需后端返回
      grok3HighRemaining += t.grok3_quota.high_remaining;
    }
    if (t.grok4_quota) {
      grok4Total += t.grok4_quota.total_tokens;
      grok4Remaining += t.grok4_quota.remaining_tokens;
    }
    if (t.grok41_queries != null) {
      grok41Queries += t.grok41_queries;
    }
    // Image Edit 冷却中计数
    if (t.model_cooldowns && t.model_cooldowns['grok-imagine-1.0-edit']) {
      const until = t.model_cooldowns['grok-imagine-1.0-edit'];
      if (until > Date.now()) editCooldownCount++;
    }
  });
  
  // Grok-3 统计
  setText('stat-grok3-quota', `${grok3Remaining} / ${grok3Total}`);
  setBar('stat-grok3-bar', grok3Remaining, grok3Total, '#10b981');
  
  // Grok-4 统计
  setText('stat-grok4-quota', `${grok4Remaining} / ${grok4Total}`);
  setBar('stat-grok4-bar', grok4Remaining, grok4Total, '#3b82f6');
  
  // Grok-4.1 探针
  setText('stat-grok41-quota', `${grok41Queries} queries`);
  
  // Image 剩余 = grok-3 high 桶汇总
  // 因为 grok-imagine-1.0 / grok-imagine-1.0-fast 都走 grok-3 cost=HIGH
  setText('stat-image-quota', `${grok3HighRemaining}`);
  
  // Video 剩余 = 同 Image(grok-imagine-1.0-video 也走 grok-3 HIGH)
  setText('stat-video-quota', `${grok3HighRemaining}`);
  
  // Image Edit = 独立上游探测型
  const activeForEdit = flatTokens.filter(t => t.status === 'active').length;
  const editAvailable = activeForEdit - editCooldownCount;
  setText('stat-edit-quota', editAvailable > 0 ? `${editAvailable} 可用` : '冷却中');
}

function setBar(id, value, max, color) {
  const el = byId(id);
  if (!el) return;
  const pct = max > 0 ? Math.round(value / max * 100) : 0;
  el.innerHTML = `<div class="fill" style="width:${pct}%;background:${color}"></div>`;
}

Note

Image / Video 剩余的推导逻辑

  • grok-imagine-1.0grok-imagine-1.0-fastgrok-imagine-1.0-video 在 model.py 中都配置为 grok_model="grok-3", cost=Cost.HIGH
  • 每次成功调用消耗 grok-3.remainingTokens -= 4, grok-3.high -= 1
  • 因此 Image/Video 的真实可用次数 = grok-3 high 桶剩余(所有 active token 汇总)

Image Edit 的处理

  • grok-imagine-1.0-editgrok_model="imagine-image-edit",是独立上游
  • 无法通过 /rest/rate-limits 查询到真实桶
  • 采用与 grok-4.1-mini 相同的模型级探测型冷却策略
  • 前端只显示「X 可用 / 冷却中」,不显示精确数字

编辑弹窗适配

编辑弹窗的单一「额度」输入框暂时保留不动,因为:

  • 分桶额度来自上游 /rest/rate-limits 同步,不应手动编辑
  • 现有 quota 字段可作为管理员手动覆盖的「总额度上限」
  • 如果后续需要手动调整,可以改为只读展示 + 「同步」按钮

降级兼容

  • 如果后端尚未返回 grok3_quota / grok4_quota 字段,前端自动降级到旧的单一 quota 展示
  • processTokens 中对新字段做 optional 处理
  • 表格列宽从 w-20 调整为 w-36 以容纳双行进度条

RateLimitsReverse 改造

当前 rate_limits.py 只查询 grok-4-1-thinking-1129 一种模型。需要改为同时查询 grok-3 和 grok-4 的真实桶:

# 需要分别查询以下 modelName:
MODELS_TO_QUERY = [
    "grok-3",                    # 真实桶
    "grok-4",                    # 真实桶
    "grok-4-1-thinking-1129",    # 探针
    "grok-420",                  # 探针
]

价值与场景

额度显示

备选方案(可选)

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions