完全准确查询你的GPT订阅周限的方法【油猴脚本】

修复了周额度计算可能偏高的问题。

旧版脚本只按“日期”判断本周周期,没有保留具体时间。比如真实周期是中午 12 点开始,旧版会当成当天 0 点开始,导致把周期开始前的用量也算进本周。

新版改为保留完整周期时间,并对周期开始当天做比例估算,避免把不属于本周期的用量算进去。

// ==UserScript==
// @name         Codex Quota Compass (Visual Edition)
// @namespace    http://tampermonkey.net/
// @version      1.9
// @description  人性化展示 Codex 配额
// @author       Jun Zhao, Canxin
// @match        https://chatgpt.com/codex/cloud/settings/analytics*
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==

(function () {
  "use strict";

  if (window.__codexQuotaCompassInjected) return;
  window.__codexQuotaCompassInjected = true;

  const CONFIG = {
    USD_PER_CREDIT: 40 / 1000,
    HISTORY_DAYS: 30,
    TARGET_WINDOW_SECONDS: 7 * 24 * 60 * 60,
    DAY_MS: 24 * 60 * 60 * 1000,

    // 重要:原脚本用 toISOString(),因此这里默认继续按 UTC 日期与后端 daily bucket 对齐。
    // 如果你确认后端 daily bucket 是浏览器本地日期,可以改为 false。
    USE_UTC_DAY: true,

    EPS: 1e-9,
  };

  const HTML_ESCAPE = {
    "&": "&",
    "<": "&lt;",
    ">": "&gt;",
    '"': "&quot;",
    "'": "&#39;",
  };

  const escapeHtml = (value) =>
    String(value ?? "").replace(/[&<>"']/g, (ch) => HTML_ESCAPE[ch]);

  const n = (value) => {
    if (typeof value === "string") value = value.replace(/,/g, "").trim();
    const parsed = Number(value);
    return Number.isFinite(parsed) ? parsed : 0;
  };

  const clamp = (value, min = 0, max = 1) =>
    Math.min(max, Math.max(min, n(value)));

  const trimFixed = (value, digits = 2) => {
    const str = n(value).toFixed(digits);
    return str.replace(/\.?0+$/, "");
  };

  const fmtNum = (value) => {
    const num = n(value);
    const abs = Math.abs(num);
    const sign = num < 0 ? "-" : "";
    if (abs >= 1e9) return sign + trimFixed(abs / 1e9, 2) + "B";
    if (abs >= 1e6) return sign + trimFixed(abs / 1e6, 2) + "M";
    if (abs >= 1e3) return sign + trimFixed(abs / 1e3, 2) + "K";
    return num.toLocaleString();
  };

  const fmtCredits = (value, digits = 3) => n(value).toFixed(digits);

  const fmtTurns = (value) => {
    const num = n(value);
    return Math.abs(num - Math.round(num)) < 1e-9
      ? String(Math.round(num))
      : num.toFixed(1);
  };

  const tokenTotal = (obj = {}) => {
    const total = n(obj.text_total_tokens);
    const parts =
      n(obj.cached_text_input_tokens) +
      n(obj.uncached_text_input_tokens) +
      n(obj.text_output_tokens);
    return total > 0 ? total : parts;
  };

  const pad2 = (value) => String(value).padStart(2, "0");

  const dateKeyFromMs = (ms) => {
    const d = new Date(ms);
    if (!Number.isFinite(d.getTime())) return "";
    if (CONFIG.USE_UTC_DAY) return d.toISOString().slice(0, 10);
    return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
  };

  const dayStartMs = (dateKey) => {
    const [year, month, day] = String(dateKey || "")
      .slice(0, 10)
      .split("-")
      .map(Number);
    if (!year || !month || !day) return NaN;
    return CONFIG.USE_UTC_DAY
      ? Date.UTC(year, month - 1, day)
      : new Date(year, month - 1, day).getTime();
  };

  const formatDateTime = (ms) => {
    const d = new Date(ms);
    if (!Number.isFinite(d.getTime())) return "未知";
    return d.toLocaleString(undefined, {
      year: "numeric",
      month: "2-digit",
      day: "2-digit",
      hour: "2-digit",
      minute: "2-digit",
      timeZoneName: "short",
    });
  };

  const formatDuration = (seconds) => {
    const s = n(seconds);
    if (!s) return "当前周期";
    const days = s / 86400;
    if (days >= 1) {
      const rounded = Math.round(days);
      return Math.abs(days - rounded) < 1e-6
        ? `${rounded} 天周期`
        : `${days.toFixed(1)} 天周期`;
    }
    const hours = s / 3600;
    return `${trimFixed(hours, 1)} 小时周期`;
  };

  const formatPercentFromRatio = (ratio) => {
    const pct = clamp(ratio) * 100;
    return `${pct >= 10 ? pct.toFixed(1) : pct.toFixed(2)}%`;
  };

  const asArray = (value) => (Array.isArray(value) ? value : []);

  GM_addStyle(`
        #codex-compass-root {
            position: fixed; top: 5%; left: 50%; transform: translateX(-50%);
            width: 760px; max-width: calc(100vw - 32px); max-height: 90vh; background: #fff;
            border-radius: 12px; box-shadow: 0 10px 50px rgba(0,0,0,0.3);
            z-index: 10001; padding: 24px; display: none;
            flex-direction: column; border: 1px solid #e5e5e5; color: #333;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
            overflow: hidden;
        }
        .compass-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 15px; }
        .compass-title { font-size: 18px; font-weight: 600; }
        .compass-subtitle { font-size: 12px; color: #777; margin-top: 3px; line-height: 1.4; }
        .compass-close { cursor: pointer; font-size: 28px; color: #999; line-height: 1; user-select: none; }
        .compass-close:hover { color: #333; }
        .compass-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 14px; }
        .compass-card { background: #f9f9f9; padding: 12px; border-radius: 8px; border: 1px solid #eee; min-width: 0; }
        .compass-card.highlight { background: #eefaf5; border-color: #d1f2e1; }
        .card-label { font-size: 12px; color: #666; margin-bottom: 4px; }
        .card-value { font-size: 15px; font-weight: bold; color: #10a37f; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
        .compass-note {
            font-size: 12px; color: #6b7280; background: #f8fafc; border: 1px solid #e5e7eb;
            padding: 8px 10px; border-radius: 8px; margin-bottom: 14px; line-height: 1.45;
        }
        .table-container {
            max-height: 220px; overflow-y: auto; border: 1px solid #eee;
            border-radius: 6px; margin-bottom: 15px;
        }
        .compass-table { width: 100%; border-collapse: collapse; font-size: 12px; }
        .compass-table thead { position: sticky; top: 0; background: #f5f5f5; z-index: 1; }
        .compass-table th { text-align: left; padding: 8px; border-bottom: 1px solid #eee; color: #666; white-space: nowrap; }
        .compass-table td { padding: 8px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }
        .compass-footer-row { background: #fafafa; font-weight: bold; position: sticky; bottom: 0; border-top: 2px solid #eee; }
        .estimated-row { color: #374151; }
        .estimated-mark { color: #d97706; font-weight: 600; margin-left: 3px; }

        #codex-compass-btn {
            position: fixed; bottom: 20px; right: 20px; z-index: 10000;
            padding: 10px 20px; background: #10a37f; color: white;
            border: none; border-radius: 8px; cursor: pointer; font-weight: 600;
            box-shadow: 0 4px 20px rgba(0,0,0,0.16);
        }
        #codex-compass-btn:disabled {
            opacity: 0.72; cursor: wait;
        }

        @media (max-width: 720px) {
            #codex-compass-root { top: 16px; padding: 16px; }
            .compass-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
        }
    `);

  async function getAccessToken() {
    const bootstrapData =
      document.getElementById("client-bootstrap")?.textContent || "";

    const jwtCandidates =
      bootstrapData.match(/eyJ[\w-]*\.[\w-]+\.[\w-]+/g) ||
      bootstrapData.match(/[\w-]{30,}\.[\w-]{30,}\.[\w-]{30,}/g) ||
      [];

    if (jwtCandidates.length > 0) return jwtCandidates[0];

    try {
      const res = await fetch("/api/auth/session", {
        credentials: "include",
        cache: "no-store",
      });
      if (!res.ok) return null;
      const session = await res.json();
      return (
        session?.accessToken ||
        session?.access_token ||
        session?.token ||
        null
      );
    } catch {
      return null;
    }
  }

  async function apiGet(path, token) {
    const res = await fetch(path, {
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: "application/json",
      },
      credentials: "include",
      cache: "no-store",
    });

    const text = await res.text();
    let json = null;

    if (text) {
      try {
        json = JSON.parse(text);
      } catch {
        // 交给下面的 res.ok 分支给出更清楚的报错。
      }
    }

    if (!res.ok) {
      const detail =
        json?.detail ||
        json?.message ||
        (typeof json?.error === "string" ? json.error : json?.error?.message) ||
        text.slice(0, 200);
      throw new Error(
        `${res.status} ${res.statusText}${detail ? `: ${detail}` : ""}`,
      );
    }

    if (json === null) throw new Error("接口返回的不是 JSON。");
    return json;
  }

  const rateLimitWindows = (rateLimit = {}) =>
    Object.entries(rateLimit)
      .filter(
        ([key, value]) =>
          key.endsWith("_window") && value && typeof value === "object",
      )
      .map(([key, value]) => ({
        ...value,
        _window_name: key,
      }));

  const pickQuotaWindow = (rateLimit = {}) => {
    const windows = rateLimitWindows(rateLimit);
    if (windows.length === 0) return null;

    const withDuration = windows.filter((w) => n(w.limit_window_seconds) > 0);
    if (withDuration.length > 0) {
      return [...withDuration].sort(
        (a, b) =>
          Math.abs(n(a.limit_window_seconds) - CONFIG.TARGET_WINDOW_SECONDS) -
          Math.abs(n(b.limit_window_seconds) - CONFIG.TARGET_WINDOW_SECONDS),
      )[0];
    }

    if (rateLimit.secondary_window) {
      return { ...rateLimit.secondary_window, _window_name: "secondary_window" };
    }
    return windows[0];
  };

  const toEpochMs = (secondsOrMs) => {
    const value = n(secondsOrMs);
    if (!value) return NaN;
    return value > 1e12 ? value : value * 1000;
  };

  const cycleRangeFromWindow = (quotaWindow) => {
    const resetMs = toEpochMs(quotaWindow?.reset_at);
    const durationMs = n(quotaWindow?.limit_window_seconds) * 1000;
    const nowMs = Date.now();

    if (Number.isFinite(resetMs) && durationMs > 0) {
      return {
        cycleStartMs: resetMs - durationMs,
        cycleEndMs: resetMs,
      };
    }

    return {
      cycleStartMs: nowMs - CONFIG.TARGET_WINDOW_SECONDS * 1000,
      cycleEndMs: nowMs + CONFIG.TARGET_WINDOW_SECONDS * 1000,
    };
  };

  const usedRatioFromWindow = (quotaWindow) => {
    // 字段名是 used_percent,因此按 0-100 的百分比处理;避免把 1% 误判成 100%。
    const rawPercent = n(quotaWindow?.used_percent);
    if (rawPercent <= 0) return 0;
    return clamp(rawPercent / 100, 0, 1);
  };

  const scaleTotals = (totals = {}, ratio = 1) => {
    const out = { ...totals };
    const numericKeys = [
      "credits",
      "turns",
      "text_total_tokens",
      "cached_text_input_tokens",
      "uncached_text_input_tokens",
      "text_output_tokens",
    ];

    numericKeys.forEach((key) => {
      if (Object.prototype.hasOwnProperty.call(totals, key)) {
        out[key] = n(totals[key]) * ratio;
      }
    });

    return out;
  };

  const makeScaledRow = (item, ratio, displayDate, sortOffset = 0) => ({
    ...item,
    totals: scaleTotals(item.totals || {}, ratio),
    _displayDate: displayDate,
    _dateKey: String(item?.date || "").slice(0, 10),
    _sortTs: dayStartMs(item?.date) + sortOffset,
    _ratio: ratio,
    _estimated: ratio > CONFIG.EPS && ratio < 1 - CONFIG.EPS,
  });

  const splitDailyBuckets = (
    dailyList,
    cycleStartMs,
    cycleEndMs,
    nowMs = Date.now(),
  ) => {
    const currentCycleList = [];
    const historyList = [];

    asArray(dailyList).forEach((item) => {
      const dateKey = String(item?.date || "").slice(0, 10);
      const bucketStart = dayStartMs(dateKey);
      if (!Number.isFinite(bucketStart)) return;

      const bucketEnd = bucketStart + CONFIG.DAY_MS;

      // daily 接口里“今天”的 bucket 通常只包含目前已经发生的用量,
      // 所以今天不能按 24 小时完整天去折算,否则会把今天的用量低估。
      const observedStart = bucketStart;
      const observedEnd = Math.min(bucketEnd, nowMs);
      const observedMs = observedEnd - observedStart;

      // 跳过未来日期或空 bucket。
      if (observedMs <= 0) return;

      const currentOverlap = Math.max(
        0,
        Math.min(observedEnd, cycleEndMs, nowMs) -
          Math.max(observedStart, cycleStartMs),
      );

      const currentRatio = clamp(currentOverlap / observedMs);
      const outsideRatio = clamp(1 - currentRatio);

      if (currentRatio > CONFIG.EPS) {
        const label =
          currentRatio < 1 - CONFIG.EPS
            ? `${dateKey}(周期内≈${formatPercentFromRatio(currentRatio)})`
            : dateKey;
        currentCycleList.push(makeScaledRow(item, currentRatio, label, 1));
      }

      const hasOutsidePart =
        observedStart < cycleStartMs || observedEnd > cycleEndMs;

      if (hasOutsidePart && outsideRatio > CONFIG.EPS) {
        const outsideLabel =
          observedStart < cycleStartMs ? "周期前" : "周期后";
        const label =
          outsideRatio < 1 - CONFIG.EPS
            ? `${dateKey}(${outsideLabel}≈${formatPercentFromRatio(
                outsideRatio,
              )})`
            : dateKey;
        historyList.push(makeScaledRow(item, outsideRatio, label, 0));
      }
    });

    const byTime = (a, b) => a._sortTs - b._sortTs;
    return {
      currentCycleList: currentCycleList.sort(byTime),
      historyList: historyList.sort(byTime),
    };
  };

  const getStats = (list) => {
    const stats = list.reduce(
      (acc, row) => {
        acc.credits += n(row.totals?.credits);
        acc.turns += n(row.totals?.turns);
        acc.tokens += tokenTotal(row.totals);
        return acc;
      },
      { credits: 0, turns: 0, tokens: 0 },
    );

    return {
      ...stats,
      usd: stats.credits * CONFIG.USD_PER_CREDIT,
    };
  };

  const renderTable = (list, stats) => {
    const rows = [...list].sort((a, b) => b._sortTs - a._sortTs);

    return `
            <div class="table-container">
                <table class="compass-table">
                    <thead>
                        <tr><th>日期</th><th>Credits</th><th>Tokens</th><th>金额</th><th>轮数</th></tr>
                    </thead>
                    <tbody>
                        ${
                          rows.length === 0
                            ? `<tr><td colspan="5" style="color:#777; text-align:center; padding:14px;">暂无数据</td></tr>`
                            : rows
                                .map((row) => {
                                  const estimatedTitle = row._estimated
                                    ? "该行由按天聚合数据按时间占比估算,无法精确到真实使用时刻。"
                                    : "";
                                  return `
                            <tr class="${row._estimated ? "estimated-row" : ""}">
                                <td title="${escapeHtml(estimatedTitle)}">
                                  ${escapeHtml(row._displayDate || row.date)}
                                  ${
                                    row._estimated
                                      ? `<span class="estimated-mark">*</span>`
                                      : ""
                                  }
                                </td>
                                <td style="font-family:monospace">${fmtCredits(row.totals?.credits)}</td>
                                <td style="font-family:monospace">${fmtNum(tokenTotal(row.totals))}</td>
                                <td>$ ${(n(row.totals?.credits) * CONFIG.USD_PER_CREDIT).toFixed(2)}</td>
                                <td>${fmtTurns(row.totals?.turns)}</td>
                            </tr>
                        `;
                                })
                                .join("")
                        }
                    </tbody>
                    <tfoot>
                        <tr class="compass-footer-row">
                            <td>合计</td>
                            <td>${fmtCredits(stats.credits)}</td>
                            <td style="font-family:monospace">${fmtNum(stats.tokens)}</td>
                            <td style="color:#10a37f">$ ${stats.usd.toFixed(2)}</td>
                            <td>${fmtTurns(stats.turns)}</td>
                        </tr>
                    </tfoot>
                </table>
            </div>
        `;
  };

  const showPanel = (data) => {
    const root = document.getElementById("codex-compass-root");
    if (!root) return;

    const { quotaWindow, dailyList, cycleStartMs, cycleEndMs, nowMs } = data;

    const { currentCycleList, historyList } = splitDailyBuckets(
      dailyList,
      cycleStartMs,
      cycleEndMs,
      nowMs,
    );

    const currentStats = getStats(currentCycleList);
    const historyStats = getStats(historyList);

    const usedRatio = usedRatioFromWindow(quotaWindow);
    const rawUsedPercent = n(quotaWindow?.used_percent);
    const usedPercentText =
      rawUsedPercent > 0 ? `${trimFixed(rawUsedPercent, 2)}%` : "未知";

    const estCredits =
      usedRatio > CONFIG.EPS ? currentStats.credits / usedRatio : NaN;
    const hasEstCredits = Number.isFinite(estCredits) && estCredits > 0;
    const estCreditsText = hasEstCredits ? estCredits.toFixed(1) : "未知";
    const estUsdText = hasEstCredits
      ? `$ ${(estCredits * CONFIG.USD_PER_CREDIT).toFixed(2)}`
      : "未知";

    const windowName = quotaWindow?._window_name || "unknown_window";
    const periodName = formatDuration(quotaWindow?.limit_window_seconds);
    const estimatedRows = [...currentCycleList, ...historyList].some(
      (row) => row._estimated,
    );

    let historyRangeTitle = "⏳ 历史记录(本周期外)";
    if (historyList.length > 0) {
      const sortedHistory = [...historyList].sort((a, b) => a._sortTs - b._sortTs);
      const startStr = sortedHistory[0]._dateKey || sortedHistory[0].date;
      const endStr =
        sortedHistory[sortedHistory.length - 1]._dateKey ||
        sortedHistory[sortedHistory.length - 1].date;
      historyRangeTitle = `⏳ 历史记录(本周期外 ${startStr} 至 ${endStr})`;
    }

    root.innerHTML = `
            <div class="compass-header">
                <div>
                  <div class="compass-title">📊 Codex 配额深度分析 (V1.9)</div>
                  <div class="compass-subtitle">
                    ${escapeHtml(periodName)} · ${escapeHtml(windowName)}
                    · ${escapeHtml(formatDateTime(cycleStartMs))} → ${escapeHtml(
                      formatDateTime(cycleEndMs),
                    )}
                  </div>
                </div>
                <div class="compass-close" id="compass-close-btn">&times;</div>
            </div>

            <div class="compass-grid">
                <div class="compass-card">
                    <div class="card-label">已用比例</div>
                    <div class="card-value">${escapeHtml(usedPercentText)}</div>
                </div>
                <div class="compass-card">
                    <div class="card-label">本周期已用</div>
                    <div class="card-value">${currentStats.credits.toFixed(1)} <span style="font-size:10px; font-weight:normal">Credits</span></div>
                </div>
                <div class="compass-card highlight">
                    <div class="card-label">推算总额</div>
                    <div class="card-value">${escapeHtml(estCreditsText)} <span style="font-size:10px; font-weight:normal">Credits</span></div>
                </div>
                <div class="compass-card">
                    <div class="card-label">周期价值(估算)</div>
                    <div class="card-value">${escapeHtml(estUsdText)}</div>
                </div>
            </div>

            <div class="compass-note">
              ${
                estimatedRows
                  ? `带 <b>*</b> 的边界日期来自 daily 聚合数据的时间比例估算;因为接口不是按请求/小时返回,所以无法知道周期开始前后真实各用了多少。`
                  : `当前周期没有跨 daily bucket 的边界折算;若后端只返回 daily 聚合,边界日仍可能存在不可见的细粒度误差。`
              }
            </div>

            <div style="font-weight:600; margin-bottom:8px; font-size:13px;">
              📅 本周期明细(始于 ${escapeHtml(formatDateTime(cycleStartMs))})
            </div>
            ${renderTable(currentCycleList, currentStats)}

            ${
              historyList.length > 0
                ? `
            <div style="font-weight:600; margin-bottom:8px; font-size:13px; color: #666; margin-top: 5px;">
              ${escapeHtml(historyRangeTitle)}
            </div>
            ${renderTable(historyList, historyStats)}
            `
                : ""
            }
        `;

    root.style.display = "flex";
    document.getElementById("compass-close-btn").onclick = () => {
      root.style.display = "none";
    };
  };

  const run = async () => {
    const btn = document.getElementById("codex-compass-btn");
    if (!btn || btn.disabled) return;

    btn.disabled = true;
    btn.innerText = "分析中...";

    try {
      const token = await getAccessToken();
      if (!token) {
        alert("令牌获取失败,请确保已登录,或刷新页面后重试。");
        return;
      }

      const usage = await apiGet("/backend-api/wham/usage", token);
      const quotaWindow = pickQuotaWindow(usage?.rate_limit);

      const { cycleStartMs, cycleEndMs } = cycleRangeFromWindow(quotaWindow);
      const nowMs = Date.now();

      const historyStartMs = nowMs - CONFIG.HISTORY_DAYS * CONFIG.DAY_MS;
      const queryStartMs = Math.min(historyStartMs, cycleStartMs - CONFIG.DAY_MS);
      const startDate = dateKeyFromMs(queryStartMs);

      // 多取一天,兼容 end_date 是 exclusive 的实现;splitDailyBuckets 会跳过未来日期。
      const endDate = dateKeyFromMs(nowMs + CONFIG.DAY_MS);

      const params = new URLSearchParams({
        start_date: startDate,
        end_date: endDate,
        group_by: "day",
      });

      const dailyData = await apiGet(
        `/backend-api/wham/analytics/daily-workspace-usage-counts?${params.toString()}`,
        token,
      );

      showPanel({
        quotaWindow,
        dailyList: asArray(dailyData?.data),
        cycleStartMs,
        cycleEndMs,
        nowMs,
      });
    } catch (e) {
      console.error("[Codex Quota Compass]", e);
      alert("错误: " + (e?.message || String(e)));
    } finally {
      btn.disabled = false;
      btn.innerText = "📊 运行用量分析";
    }
  };

  const mount = () => {
    if (document.getElementById("codex-compass-btn")) return;

    const btn = document.createElement("button");
    btn.id = "codex-compass-btn";
    btn.innerText = "📊 运行用量分析";
    btn.onclick = run;
    document.body.appendChild(btn);

    const root = document.createElement("div");
    root.id = "codex-compass-root";
    document.body.appendChild(root);
  };

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", mount, { once: true });
  } else {
    mount();
  }
})();
2 个赞


