feat(codex): 用量窗口支持 Spark 独立限额分组解析
- UsageWindow 增加 WindowGroup(CODEX/SPARK)分组字段\n- additional_rate_limits 按 limit_name 是否含 spark 归类\n- selectPrimaryWindow 优先普通 Codex 组,避免 Spark 5h 抢占主余额
This commit is contained in:
parent
4ed6aaa3cc
commit
7e44e545bf
@ -34,6 +34,16 @@ class CodexRepository(
|
|||||||
) {
|
) {
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
/** 用量窗口来源分组:CODEX = 普通 Codex 限额,SPARK = GPT-5.3-Codex-Spark 独立限额 */
|
||||||
|
internal enum class WindowGroup { CODEX, SPARK }
|
||||||
|
|
||||||
|
internal data class UsageWindow(
|
||||||
|
val label: String,
|
||||||
|
val remainingPct: Int,
|
||||||
|
val resetAt: Long?,
|
||||||
|
val group: WindowGroup = WindowGroup.CODEX
|
||||||
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val WHAM_USAGE = "https://chatgpt.com/backend-api/wham/usage"
|
private const val WHAM_USAGE = "https://chatgpt.com/backend-api/wham/usage"
|
||||||
private const val RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
|
private const val RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
|
||||||
@ -43,26 +53,41 @@ class CodexRepository(
|
|||||||
private const val REFRESH_BUFFER_MS = 60L * 60 * 1000
|
private const val REFRESH_BUFFER_MS = 60L * 60 * 1000
|
||||||
private const val TAG = "Codex"
|
private const val TAG = "Codex"
|
||||||
|
|
||||||
internal data class UsageWindow(val label: String, val remainingPct: Int, val resetAt: Long?)
|
|
||||||
|
|
||||||
internal fun parseUsageWindows(data: JsonObject): List<UsageWindow> {
|
internal fun parseUsageWindows(data: JsonObject): List<UsageWindow> {
|
||||||
val result = mutableListOf<UsageWindow>()
|
val result = mutableListOf<UsageWindow>()
|
||||||
fun addWindows(rl: JsonObject?) {
|
fun addWindows(rl: JsonObject?, group: WindowGroup) {
|
||||||
if (rl == null) return
|
if (rl == null) return
|
||||||
for (key in listOf("primary_window", "secondary_window")) {
|
for (key in listOf("primary_window", "secondary_window")) {
|
||||||
val w = rl[key] as? JsonObject ?: continue
|
val w = rl[key] as? JsonObject ?: continue
|
||||||
val usedPct = (w["used_percent"] as? JsonPrimitive)?.floatOrNull ?: continue
|
val usedPct = (w["used_percent"] as? JsonPrimitive)?.floatOrNull ?: continue
|
||||||
val remaining = (100 - usedPct).toInt().coerceIn(0, 100)
|
val remaining = (100 - usedPct).toInt().coerceIn(0, 100)
|
||||||
result.add(UsageWindow(durationLabel((w["limit_window_seconds"] as? JsonPrimitive)?.longOrNull), remaining, (w["reset_at"] as? JsonPrimitive)?.longOrNull?.times(1000L)))
|
result.add(UsageWindow(durationLabel((w["limit_window_seconds"] as? JsonPrimitive)?.longOrNull), remaining, (w["reset_at"] as? JsonPrimitive)?.longOrNull?.times(1000L), group))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
addWindows(data["rate_limit"] as? JsonObject)
|
// 顶层 rate_limit 始终属于普通 Codex 限额
|
||||||
|
addWindows(data["rate_limit"] as? JsonObject, WindowGroup.CODEX)
|
||||||
|
// additional_rate_limits 每项可携带 limit_name,含 "spark" 的归入 Spark 独立限额组
|
||||||
(data["additional_rate_limits"] as? kotlinx.serialization.json.JsonArray)?.forEach { item ->
|
(data["additional_rate_limits"] as? kotlinx.serialization.json.JsonArray)?.forEach { item ->
|
||||||
if (item is JsonObject) addWindows(item["rate_limit"] as? JsonObject)
|
if (item is JsonObject) {
|
||||||
|
val name = (item["limit_name"] as? JsonPrimitive)?.contentOrNull.orEmpty()
|
||||||
|
val group = if (name.contains("spark", ignoreCase = true)) WindowGroup.SPARK else WindowGroup.CODEX
|
||||||
|
addWindows(item["rate_limit"] as? JsonObject, group)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择主余额窗口:
|
||||||
|
* 优先普通 Codex 组的 5h 窗口(与旧逻辑一致);
|
||||||
|
* 普通组没有 5h 时取普通组第一个(如仅 weekly 的 prolite 账号);
|
||||||
|
* 普通组为空才 fallback 到任意第一个(避免 Spark 5h 抢走主余额)。
|
||||||
|
*/
|
||||||
|
internal fun selectPrimaryWindow(windows: List<UsageWindow>): UsageWindow =
|
||||||
|
windows.firstOrNull { it.group == WindowGroup.CODEX && it.label.contains("h") }
|
||||||
|
?: windows.firstOrNull { it.group == WindowGroup.CODEX }
|
||||||
|
?: windows.first()
|
||||||
|
|
||||||
/** 窗口时长 → 中性标识(weekly/monthly/usage/数字标签),由 UI 层按语言本地化 */
|
/** 窗口时长 → 中性标识(weekly/monthly/usage/数字标签),由 UI 层按语言本地化 */
|
||||||
internal fun durationLabel(seconds: Long?): String = when {
|
internal fun durationLabel(seconds: Long?): String = when {
|
||||||
seconds == null -> "usage"
|
seconds == null -> "usage"
|
||||||
@ -127,12 +152,13 @@ class CodexRepository(
|
|||||||
if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_WINDOWS, "未找到 Codex 用量窗口数据"))
|
if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_WINDOWS, "未找到 Codex 用量窗口数据"))
|
||||||
|
|
||||||
val config = ServiceConfigProvider.get(ServiceType.CODEX)
|
val config = ServiceConfigProvider.get(ServiceType.CODEX)
|
||||||
val primary = windows.firstOrNull { it.label.contains("h") } ?: windows.first()
|
val primary = selectPrimaryWindow(windows)
|
||||||
val extras = buildMap {
|
val extras = buildMap {
|
||||||
windows.forEachIndexed { i, w ->
|
windows.forEachIndexed { i, w ->
|
||||||
put("window_$i.label", w.label)
|
put("window_$i.label", w.label)
|
||||||
put("window_$i.remainingPct", w.remainingPct.toString())
|
put("window_$i.remainingPct", w.remainingPct.toString())
|
||||||
put("window_$i.resetAt", w.resetAt?.toString() ?: "")
|
put("window_$i.resetAt", w.resetAt?.toString() ?: "")
|
||||||
|
put("window_$i.group", w.group.name)
|
||||||
}
|
}
|
||||||
put("primary.label", primary.label)
|
put("primary.label", primary.label)
|
||||||
(usageResult["plan_type"] as? JsonPrimitive)?.contentOrNull?.let { put("plan", it) }
|
(usageResult["plan_type"] as? JsonPrimitive)?.contentOrNull?.let { put("plan", it) }
|
||||||
|
|||||||
@ -227,6 +227,126 @@ class CodexRepositoryTest {
|
|||||||
assertTrue(windows.isEmpty())
|
assertTrue(windows.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parseUsageWindows groups spark windows by limit_name`() {
|
||||||
|
// 模拟真实 wham 返回:普通 rate_limit 只有 weekly,additional 带 GPT-5.3-Codex-Spark
|
||||||
|
val primaryWindow = JsonObject(mapOf(
|
||||||
|
"primary_window" to JsonObject(mapOf(
|
||||||
|
"used_percent" to JsonPrimitive(3.0),
|
||||||
|
"reset_at" to JsonPrimitive(1787813438L),
|
||||||
|
"limit_window_seconds" to JsonPrimitive(604800L)
|
||||||
|
))
|
||||||
|
))
|
||||||
|
val sparkRl = JsonObject(mapOf(
|
||||||
|
"primary_window" to JsonObject(mapOf(
|
||||||
|
"used_percent" to JsonPrimitive(0.0),
|
||||||
|
"reset_at" to JsonPrimitive(1787294974L),
|
||||||
|
"limit_window_seconds" to JsonPrimitive(18000L)
|
||||||
|
)),
|
||||||
|
"secondary_window" to JsonObject(mapOf(
|
||||||
|
"used_percent" to JsonPrimitive(0.0),
|
||||||
|
"reset_at" to JsonPrimitive(1787354555L),
|
||||||
|
"limit_window_seconds" to JsonPrimitive(604800L)
|
||||||
|
))
|
||||||
|
))
|
||||||
|
val data = JsonObject(mapOf(
|
||||||
|
"rate_limit" to primaryWindow,
|
||||||
|
"additional_rate_limits" to kotlinx.serialization.json.JsonArray(
|
||||||
|
listOf(JsonObject(mapOf(
|
||||||
|
"limit_name" to JsonPrimitive("GPT-5.3-Codex-Spark"),
|
||||||
|
"metered_feature" to JsonPrimitive("codex_bengalfox"),
|
||||||
|
"rate_limit" to sparkRl
|
||||||
|
)))
|
||||||
|
)
|
||||||
|
))
|
||||||
|
val windows = CodexRepository.parseUsageWindows(data)
|
||||||
|
assertEquals(3, windows.size)
|
||||||
|
// 普通组:weekly
|
||||||
|
assertEquals(CodexRepository.WindowGroup.CODEX, windows[0].group)
|
||||||
|
assertEquals("weekly", windows[0].label)
|
||||||
|
assertEquals(97, windows[0].remainingPct)
|
||||||
|
// Spark 组:5h + weekly
|
||||||
|
assertEquals(CodexRepository.WindowGroup.SPARK, windows[1].group)
|
||||||
|
assertEquals("5h", windows[1].label)
|
||||||
|
assertEquals(CodexRepository.WindowGroup.SPARK, windows[2].group)
|
||||||
|
assertEquals("weekly", windows[2].label)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parseUsageWindows treats additional without spark name as codex`() {
|
||||||
|
val primaryWindow = JsonObject(mapOf(
|
||||||
|
"primary_window" to JsonObject(mapOf(
|
||||||
|
"used_percent" to JsonPrimitive(10.0),
|
||||||
|
"reset_at" to JsonPrimitive(1719500400L),
|
||||||
|
"limit_window_seconds" to JsonPrimitive(18000L)
|
||||||
|
))
|
||||||
|
))
|
||||||
|
val additionalRl = JsonObject(mapOf(
|
||||||
|
"primary_window" to JsonObject(mapOf(
|
||||||
|
"used_percent" to JsonPrimitive(20.0),
|
||||||
|
"reset_at" to JsonPrimitive(1719932400L),
|
||||||
|
"limit_window_seconds" to JsonPrimitive(604800L)
|
||||||
|
))
|
||||||
|
))
|
||||||
|
val data = JsonObject(mapOf(
|
||||||
|
"rate_limit" to primaryWindow,
|
||||||
|
"additional_rate_limits" to kotlinx.serialization.json.JsonArray(
|
||||||
|
listOf(JsonObject(mapOf("limit_name" to JsonPrimitive("Other-Limit"), "rate_limit" to additionalRl)))
|
||||||
|
)
|
||||||
|
))
|
||||||
|
val windows = CodexRepository.parseUsageWindows(data)
|
||||||
|
assertEquals(2, windows.size)
|
||||||
|
assertTrue(windows.all { it.group == CodexRepository.WindowGroup.CODEX })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── selectPrimaryWindow ──
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selectPrimaryWindow prefers codex 5h over spark`() {
|
||||||
|
// 普通组只有 weekly + Spark 5h:primary 必须是普通 weekly,不能是 Spark 5h
|
||||||
|
val windows = listOf(
|
||||||
|
CodexRepository.UsageWindow("weekly", 97, null, CodexRepository.WindowGroup.CODEX),
|
||||||
|
CodexRepository.UsageWindow("5h", 100, null, CodexRepository.WindowGroup.SPARK),
|
||||||
|
CodexRepository.UsageWindow("weekly", 100, null, CodexRepository.WindowGroup.SPARK)
|
||||||
|
)
|
||||||
|
val primary = CodexRepository.selectPrimaryWindow(windows)
|
||||||
|
assertEquals("weekly", primary.label)
|
||||||
|
assertEquals(CodexRepository.WindowGroup.CODEX, primary.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selectPrimaryWindow prefers codex 5h when both have 5h`() {
|
||||||
|
val windows = listOf(
|
||||||
|
CodexRepository.UsageWindow("5h", 42, null, CodexRepository.WindowGroup.CODEX),
|
||||||
|
CodexRepository.UsageWindow("weekly", 15, null, CodexRepository.WindowGroup.CODEX),
|
||||||
|
CodexRepository.UsageWindow("5h", 0, null, CodexRepository.WindowGroup.SPARK)
|
||||||
|
)
|
||||||
|
val primary = CodexRepository.selectPrimaryWindow(windows)
|
||||||
|
assertEquals("5h", primary.label)
|
||||||
|
assertEquals(CodexRepository.WindowGroup.CODEX, primary.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selectPrimaryWindow falls back to codex first when no codex 5h`() {
|
||||||
|
// 只有普通 weekly(无 5h)+ Spark 5h:primary 取普通 weekly
|
||||||
|
val windows = listOf(
|
||||||
|
CodexRepository.UsageWindow("weekly", 97, null, CodexRepository.WindowGroup.CODEX),
|
||||||
|
CodexRepository.UsageWindow("5h", 100, null, CodexRepository.WindowGroup.SPARK)
|
||||||
|
)
|
||||||
|
val primary = CodexRepository.selectPrimaryWindow(windows)
|
||||||
|
assertEquals("weekly", primary.label)
|
||||||
|
assertEquals(CodexRepository.WindowGroup.CODEX, primary.group)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selectPrimaryWindow falls back to any when codex empty`() {
|
||||||
|
val windows = listOf(
|
||||||
|
CodexRepository.UsageWindow("5h", 100, null, CodexRepository.WindowGroup.SPARK)
|
||||||
|
)
|
||||||
|
val primary = CodexRepository.selectPrimaryWindow(windows)
|
||||||
|
assertEquals(CodexRepository.WindowGroup.SPARK, primary.group)
|
||||||
|
}
|
||||||
|
|
||||||
// ── parseSseResponse ──
|
// ── parseSseResponse ──
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user