From 7e44e545bf4db1d8f9cab1b8ea1c679774081949 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:49:35 +0000 Subject: [PATCH] =?UTF-8?q?feat(codex):=20=E7=94=A8=E9=87=8F=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E6=94=AF=E6=8C=81=20Spark=20=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E9=99=90=E9=A2=9D=E5=88=86=E7=BB=84=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UsageWindow 增加 WindowGroup(CODEX/SPARK)分组字段\n- additional_rate_limits 按 limit_name 是否含 spark 归类\n- selectPrimaryWindow 优先普通 Codex 组,避免 Spark 5h 抢占主余额 --- .../token/data/repository/CodexRepository.kt | 40 +++++- .../data/repository/CodexRepositoryTest.kt | 120 ++++++++++++++++++ 2 files changed, 153 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt index c97044c..3e4a9c2 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt @@ -34,6 +34,16 @@ class CodexRepository( ) { 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 { private const val WHAM_USAGE = "https://chatgpt.com/backend-api/wham/usage" 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 TAG = "Codex" - internal data class UsageWindow(val label: String, val remainingPct: Int, val resetAt: Long?) - internal fun parseUsageWindows(data: JsonObject): List { val result = mutableListOf() - fun addWindows(rl: JsonObject?) { + fun addWindows(rl: JsonObject?, group: WindowGroup) { if (rl == null) return for (key in listOf("primary_window", "secondary_window")) { val w = rl[key] as? JsonObject ?: continue val usedPct = (w["used_percent"] as? JsonPrimitive)?.floatOrNull ?: continue 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 -> - 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 } + /** + * 选择主余额窗口: + * 优先普通 Codex 组的 5h 窗口(与旧逻辑一致); + * 普通组没有 5h 时取普通组第一个(如仅 weekly 的 prolite 账号); + * 普通组为空才 fallback 到任意第一个(避免 Spark 5h 抢走主余额)。 + */ + internal fun selectPrimaryWindow(windows: List): UsageWindow = + windows.firstOrNull { it.group == WindowGroup.CODEX && it.label.contains("h") } + ?: windows.firstOrNull { it.group == WindowGroup.CODEX } + ?: windows.first() + /** 窗口时长 → 中性标识(weekly/monthly/usage/数字标签),由 UI 层按语言本地化 */ internal fun durationLabel(seconds: Long?): String = when { seconds == null -> "usage" @@ -127,12 +152,13 @@ class CodexRepository( if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError(RepositoryError.ParseErrorReason.NO_WINDOWS, "未找到 Codex 用量窗口数据")) val config = ServiceConfigProvider.get(ServiceType.CODEX) - val primary = windows.firstOrNull { it.label.contains("h") } ?: windows.first() + val primary = selectPrimaryWindow(windows) val extras = buildMap { windows.forEachIndexed { i, w -> put("window_$i.label", w.label) put("window_$i.remainingPct", w.remainingPct.toString()) put("window_$i.resetAt", w.resetAt?.toString() ?: "") + put("window_$i.group", w.group.name) } put("primary.label", primary.label) (usageResult["plan_type"] as? JsonPrimitive)?.contentOrNull?.let { put("plan", it) } diff --git a/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt b/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt index 70ed344..e9737cf 100644 --- a/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt +++ b/app/src/test/java/com/rainy/token/data/repository/CodexRepositoryTest.kt @@ -227,6 +227,126 @@ class CodexRepositoryTest { 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 ── @Test