pro20x的,不知道为啥这么少额度

1 个赞

感谢更新,
但是由于奥特曼重置周限的原因,反而预估变多很多)
Plus号


我的pro5x咋回事

正价老号 team,发出来给大伙参考下

1 个赞

:melting_face:上次查询我还是周价值160刀的plus,今天重置了我去查变成39刀了
明显降额了


20x 怎么变成 5x 额度了 :astonished_face:

1 个赞

今天突然看不到金额了,是不是被官方藏起来了

看起来是的,没了,看接口返回都是0

现在好像不能用了呀,接口变了吗?

接口变了,返回的credits都为0

修复版的代码

// ==UserScript==
// @name         Codex Quota Compass (Visual Edition)
// @namespace    http://tampermonkey.net/
// @version      2.0
// @description  Codex 配额分析:按 token-based rate card 估算 Credits / 金额
// @author       Jun Zhao / fixed by ChatGPT
// @match        https://chatgpt.com/codex/cloud/settings/analytics*
// @grant        GM_addStyle
// ==/UserScript==

(function () {
  "use strict";

  const CONFIG = {
    // 你原脚本里的换算:$40 / 1000 credits = $0.04 / credit
    USD_PER_CREDIT: 40 / 1000,

    // 默认按 Codex 代码审查/常见 Codex 模型 GPT-5.3-Codex 估算
    // 如果你实际主要用 GPT-5.5 / GPT-5.4,可以在面板顶部切换
    DEFAULT_MODEL: "gpt-5.3-codex",

    DEBUG: true,
  };

  // OpenAI Codex token-based rate card:credits per 1M tokens
  // 来源:Codex rate card
  const RATE_CARDS = {
    "gpt-5.5": {
      label: "GPT-5.5",
      input: 125,
      cached: 12.5,
      output: 750,
    },
    "gpt-5.4": {
      label: "GPT-5.4",
      input: 62.5,
      cached: 6.25,
      output: 375,
    },
    "gpt-5.4-mini": {
      label: "GPT-5.4-Mini",
      input: 18.75,
      cached: 1.875,
      output: 113,
    },
    "gpt-5.3-codex": {
      label: "GPT-5.3-Codex",
      input: 43.75,
      cached: 4.375,
      output: 350,
    },
    "gpt-5.2": {
      label: "GPT-5.2",
      input: 43.75,
      cached: 4.375,
      output: 350,
    },
  };

  let currentModel = localStorage.getItem("codex-compass-model") || CONFIG.DEFAULT_MODEL;

  const safeNum = (v) => {
    if (v === null || v === undefined || v === "") return 0;
    if (typeof v === "number") return Number.isFinite(v) ? v : 0;
    if (typeof v === "string") {
      const cleaned = v.replace(/[$,\s]/g, "");
      const matched = cleaned.match(/-?\d+(\.\d+)?/);
      if (!matched) return 0;
      const parsed = Number(matched[0]);
      return Number.isFinite(parsed) ? parsed : 0;
    }
    const num = Number(v);
    return Number.isFinite(num) ? num : 0;
  };

  const fmtNum = (n) => {
    n = safeNum(n);
    if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
    if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
    if (n >= 1e3) return (n / 1e3).toFixed(2) + "K";
    return n.toLocaleString();
  };

  const fmtUsd = (n) => {
    n = safeNum(n);
    if (n > 0 && n < 0.01) return "$ " + n.toFixed(4);
    return "$ " + n.toFixed(2);
  };

  const fmtCredits = (n) => {
    n = safeNum(n);
    if (n >= 1000) return n.toFixed(1);
    if (n >= 100) return n.toFixed(2);
    return n.toFixed(3);
  };

  function isObj(v) {
    return v && typeof v === "object" && !Array.isArray(v);
  }

  function deepSumByKeys(obj, keyTester, seen = new WeakSet()) {
    if (!isObj(obj) || seen.has(obj)) return 0;
    seen.add(obj);

    let sum = 0;

    for (const [key, value] of Object.entries(obj)) {
      if (keyTester(String(key).toLowerCase())) {
        sum += safeNum(value);
      }

      if (isObj(value)) {
        sum += deepSumByKeys(value, keyTester, seen);
      }
    }

    return sum;
  }

  function deepPickFirstNumber(obj, keyTester, seen = new WeakSet()) {
    if (!isObj(obj) || seen.has(obj)) return null;
    seen.add(obj);

    for (const [key, value] of Object.entries(obj)) {
      if (keyTester(String(key).toLowerCase())) {
        const num = safeNum(value);
        if (num !== 0) return num;
      }
    }

    for (const value of Object.values(obj)) {
      if (isObj(value)) {
        const found = deepPickFirstNumber(value, keyTester, seen);
        if (found !== null) return found;
      }
    }

    return null;
  }

  function getDate(row = {}) {
    return (
      row.date ||
      row.day ||
      row.start_date ||
      row.created_date ||
      row.bucket ||
      row.period_start ||
      "-"
    );
  }

  function getTurns(row = {}) {
    const direct =
      safeNum(row.turns) ||
      safeNum(row?.totals?.turns) ||
      safeNum(row.total_turns) ||
      safeNum(row?.totals?.total_turns) ||
      safeNum(row.requests) ||
      safeNum(row?.totals?.requests);

    if (direct) return direct;

    return (
      deepPickFirstNumber(row, (k) =>
        ["turns", "total_turns", "requests", "request_count", "count"].includes(k),
      ) || 0
    );
  }

  function getTokenParts(row = {}) {
    const cached =
      safeNum(row.cached_text_input_tokens) ||
      safeNum(row?.totals?.cached_text_input_tokens) ||
      safeNum(row.cached_input_tokens) ||
      safeNum(row?.totals?.cached_input_tokens) ||
      deepSumByKeys(row, (k) =>
        k.includes("cached") &&
        k.includes("token") &&
        !k.includes("uncached") &&
        !k.includes("total"),
      );

    const uncached =
      safeNum(row.uncached_text_input_tokens) ||
      safeNum(row?.totals?.uncached_text_input_tokens) ||
      safeNum(row.uncached_input_tokens) ||
      safeNum(row?.totals?.uncached_input_tokens) ||
      safeNum(row.text_input_tokens) ||
      safeNum(row?.totals?.text_input_tokens) ||
      safeNum(row.input_tokens) ||
      safeNum(row?.totals?.input_tokens) ||
      safeNum(row.prompt_tokens) ||
      safeNum(row?.totals?.prompt_tokens) ||
      deepSumByKeys(row, (k) =>
        (
          k.includes("uncached") ||
          k === "input_tokens" ||
          k === "text_input_tokens" ||
          k === "prompt_tokens"
        ) &&
        k.includes("token") &&
        !k.includes("cached_text")
      );

    const output =
      safeNum(row.text_output_tokens) ||
      safeNum(row?.totals?.text_output_tokens) ||
      safeNum(row.output_tokens) ||
      safeNum(row?.totals?.output_tokens) ||
      safeNum(row.completion_tokens) ||
      safeNum(row?.totals?.completion_tokens) ||
      deepSumByKeys(row, (k) =>
        (
          k.includes("output") ||
          k.includes("completion")
        ) &&
        k.includes("token")
      );

    let total =
      safeNum(row.text_total_tokens) ||
      safeNum(row?.totals?.text_total_tokens) ||
      safeNum(row.total_tokens) ||
      safeNum(row?.totals?.total_tokens) ||
      safeNum(row.tokens) ||
      safeNum(row?.totals?.tokens);

    if (!total) total = cached + uncached + output;

    return {
      cached,
      uncached,
      output,
      total,
    };
  }

  function estimateCredits(row = {}) {
    const rate = RATE_CARDS[currentModel] || RATE_CARDS[CONFIG.DEFAULT_MODEL];
    const parts = getTokenParts(row);

    const credits =
      (parts.uncached / 1_000_000) * rate.input +
      (parts.cached / 1_000_000) * rate.cached +
      (parts.output / 1_000_000) * rate.output;

    return credits;
  }

  function estimateUsd(row = {}) {
    return estimateCredits(row) * CONFIG.USD_PER_CREDIT;
  }

  function getUsedPercent(secondary = {}) {
    return (
      safeNum(secondary.used_percent) ||
      safeNum(secondary.usage_percent) ||
      safeNum(secondary.percent_used) ||
      0
    );
  }

  GM_addStyle(`
    #codex-compass-root {
      position: fixed;
      top: 5%;
      left: 50%;
      transform: translateX(-50%);
      width: 820px;
      max-height: 90vh;
      background: #fff;
      border-radius: 12px;
      box-shadow: 0 10px 50px rgba(0,0,0,0.3);
      z-index: 10001;
      padding: 24px;
      display: none;
      flex-direction: column;
      border: 1px solid #e5e5e5;
      color: #333;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
    }

    .compass-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 15px;
    }

    .compass-title {
      font-size: 18px;
      font-weight: 600;
    }

    .compass-close {
      cursor: pointer;
      font-size: 28px;
      color: #999;
      line-height: 1;
    }

    .compass-toolbar {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      margin-bottom: 14px;
      padding: 10px 12px;
      background: #f8f8f8;
      border: 1px solid #eee;
      border-radius: 8px;
      font-size: 12px;
    }

    .compass-toolbar select {
      padding: 5px 8px;
      border-radius: 6px;
      border: 1px solid #ddd;
      background: white;
      font-size: 12px;
    }

    .compass-grid {
      display: grid;
      grid-template-columns: repeat(4, 1fr);
      gap: 12px;
      margin-bottom: 20px;
    }

    .compass-card {
      background: #f9f9f9;
      padding: 12px;
      border-radius: 8px;
      border: 1px solid #eee;
    }

    .compass-card.highlight {
      background: #eefaf5;
      border-color: #d1f2e1;
    }

    .card-label {
      font-size: 12px;
      color: #666;
      margin-bottom: 4px;
    }

    .card-value {
      font-size: 15px;
      font-weight: bold;
      color: #10a37f;
      white-space: nowrap;
    }

    .table-container {
      max-height: 240px;
      overflow-y: auto;
      border: 1px solid #eee;
      border-radius: 6px;
      margin-bottom: 15px;
    }

    .compass-table {
      width: 100%;
      border-collapse: collapse;
      font-size: 12px;
    }

    .compass-table thead {
      position: sticky;
      top: 0;
      background: #f5f5f5;
      z-index: 1;
    }

    .compass-table th {
      text-align: left;
      padding: 8px;
      border-bottom: 1px solid #eee;
      color: #666;
      white-space: nowrap;
    }

    .compass-table td {
      padding: 8px;
      border-bottom: 1px solid #f0f0f0;
      white-space: nowrap;
    }

    .compass-footer-row {
      background: #fafafa;
      font-weight: bold;
      position: sticky;
      bottom: 0;
      border-top: 2px solid #eee;
    }

    .compass-note {
      font-size: 11px;
      color: #777;
      line-height: 1.5;
      background: #fafafa;
      border: 1px solid #eee;
      border-radius: 8px;
      padding: 10px 12px;
    }

    #codex-compass-btn {
      position: fixed;
      bottom: 20px;
      right: 20px;
      z-index: 10000;
      padding: 10px 20px;
      background: #10a37f;
      color: white;
      border: none;
      border-radius: 8px;
      cursor: pointer;
      font-weight: 600;
      box-shadow: 0 4px 14px rgba(0,0,0,0.18);
    }

    #codex-compass-btn:hover {
      background: #0d8c6d;
    }

    #codex-compass-btn:disabled {
      opacity: .7;
      cursor: not-allowed;
    }
  `);

  async function apiGet(path, token) {
    const res = await fetch(path, {
      credentials: "include",
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: "application/json",
      },
    });

    const text = await res.text();

    let json = {};
    try {
      json = text ? JSON.parse(text) : {};
    } catch (e) {
      throw new Error(`接口返回不是 JSON:HTTP ${res.status}`);
    }

    if (!res.ok) {
      throw new Error(json?.detail || json?.message || `HTTP ${res.status}`);
    }

    return json;
  }

  function calcStats(list) {
    return list.reduce(
      (acc, row) => {
        const parts = getTokenParts(row);
        const credits = estimateCredits(row);
        const usd = credits * CONFIG.USD_PER_CREDIT;

        acc.cached += parts.cached;
        acc.uncached += parts.uncached;
        acc.output += parts.output;
        acc.tokens += parts.total;
        acc.turns += getTurns(row);
        acc.credits += credits;
        acc.usd += usd;

        return acc;
      },
      {
        cached: 0,
        uncached: 0,
        output: 0,
        tokens: 0,
        turns: 0,
        credits: 0,
        usd: 0,
      },
    );
  }

  function renderModelSelect() {
    return `
      <select id="codex-model-select">
        ${Object.entries(RATE_CARDS)
          .map(([key, value]) => {
            return `
              <option value="${key}" ${key === currentModel ? "selected" : ""}>
                ${value.label}
              </option>
            `;
          })
          .join("")}
      </select>
    `;
  }

  function showPanel(data) {
    const root = document.getElementById("codex-compass-root");
    const { secondary, dailyList, cycleStartDate } = data;

    const currentCycleList = [];
    const historyList = [];

    dailyList.forEach((item) => {
      const date = getDate(item);

      if (date !== "-" && new Date(date) < new Date(cycleStartDate)) {
        historyList.push(item);
      } else {
        currentCycleList.push(item);
      }
    });

    const currentStats = calcStats(currentCycleList);
    const historyStats = calcStats(historyList);
    const allStats = calcStats(dailyList);

    const usedPercent = getUsedPercent(secondary);
    const rate = RATE_CARDS[currentModel] || RATE_CARDS[CONFIG.DEFAULT_MODEL];

    let historyRangeTitle = "⏳ 历史记录";
    if (historyList.length > 0) {
      const sortedHistory = [...historyList].sort(
        (a, b) => new Date(getDate(a)) - new Date(getDate(b)),
      );

      historyRangeTitle = `⏳ 历史记录 (${getDate(sortedHistory[0])} 至 ${getDate(
        sortedHistory[sortedHistory.length - 1],
      )})`;
    }

    const renderTable = (list, stats) => `
      <div class="table-container">
        <table class="compass-table">
          <thead>
            <tr>
              <th>日期</th>
              <th>Input</th>
              <th>Cached</th>
              <th>Output</th>
              <th>Total</th>
              <th>Credits估算</th>
              <th>金额估算</th>
              <th>轮数</th>
            </tr>
          </thead>
          <tbody>
            ${
              list.length
                ? [...list]
                    .reverse()
                    .map((row) => {
                      const parts = getTokenParts(row);
                      const credits = estimateCredits(row);
                      const usd = credits * CONFIG.USD_PER_CREDIT;

                      return `
                        <tr>
                          <td>${getDate(row)}</td>
                          <td style="font-family:monospace">${fmtNum(parts.uncached)}</td>
                          <td style="font-family:monospace">${fmtNum(parts.cached)}</td>
                          <td style="font-family:monospace">${fmtNum(parts.output)}</td>
                          <td style="font-family:monospace">${fmtNum(parts.total)}</td>
                          <td style="font-family:monospace">${fmtCredits(credits)}</td>
                          <td style="font-family:monospace">${fmtUsd(usd)}</td>
                          <td>${getTurns(row)}</td>
                        </tr>
                      `;
                    })
                    .join("")
                : `
                  <tr>
                    <td colspan="8" style="text-align:center; color:#999; padding:14px;">
                      暂无数据
                    </td>
                  </tr>
                `
            }
          </tbody>
          <tfoot>
            <tr class="compass-footer-row">
              <td>合计</td>
              <td style="font-family:monospace">${fmtNum(stats.uncached)}</td>
              <td style="font-family:monospace">${fmtNum(stats.cached)}</td>
              <td style="font-family:monospace">${fmtNum(stats.output)}</td>
              <td style="font-family:monospace">${fmtNum(stats.tokens)}</td>
              <td style="font-family:monospace">${fmtCredits(stats.credits)}</td>
              <td style="color:#10a37f; font-family:monospace">${fmtUsd(stats.usd)}</td>
              <td>${stats.turns}</td>
            </tr>
          </tfoot>
        </table>
      </div>
    `;

    root.innerHTML = `
      <div class="compass-header">
        <div class="compass-title">📊 Codex 配额深度分析 (V2.0)</div>
        <div class="compass-close" id="compass-close-btn">&times;</div>
      </div>

      <div class="compass-toolbar">
        <div>
          当前估算模型:
          ${renderModelSelect()}
        </div>
        <div>
          费率:Input ${rate.input} / Cached ${rate.cached} / Output ${rate.output} credits per 1M tokens
        </div>
      </div>

      <div class="compass-grid">
        <div class="compass-card">
          <div class="card-label">已用比例</div>
          <div class="card-value">${usedPercent}%</div>
        </div>

        <div class="compass-card">
          <div class="card-label">本周期 Credits估算</div>
          <div class="card-value">
            ${fmtCredits(currentStats.credits)}
          </div>
        </div>

        <div class="compass-card highlight">
          <div class="card-label">本周期金额估算</div>
          <div class="card-value">
            ${fmtUsd(currentStats.usd)}
          </div>
        </div>

        <div class="compass-card">
          <div class="card-label">近30天金额估算</div>
          <div class="card-value">
            ${fmtUsd(allStats.usd)}
          </div>
        </div>
      </div>

      <div style="font-weight:600; margin-bottom:8px; font-size:13px;">
        📅 本周期明细 (始于 ${cycleStartDate})
      </div>
      ${renderTable(currentCycleList, currentStats)}

      ${
        historyList.length > 0
          ? `
            <div style="font-weight:600; margin-bottom:8px; font-size:13px; color:#666; margin-top:5px;">
              ${historyRangeTitle}
            </div>
            ${renderTable(historyList, historyStats)}
          `
          : ""
      }

      <div class="compass-note">
        说明:你的接口日志里 credits 和 usd 都是 0,说明 daily usage 接口没有直接返回金额。
        这版改为按 token 类型估算:uncached input / cached input / output tokens × Codex rate card。
        金额使用你原脚本的换算:1000 credits = $40,即 1 credit = $0.04。
        如果你主要用 GPT-5.5 或 GPT-5.4,请在上方切换模型,表格会自动重算。
      </div>
    `;

    root.style.display = "flex";

    document.getElementById("compass-close-btn").onclick = () => {
      root.style.display = "none";
    };

    document.getElementById("codex-model-select").onchange = (e) => {
      currentModel = e.target.value;
      localStorage.setItem("codex-compass-model", currentModel);
      showPanel(data);
    };
  }

  async function run() {
    const btn = document.getElementById("codex-compass-btn");

    const bootstrapData =
      document.getElementById("client-bootstrap")?.textContent || "";

    const token = bootstrapData.match(
      /[\w-]{30,}\.[\w-]{30,}\.[\w-]{30,}/,
    )?.[0];

    if (!token) {
      alert("令牌获取失败,请确保已登录 ChatGPT。");
      return;
    }

    btn.innerText = "分析中...";
    btn.disabled = true;

    try {
      const usage = await apiGet("/backend-api/wham/usage", token);

      const secondary =
        usage?.rate_limit?.secondary_window ||
        usage?.rate_limit?.primary_window ||
        usage?.secondary_window ||
        usage?.primary_window ||
        {};

      const endDate = new Date(Date.now() + 86400000)
        .toISOString()
        .split("T")[0];

      const startDate = new Date(Date.now() - 30 * 86400000)
        .toISOString()
        .split("T")[0];

      const resetAt = safeNum(secondary.reset_at);
      const limitWindowSeconds = safeNum(secondary.limit_window_seconds);

      const cycleStartDate =
        resetAt > 0 && limitWindowSeconds > 0
          ? new Date((resetAt - limitWindowSeconds) * 1000)
              .toISOString()
              .split("T")[0]
          : startDate;

      const dailyData = await apiGet(
        `/backend-api/wham/analytics/daily-workspace-usage-counts?start_date=${startDate}&end_date=${endDate}&group_by=day`,
        token,
      );

      const dailyList =
        Array.isArray(dailyData?.data)
          ? dailyData.data
          : Array.isArray(dailyData?.items)
            ? dailyData.items
            : Array.isArray(dailyData?.results)
              ? dailyData.results
              : [];

      if (CONFIG.DEBUG) {
        console.group("[Codex Quota Compass] 调试信息 V2.0");
        console.log("usage 原始数据:", usage);
        console.log("dailyData 原始数据:", dailyData);
        console.table(
          dailyList.map((row) => {
            const parts = getTokenParts(row);
            const credits = estimateCredits(row);

            return {
              date: getDate(row),
              input_tokens: parts.uncached,
              cached_tokens: parts.cached,
              output_tokens: parts.output,
              total_tokens: parts.total,
              estimated_credits: credits,
              estimated_usd: credits * CONFIG.USD_PER_CREDIT,
              turns: getTurns(row),
            };
          }),
        );
        console.groupEnd();
      }

      showPanel({
        secondary,
        dailyList,
        cycleStartDate,
      });
    } catch (e) {
      console.error("[Codex Quota Compass] 错误:", e);
      alert("错误: " + (e?.message || e));
    } finally {
      btn.innerText = "📊 运行用量分析";
      btn.disabled = false;
    }
  }

  function init() {
    if (document.getElementById("codex-compass-btn")) return;

    const btn = document.createElement("button");
    btn.id = "codex-compass-btn";
    btn.innerText = "📊 运行用量分析";
    btn.onclick = run;
    document.body.appendChild(btn);

    const root = document.createElement("div");
    root.id = "codex-compass-root";
    document.body.appendChild(root);
  }

  init();
})();
7 个赞

佬,请教一下,为啥这个算出来和上面的脚本算出来差距很大,我改了模型为5.5的

土区的这么少。。。

1 个赞

直接模拟估算 价格就图一乐 以token数为准

1 个赞

怎么感觉估算额度跟之前比,缩水了4倍,是算法完全变了么

1 个赞

虽然只是图一乐,但这是不是太乐了,我pro20x诶,中途全程gpt5.5xhigh fast没切过

用token算乘2.5周限是2840刀

1 个赞

你是不是测试之前刚用了,使用记录要6小时才刷新,建议是过几个小时再看

佬,是配置到油猴刷新页面就行么?为什么我的这边啥也没有,开与不开是一样的。

最新版接口返回没有积分了,我用codex重新改了版
2.2.txt (36.7 KB)