diff --git a/CHANGELOG.md b/CHANGELOG.md index 17044f90d..ea8096b4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.45.2.2 — 2026-07-24 + +### Fixed +- Alibaba Token Plan: restore the authenticated 5-hour and weekly rate windows, keep the legacy monthly credit response as a fallback, and label each restored window by its actual duration. Thanks @rohitsabu! + ## 0.45.2.1 — 2026-07-19 ### Added diff --git a/CodexBarMobile/CHANGELOG.md b/CodexBarMobile/CHANGELOG.md index 38a96b681..fc777fb96 100644 --- a/CodexBarMobile/CHANGELOG.md +++ b/CodexBarMobile/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to the CodexBar iOS companion app will be documented in this file. +## [1.19.0 (189)] — 2026-07-24 — Alibaba Token Plan hotfix + +### Fixed + +- **Alibaba Token Plan limits** — Restored the rolling 5-hour and weekly + limits from the Mac companion and labeled both windows correctly while + retaining the existing monthly-credit fallback. + +### Notes + +- Pairs with Mac CodexBar `0.45.2.2` / build `109.2`; existing Shared payloads + and the CloudKit Production schema are unchanged. +- iOS `MARKETING_VERSION` remains `1.19.0`. +- iOS `CURRENT_PROJECT_VERSION`: `188` → `189`. + +--- + ## [1.19.0 (188)] — 2026-07-19 — CodexBar 0.45 upstream sync ### Added diff --git a/CodexBarMobile/CodexBarMobile.xcodeproj/project.pbxproj b/CodexBarMobile/CodexBarMobile.xcodeproj/project.pbxproj index 729080a9e..2f72b078e 100644 --- a/CodexBarMobile/CodexBarMobile.xcodeproj/project.pbxproj +++ b/CodexBarMobile/CodexBarMobile.xcodeproj/project.pbxproj @@ -1246,7 +1246,7 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = CodexBarMobileWidgets/WidgetExtension.entitlements; - CURRENT_PROJECT_VERSION = 188; + CURRENT_PROJECT_VERSION = 189; DEVELOPMENT_TEAM = 3TUERHN53E; INFOPLIST_FILE = CodexBarMobileWidgets/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -1266,7 +1266,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_IDENTITY = ""; - CURRENT_PROJECT_VERSION = 188; + CURRENT_PROJECT_VERSION = 189; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; @@ -1291,7 +1291,7 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = CodexBarMobileWidgets/WidgetExtension.entitlements; - CURRENT_PROJECT_VERSION = 188; + CURRENT_PROJECT_VERSION = 189; DEVELOPMENT_TEAM = 3TUERHN53E; INFOPLIST_FILE = CodexBarMobileWidgets/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -1466,7 +1466,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = CodexBarMobilePushExtension/PushExtension.entitlements; - CURRENT_PROJECT_VERSION = 188; + CURRENT_PROJECT_VERSION = 189; DEVELOPMENT_TEAM = 3TUERHN53E; INFOPLIST_FILE = CodexBarMobilePushExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -1488,7 +1488,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = CodexBarMobile/CodexBarMobile.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 188; + CURRENT_PROJECT_VERSION = 189; DEVELOPMENT_TEAM = 3TUERHN53E; INFOPLIST_FILE = CodexBarMobile/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -1526,7 +1526,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = CodexBarMobile/CodexBarMobile.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 188; + CURRENT_PROJECT_VERSION = 189; DEVELOPMENT_TEAM = 3TUERHN53E; INFOPLIST_FILE = CodexBarMobile/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -1562,7 +1562,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_IDENTITY = ""; - CURRENT_PROJECT_VERSION = 188; + CURRENT_PROJECT_VERSION = 189; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; @@ -1586,7 +1586,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = CodexBarMobilePushExtension/PushExtension.entitlements; - CURRENT_PROJECT_VERSION = 188; + CURRENT_PROJECT_VERSION = 189; DEVELOPMENT_TEAM = 3TUERHN53E; INFOPLIST_FILE = CodexBarMobilePushExtension/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/CodexBarMobile/CodexBarMobile/ContentView.swift b/CodexBarMobile/CodexBarMobile/ContentView.swift index bb82a6ba2..13ab30795 100644 --- a/CodexBarMobile/CodexBarMobile/ContentView.swift +++ b/CodexBarMobile/CodexBarMobile/ContentView.swift @@ -3665,7 +3665,7 @@ private enum MobileReleaseNotesCatalog { String( localized: "More complete details — sub2api shows account balance and request totals, while Wayfinder shows routing activity and savings."), String( - localized: "Richer limit coverage — monthly and additional limits now stay visible alongside daily and weekly windows when a provider offers them."), + localized: "Richer limit coverage — Alibaba Token Plan restores its 5-hour and weekly limits, and monthly or additional limits stay visible when providers offer them."), String( localized: "A more capable Mac companion — customize menu bar layouts, see usage forecasts, use safer refresh controls, and benefit from the latest provider, performance, and security fixes."), ]), @@ -3673,7 +3673,7 @@ private enum MobileReleaseNotesCatalog { title: String(localized: "Required Mac version"), items: [ String( - localized: "For all new details, update CodexBar on Mac to version 0.45.2.1 or later. iPhone 1.19 still works with data from older Mac versions."), + localized: "For all new details, update CodexBar on Mac to version 0.45.2.2 or later. iPhone 1.19 still works with data from older Mac versions."), ]), ]), ReleaseNotesVersion( diff --git a/CodexBarMobile/CodexBarMobile/Localizable.xcstrings b/CodexBarMobile/CodexBarMobile/Localizable.xcstrings index bf4c93f84..890c89117 100644 --- a/CodexBarMobile/CodexBarMobile/Localizable.xcstrings +++ b/CodexBarMobile/CodexBarMobile/Localizable.xcstrings @@ -13551,6 +13551,35 @@ } } }, + "5-hour": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "5-hour" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "5時間" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "5 小时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "5 小時" + } + } + } + }, "Alibaba Token Plan (Bailian) — monthly token-plan quota card showing used and total credits with the reset date, imported from browser or manual cookies.": { "localizations": { "en": { @@ -19630,11 +19659,11 @@ "zh-Hans": { "stringUnit": { "state": "translated", "value": "更完整的详情——sub2api 显示账户余额和请求总计,Wayfinder 显示路由活动与节省金额。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "更完整的詳情——sub2api 顯示帳戶餘額和請求總計,Wayfinder 顯示路由活動與節省金額。" } } } }, - "Richer limit coverage — monthly and additional limits now stay visible alongside daily and weekly windows when a provider offers them.": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Richer limit coverage — monthly and additional limits now stay visible alongside daily and weekly windows when a provider offers them." } }, - "ja": { "stringUnit": { "state": "translated", "value": "上限表示を拡充 — プロバイダーが提供する月間および追加の上限も、日次・週次の上限と並んで表示されます。" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "更完整的额度展示——提供商支持时,月度和其他额度现在会与每日、每周额度一同保留显示。" } }, - "zh-Hant": { "stringUnit": { "state": "translated", "value": "更完整的額度顯示——提供者支援時,月度和其他額度現在會與每日、每週額度一同保留顯示。" } } + "Richer limit coverage — Alibaba Token Plan restores its 5-hour and weekly limits, and monthly or additional limits stay visible when providers offer them.": { "extractionState": "manual", "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Richer limit coverage — Alibaba Token Plan restores its 5-hour and weekly limits, and monthly or additional limits stay visible when providers offer them." } }, + "ja": { "stringUnit": { "state": "translated", "value": "上限表示を拡充 — Alibaba Token Plan の5時間・週間上限を復元し、プロバイダーが提供する月間および追加の上限も表示します。" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "更完整的额度展示——恢复 Alibaba Token Plan 的 5 小时和每周额度,并继续显示提供商支持的月度及其他额度。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "更完整的額度顯示——恢復 Alibaba Token Plan 的 5 小時和每週額度,並繼續顯示提供者支援的月度及其他額度。" } } } }, "A more capable Mac companion — customize menu bar layouts, see usage forecasts, use safer refresh controls, and benefit from the latest provider, performance, and security fixes.": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "A more capable Mac companion — customize menu bar layouts, see usage forecasts, use safer refresh controls, and benefit from the latest provider, performance, and security fixes." } }, @@ -19642,11 +19671,11 @@ "zh-Hans": { "stringUnit": { "state": "translated", "value": "功能更强的 Mac 端——可自定义菜单栏布局、查看用量预测、使用更安全的刷新控制,并获得最新的提供商、性能与安全修复。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "功能更強的 Mac 端——可自訂選單列佈局、查看用量預測、使用更安全的重新整理控制,並獲得最新的提供者、效能與安全修正。" } } } }, - "For all new details, update CodexBar on Mac to version 0.45.2.1 or later. iPhone 1.19 still works with data from older Mac versions.": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "For all new details, update CodexBar on Mac to version 0.45.2.1 or later. iPhone 1.19 still works with data from older Mac versions." } }, - "ja": { "stringUnit": { "state": "translated", "value": "すべての新しい詳細を表示するには、Mac の CodexBar を 0.45.2.1 以降に更新してください。iPhone 1.19 は古い Mac 版のデータも引き続き表示できます。" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "要查看全部新详情,请将 Mac 上的 CodexBar 更新到 0.45.2.1 或更高版本。iPhone 1.19 仍可继续读取旧版 Mac 的数据。" } }, - "zh-Hant": { "stringUnit": { "state": "translated", "value": "要查看全部新詳情,請將 Mac 上的 CodexBar 更新到 0.45.2.1 或更高版本。iPhone 1.19 仍可繼續讀取舊版 Mac 的資料。" } } + "For all new details, update CodexBar on Mac to version 0.45.2.2 or later. iPhone 1.19 still works with data from older Mac versions.": { "extractionState": "manual", "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "For all new details, update CodexBar on Mac to version 0.45.2.2 or later. iPhone 1.19 still works with data from older Mac versions." } }, + "ja": { "stringUnit": { "state": "translated", "value": "すべての新しい詳細を表示するには、Mac の CodexBar を 0.45.2.2 以降に更新してください。iPhone 1.19 は古い Mac 版のデータも引き続き表示できます。" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "要查看全部新详情,请将 Mac 上的 CodexBar 更新到 0.45.2.2 或更高版本。iPhone 1.19 仍可继续读取旧版 Mac 的数据。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "要查看全部新詳情,請將 Mac 上的 CodexBar 更新到 0.45.2.2 或更高版本。iPhone 1.19 仍可繼續讀取舊版 Mac 的資料。" } } } } }, "version": "1.0" diff --git a/CodexBarMobile/CodexBarMobile/Views/V045ProviderCards.swift b/CodexBarMobile/CodexBarMobile/Views/V045ProviderCards.swift index 39b9e4b9b..495deac65 100644 --- a/CodexBarMobile/CodexBarMobile/Views/V045ProviderCards.swift +++ b/CodexBarMobile/CodexBarMobile/Views/V045ProviderCards.swift @@ -4,6 +4,8 @@ import SwiftUI enum ProviderWindowLabel { static func localizationKey(for label: String?) -> String? { switch label { + case "5-hour": "5-hour" + case "Credits": "Credits" case "Daily": "v045_window_daily" case "Weekly": "v045_window_weekly" case "Monthly": "v045_window_monthly" @@ -33,6 +35,10 @@ enum ProviderWindowLabel { return label ?? fallback } switch key { + case "5-hour": + return String(localized: "5-hour") + case "Credits": + return String(localized: "Credits", defaultValue: "Credits") case "v045_window_daily": return String(localized: "v045_window_daily", defaultValue: "Daily") case "v045_window_weekly": diff --git a/CodexBarMobile/CodexBarMobileTests/CloudKitMergeTests.swift b/CodexBarMobile/CodexBarMobileTests/CloudKitMergeTests.swift index bfc11a0f1..5b6644fff 100644 --- a/CodexBarMobile/CodexBarMobileTests/CloudKitMergeTests.swift +++ b/CodexBarMobile/CodexBarMobileTests/CloudKitMergeTests.swift @@ -277,6 +277,63 @@ struct CloudKitMergeTests { #expect(kimi.rateWindows.last?.usedPercent == 10) } + @Test(arguments: [false, true]) + func `Mixed Alibaba writers preserve rolling lanes in both freshness orders`( + oldMacIsFresher: Bool) throws + { + let oldDate = oldMacIsFresher ? self.newerDate : self.olderDate + let newDate = oldMacIsFresher ? self.olderDate : self.newerDate + let oldMacProvider = ProviderUsageSnapshot( + providerID: "alibabatokenplan", providerName: "Alibaba Token Plan", + primary: SyncRateWindow( + label: "Credits", usedPercent: 30, windowMinutes: 43200, + resetsAt: nil, resetDescription: nil), + secondary: nil, + accountEmail: nil, loginMethod: "Bailian Pro", + statusMessage: nil, isError: false, lastUpdated: oldDate, + rateWindows: [ + SyncRateWindow( + label: "Credits", usedPercent: 30, windowMinutes: 43200, + resetsAt: nil, resetDescription: nil), + ]) + let newMacProvider = ProviderUsageSnapshot( + providerID: "alibabatokenplan", providerName: "Alibaba Token Plan", + primary: SyncRateWindow( + label: "5-hour", usedPercent: 10, windowMinutes: 300, + resetsAt: nil, resetDescription: nil), + secondary: SyncRateWindow( + label: "Weekly", usedPercent: 20, windowMinutes: 10080, + resetsAt: nil, resetDescription: nil), + accountEmail: nil, loginMethod: "Bailian Pro", + statusMessage: nil, isError: false, lastUpdated: newDate, + rateWindows: [ + SyncRateWindow( + label: "5-hour", usedPercent: 10, windowMinutes: 300, + resetsAt: nil, resetDescription: nil), + SyncRateWindow( + label: "Weekly", usedPercent: 20, windowMinutes: 10080, + resetsAt: nil, resetDescription: nil), + SyncRateWindow( + label: "Credits", usedPercent: 30, windowMinutes: 43200, + resetsAt: nil, resetDescription: nil), + ]) + + let oldMac = self.makeSnapshot( + deviceName: "Old Mac", deviceID: "uuid-old", providers: [oldMacProvider], + appVersion: "0.45.2.1") + let newMac = self.makeSnapshot( + deviceName: "New Mac", deviceID: "uuid-new", providers: [newMacProvider], + appVersion: "0.45.2.2") + let iPhoneA = try #require(CloudSyncReader.mergeSnapshots([oldMac, newMac])) + let iPhoneB = try #require(CloudSyncReader.mergeSnapshots([newMac, oldMac])) + let alibabaA = try #require(iPhoneA.providers.first) + let alibabaB = try #require(iPhoneB.providers.first) + + #expect(alibabaA.rateWindows.compactMap(\.label) == ["5-hour", "Weekly", "Credits"]) + #expect(alibabaA.rateWindows.map(\.windowMinutes) == [300, 10080, 43200]) + #expect(alibabaB.rateWindows == alibabaA.rateWindows) + } + @Test(arguments: [false, true]) func `Mixed Claude writers preserve a specific Max tier in both freshness orders`( oldMacIsFresher: Bool) throws diff --git a/CodexBarMobile/CodexBarMobileTests/V045ProviderPresentationTests.swift b/CodexBarMobile/CodexBarMobileTests/V045ProviderPresentationTests.swift index eb09ce2d8..c18b20f7e 100644 --- a/CodexBarMobile/CodexBarMobileTests/V045ProviderPresentationTests.swift +++ b/CodexBarMobile/CodexBarMobileTests/V045ProviderPresentationTests.swift @@ -9,6 +9,8 @@ struct V045ProviderPresentationTests { func `canonical generic window labels map to localized semantic keys`() { #expect(ProviderWindowLabel.localizationKey(for: "Daily") == "v045_window_daily") #expect(ProviderWindowLabel.localizationKey(for: "Weekly") == "v045_window_weekly") + #expect(ProviderWindowLabel.localizationKey(for: "5-hour") == "5-hour") + #expect(ProviderWindowLabel.localizationKey(for: "Credits") == "Credits") #expect(ProviderWindowLabel.localizationKey(for: "Monthly") == "v045_window_monthly") #expect(ProviderWindowLabel.localizationKey(for: "Additional") == "v045_window_additional") #expect(ProviderWindowLabel.localizationKey(for: "5 hour limit") == "v045_window_5_hour_limit") diff --git a/CodexBarMobile/CodexBarWidgetShared/ProviderSnapshotMerger.swift b/CodexBarMobile/CodexBarWidgetShared/ProviderSnapshotMerger.swift index 3a488db77..888c7ce51 100644 --- a/CodexBarMobile/CodexBarWidgetShared/ProviderSnapshotMerger.swift +++ b/CodexBarMobile/CodexBarWidgetShared/ProviderSnapshotMerger.swift @@ -251,15 +251,19 @@ enum ProviderSnapshotMerger { .first(where: specificMaxLabels.contains) ?? latest } - /// Kimi and Claude added named lanes over several Mac releases. Preserve a - /// lane supplied by any active writer while taking overlapping values from - /// the freshest writer. Kimi then restores its canonical mobile order; - /// Claude keeps freshest-writer order followed by missing lanes. + /// Kimi, Claude, and Alibaba Token Plan added named lanes over several Mac + /// releases. Preserve a lane supplied by any active writer while taking + /// overlapping values from the freshest writer. Providers with fixed lane + /// semantics then restore their canonical mobile order; Claude keeps + /// freshest-writer order followed by missing lanes. private static func mergedRateWindows( _ entries: [ProviderUsageSnapshot], base: ProviderUsageSnapshot) -> [SyncRateWindow] { - guard base.providerID == "kimi" || base.providerID == "claude" else { + guard base.providerID == "kimi" + || base.providerID == "claude" + || base.providerID == "alibabatokenplan" + else { return base.rateWindows } @@ -276,13 +280,24 @@ enum ProviderSnapshotMerger { } } - guard base.providerID == "kimi" else { return merged } - let preferredOrder = [ - "weekly": 0, - "rate limit": 1, - "monthly": 2, - "code 7-day": 3, - ] + let preferredOrder: [String: Int] + switch base.providerID { + case "kimi": + preferredOrder = [ + "weekly": 0, + "rate limit": 1, + "monthly": 2, + "code 7-day": 3, + ] + case "alibabatokenplan": + preferredOrder = [ + "5-hour": 0, + "weekly": 1, + "credits": 2, + ] + default: + return merged + } return merged.enumerated().sorted { lhs, rhs in let lhsRank = Self.normalizedRateWindowLabel(lhs.element) .flatMap { preferredOrder[$0] } ?? Int.max diff --git a/CodexBarMobile/Research/043-alibaba-token-plan-rate-windows.md b/CodexBarMobile/Research/043-alibaba-token-plan-rate-windows.md new file mode 100644 index 000000000..b71ff6512 --- /dev/null +++ b/CodexBarMobile/Research/043-alibaba-token-plan-rate-windows.md @@ -0,0 +1,179 @@ +# Alibaba Token Plan Rate Windows Hotfix + +Status: `done` +Date: 2026-07-24 +Issue: [#59](https://github.com/o1xhack/CodexBar-Mobile/issues/59) +Upstream PR: [steipete/CodexBar#2437](https://github.com/steipete/CodexBar/pull/2437) +Branch: `fix/alibaba-token-plan-rate-windows` + +## Problem + +Released builds can authenticate Alibaba Token Plan accounts but may show no +usage windows because the legacy `GetSubscriptionSummary` response no longer +supplies the rolling 5-hour and weekly limits. A user submitted a provider-only +fix directly against the current `mobile-dev` commit and requested an ordinary +signed fork release. + +The contribution is evidence that the fork has external users who benefit from +timely provider fixes. Waiting for an upstream release is therefore not the +default decision when a small, reviewable patch can be validated independently. + +## Upstream and Fork State + +- The latest published upstream release is `v0.45.2`, which is already the + fork's authoritative baseline in `version.env`. +- Upstream PR #2437 remains open. Its fix commit is one commit ahead of current + upstream `main`, so no upstream release contains the change. +- Contributor commit + [`7604d2d`](https://github.com/rohitsabu/CodexBar/commit/7604d2d15cc009f340f80489f9b9aaa2c7d3ef0b) + is based directly on current fork commit `e2817d62`, making it the correct + provenance for a fork hotfix review. + +## Chosen Approach + +1. Fetch upstream PR #2437 into the read-only local ref + `upstream/pr/2437`, inspect its exact head, and compare it with the + contributor's fork commit. +2. Apply the contributor's provider-only commit without rewriting its logic. +3. Review the endpoint, authentication-header, parser, fallback, and snapshot + mapping changes for credential leakage and cross-region regressions. +4. Preserve `GetSubscriptionSummary` as the fallback and optional monthly + credits window. +5. Keep the existing Mac-to-iOS generic `primary` / `secondary` rate-window + sync path; do not change CloudKit schema, entitlements, or app groups. +6. Treat this as a fork hotfix ahead of upstream, not as an upstream sync. + +The upstream PR head is `94827370`, based on six unreleased upstream commits. +Pulling that branch into the fork would therefore import unrelated work. The +contributor's corresponding fork commit applies the same four-file hotfix to +our exact baseline while preserving the fork-only +`alibabaTokenPlanUsage: self` sync hook. + +## Review Findings + +- The new request uses the existing authenticated Alibaba endpoint family and + forwards only the same session cookie and browser-like headers already used + by the provider. No credential is added to logs, persistence, or sync data. +- Response decoding is tolerant of missing items and keeps + `GetSubscriptionSummary` as a fallback, so an unavailable rate-limit endpoint + does not remove the legacy monthly-credit display. +- Upstream review found one unresolved P2: the restored windows were still + presented with the generic `Credits` and `Usage` labels. This branch fixes + both menu render paths dynamically: true 300-minute and 10,080-minute windows + display as `5-hour` and `Weekly`, while the legacy monthly fallback retains + its existing `Credits` label. +- Fork PR review round 1 found two additional P2 cases. The fetcher now merges + the subscription summary into a successful rate-limit snapshot instead of + dropping monthly credits, and keeps the rate windows when the summary request + fails. Window mapping now compacts available 5-hour, weekly, and monthly + values so a weekly-only response remains visible and correctly labeled. +- Fork PR review round 2 found two cross-surface and endpoint-override gaps. + `ALIBABA_TOKEN_PLAN_HOST` now routes the rate-limit request as well as the + dashboard and subscription summary, including override-scoped Origin, + Referer, and request metadata, so test credentials never fall through to the + production rate-limit host. Duration-derived labels now flow through Mac + menus, CloudKit sync, localized iOS cards, CLI text, and dashboard JSON; the + tertiary monthly credits window is no longer hidden outside the Mac menu. +- Fork PR review round 3 found two request-behavior regressions. Production + rate-limit requests now retain the region's Alibaba dashboard Origin, while + `ALIBABA_TOKEN_PLAN_HOST` still supplies the Origin for override traffic. + Rate-limit and subscription-summary requests now start concurrently; a + successful summary waits at most two seconds for optional rolling windows, + and a failed summary allows at most five seconds for rate data to become the + fallback. The rate endpoint's 20-second timeout can therefore no longer + block a healthy summary response. +- Fork PR review round 4 found that the standalone + `ALIBABA_TOKEN_PLAN_QUOTA_URL` override did not contain the new rate-limit + request. Rate-limit endpoint resolution now mirrors subscription-summary + precedence: the explicit quota URL wins over the shared host override, and + all API cookies and request metadata remain scoped to that override origin. +- Fork PR review round 5 found two remaining consumer and credential-scoping + gaps. Browser imports now build and cache three independently URL-scoped + headers for the subscription summary, dashboard, and rate-limit RPC host; + pre-fix cache entries are refreshed instead of forwarding host-only cookies + across sibling domains. Mac Widget rows now use the same duration-derived + labels as the app, preserve each source window, and include Alibaba's + tertiary monthly credits row. +- Fork PR review round 6 found two final request-metadata and localization + gaps. Rate-limit `Referer` and `feURL` metadata now use the personal usage + page, including the active quota/host override origin. The Mac app and Widget + resolve `5-hour` through the localization bundle, with translations in every + supported Mac locale. +- Fork PR review round 7 found a cancellation-time race in redirect + diagnostics. Redirect delegate writes and teardown-time log snapshots now + share an `NSLock`, so an optional rate request timing out cannot mutate the + redirect array while it is read. +- Fork PR review round 8 found that rate-only fallbacks still synced an empty + structured Credits card to iOS. The sync mapper now emits that card only + when its values can render a credit metric; the independent 5-hour and + weekly rate-window cards remain available. +- Fork PR review round 9 and a full-branch self-review found symmetric stale + cookie cases: either rate-limit or subscription-summary credentials could + fail while the other response hid the rejection. Auto-imported cookies now + refresh on an explicit credential failure from either endpoint, while the + post-refresh attempt keeps any still-usable partial result. Merged snapshots + also retain the subscription summary's concrete plan name. +- Fork PR review round 10 found that compacting partial rate responses could + promote a weekly value into the session lane. Alibaba snapshots now preserve + semantic slots even when 5-hour or weekly data is absent, and the legacy + CloudKit `primary` / `secondary` fields retain the same lane identity. +- The required 2 Mac × 2 iPhone review then exposed a mixed-writer risk not + called out by the review: a freshest old-Mac Credits-only snapshot could hide + rolling lanes from a new Mac. The mobile merger now unions Alibaba's named + lanes across writers and restores `5-hour`, `Weekly`, `Credits` order. +- The patch changes provider fetch and presentation code only. It does not + change credentials, entitlements, app groups, Shared models, CloudKit record + types, or CloudKit indexes. + +## Acceptance Gates + +- Focused `AlibabaTokenPlanProviderTests` pass with test-only URL sessions and + no macOS Keychain prompts. +- Related sync mapping and menu-card tests pass. +- Portable lint and `git diff --check` pass. +- Mac app/CLI builds compile. +- The generated iOS project builds for an unsigned Simulator destination. +- No credentials, cookies, account identifiers, private configuration, + signing, entitlement, CloudKit, or app-group changes are present. +- PR Fast Checks and review are clear before merge; post-merge Final CI must + pass before any release. + +## Validation Evidence + +- Focused Alibaba provider checks: 62 tests across 11 suites passed. The + adjacent Dashboard, CLI guard, and wire-compatibility suites add 40 passing + tests, including URL-scoped RPC cookies, Mac Widget rows, + personal-page RPC metadata, production/override Origin handling, bounded + concurrent fetch behavior, endpoint override, localized Mac labels, + CloudKit/iOS labels, CLI output, and dashboard JSON. +- `CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS=1 swift test --parallel`: complete + macOS test graph passed with live-account tests disabled. +- `bash Scripts/lint.sh lint`: passed with 0 violations across 1,628 Swift + files, including SwiftFormat, SwiftLint, + localization, parser-version, docs, and fork CI-policy guards. +- `swift build --product CodexBar` and + `swift build --product CodexBarCLI`: passed. +- Generated the iOS project from `project.yml`; 604 non-UI tests and 3 + serialized UI tests passed on an iPhone 17 / iOS 26.4 Simulator. Four + SpringBoard widget tests were skipped by their explicit environment guard. +- Four-language release-note catalog passes the source/catalog audit and JSON + validation. +- CloudKit release audit against + `v0.45.2.1-mobile.1.19.0`: no schema-related or Shared model diff, so no + Production schema deploy is required. +- The canonical sync compatibility matrix is recorded in + [`043-alibaba-token-plan-rate-windows/03-testing.md`](043-alibaba-token-plan-rate-windows/03-testing.md). + All 16 rows passed substituted code/test coverage, including both old/new + writer freshness orders and opposite reader input orders. The document + explicitly retains the real-device silent-push/cache risk for release QA. +- No live Alibaba request, browser-cookie import, Keychain read, signing, + notarization, archive, upload, or release action was performed. + +## Authorization Boundary + +This task authorizes branch work, testing, fixes, and PR handoff. The PR must +remain unmerged for user review. It does not authorize live Alibaba account +probes, Keychain reads, merging, tagging, notarization, TestFlight upload, +App Store actions, appcast publication, or a live GitHub release. +User-installable publication remains a separate decision after the reviewed +hotfix is green. diff --git a/CodexBarMobile/Research/043-alibaba-token-plan-rate-windows/03-testing.md b/CodexBarMobile/Research/043-alibaba-token-plan-rate-windows/03-testing.md new file mode 100644 index 000000000..5b0980ffd --- /dev/null +++ b/CodexBarMobile/Research/043-alibaba-token-plan-rate-windows/03-testing.md @@ -0,0 +1,83 @@ +# Alibaba Token Plan Rate Windows — Sync Compatibility Testing + +Status: `substituted` +Date: 2026-07-24 +Canonical gate: [`docs/ios-sync-compatibility-testing.md`](../../../docs/ios-sync-compatibility-testing.md) + +## Verdict + +The 16-case compatibility gate is recorded and has no simulated failure. Real +hardware coverage is **not complete**: this workspace has one Mac and one iOS +Simulator, not two independent Macs and two physical iPhones with old/new +signed builds. The PR may complete code review, but release QA must treat the +remaining silent-push, independent-cache, and real CloudKit convergence risk as +unverified until the physical matrix is run. + +Versions represented by the substituted checks: + +- old Mac: `0.45.2.1`; new Mac: `0.45.2.2` +- old iOS: `1.19.0 (188)`; new iOS: `1.19.0 (189)` + +No live Alibaba account, browser cookies, Keychain item, Production CloudKit +record, or signed old build was used. This preserves the task's authorization +boundary and avoids prompting for credentials. + +## Substituted Evidence + +| ID | Validation | Result | +|---|---|---| +| S1 | `swift test --filter AlibabaTokenPlan` passed 62 tests in 11 suites. Coverage includes monthly-only, 5-hour + weekly, weekly-only, and weekly + monthly partial responses. Semantic slots stay `primary = 5-hour`, `secondary = Weekly`, `tertiary = Credits`. | pass | +| S2 | `DashboardSnapshotBuilderTests` (11), `CLIGuardDecisionTests` (14), and the Alibaba menu/Widget/sync checks cover CLI guard inputs, menu/card labels, Widget rows, Dashboard JSON kinds, and sync projection. Weekly-only never becomes a session window. | pass | +| S3 | `CloudKitMergeTests` runs two distinct Mac device IDs in both old/new freshness orders and two opposite reader input orders. Alibaba lanes converge in canonical order `5-hour`, `Weekly`, `Credits`; all 54 merge tests pass. | pass | +| S4 | All 15 `SyncWireFormatRoundTripTests` pass, including current-reader decode of payloads without `rateWindows`; the branch adds no Shared field, CloudKit record type, index, entitlement, or schema change. Old clients retain the existing `primary` / `secondary` wire fields. | pass | +| S5 | `V045ProviderPresentationTests` verifies the new iOS reader's semantic localization keys. On iPhone 17 / iOS 26.4 Simulator, 604 non-UI tests pass; the serialized UI target passes 3 tests with 4 SpringBoard-only cases skipped by their environment guard. | pass | +| S6 | Source audit confirms both iPhone paths consume `ProviderUsageSnapshot.allRateWindows`; mixed-device merge is deterministic and independent of snapshot input order. Ghost filtering and account/device identity code are unchanged. | pass | + +## 2 Mac × 2 iPhone Matrix + +Every row is marked `substituted` because independent physical-device caches, +silent pushes, and Production CloudKit delivery cannot be reproduced in this +single-Mac simulator environment. + +| Case | Mac A | Mac B | iPhone A | iPhone B | Result | Evidence | Notes | +|---:|---|---|---|---|---|---|---| +| 1 | old | old | old | old | substituted | S3, S4, S6 | R1: real silent-push and independent-cache convergence unverified. | +| 2 | old | old | old | new | substituted | S3–S6 | R1: real silent-push and independent-cache convergence unverified. | +| 3 | old | old | new | old | substituted | S3–S6 | R1: real silent-push and independent-cache convergence unverified. | +| 4 | old | old | new | new | substituted | S3–S6 | R1: real silent-push and independent-cache convergence unverified. | +| 5 | old | new | old | old | substituted | S1–S4, S6 | R1: real silent-push and independent-cache convergence unverified. | +| 6 | old | new | old | new | substituted | S1–S6 | R1: real silent-push and independent-cache convergence unverified. | +| 7 | old | new | new | old | substituted | S1–S6 | R1: real silent-push and independent-cache convergence unverified. | +| 8 | old | new | new | new | substituted | S1–S3, S5, S6 | R1: real silent-push and independent-cache convergence unverified. | +| 9 | new | old | old | old | substituted | S1–S4, S6 | R1: real silent-push and independent-cache convergence unverified. | +| 10 | new | old | old | new | substituted | S1–S6 | R1: real silent-push and independent-cache convergence unverified. | +| 11 | new | old | new | old | substituted | S1–S6 | R1: real silent-push and independent-cache convergence unverified. | +| 12 | new | old | new | new | substituted | S1–S3, S5, S6 | R1: real silent-push and independent-cache convergence unverified. | +| 13 | new | new | old | old | substituted | S1–S4, S6 | R1: real silent-push and independent-cache convergence unverified. | +| 14 | new | new | old | new | substituted | S1–S6 | R1: real silent-push and independent-cache convergence unverified. | +| 15 | new | new | new | old | substituted | S1–S6 | R1: real silent-push and independent-cache convergence unverified. | +| 16 | new | new | new | new | substituted | S1–S3, S5, S6 | R1: real silent-push and independent-cache convergence unverified. | + +## Risk Found and Fixed During Matrix Review + +The first mixed-writer simulation exposed a real compatibility defect outside +the original review comment: if an old Mac wrote the freshest monthly-only +snapshot, its `Credits` lane replaced the new Mac's rolling windows. +`ProviderSnapshotMerger` now unions Alibaba's named lanes across active writers +and restores canonical order. The test runs both freshness orders and opposite +reader input orders, preventing one iPhone's fetch order from changing the +visible result. + +## Remaining Release QA + +Before a live release, run the same 16 rows on two Macs and two physical +iPhones. For each row, record screenshots or logs showing: + +1. both Macs retain distinct device records; +2. both iPhones converge to identical `5-hour`, `Weekly`, and `Credits` rows; +3. foreground fetch and silent push both refresh without duplicated or ghost + cards; +4. upgrading either device preserves its cache and account identity. + +Until that evidence exists, this document's final release-gate result remains +`substituted`, not a physical-device `pass`. diff --git a/CodexBarMobile/Research/README.md b/CodexBarMobile/Research/README.md index d302597a6..552810b93 100644 --- a/CodexBarMobile/Research/README.md +++ b/CodexBarMobile/Research/README.md @@ -41,3 +41,4 @@ This directory contains research documents for features being considered for Cod | 039 | v0.41.0 Upstream Sync + iOS 1.18.0 (issues #42/#44/#46, v0.40.0-v0.41.0 as one release, Kimi quota lanes + Claude Max multiplier + sub-1% formatting, Mac draft gate) | `in-progress` | None; remote draft must respect no-push/no-published-tag boundary | [039-v041-upstream-sync/00-overview.md](039-v041-upstream-sync/00-overview.md) | 2026-07-09 | | 041 | Release CLI Fork Homebrew Gate (issue #50, keep fork CLI assets while skipping upstream-only tap dispatch) | `done` | — | [041-release-cli-fork-homebrew-gate.md](041-release-cli-fork-homebrew-gate.md) | 2026-07-16 | | 042 | v0.45.2 Upstream Sync + iOS 1.19.0 (issues #48/#51 plus authoritative Releases v0.42.0-v0.45.2 as one train) | `done` | Draft ready; push/merge/live release/TestFlight/tag/appcast publish remain intentionally unperformed | [042-v045-upstream-sync/00-overview.md](042-v045-upstream-sync/00-overview.md) | 2026-07-19 | +| 043 | Alibaba Token Plan Rate Windows Hotfix (issue #59, rolling 5-hour and weekly usage restoration ahead of upstream release) | `done` | Live Alibaba account proof and signed publication remain separate gates | [043-alibaba-token-plan-rate-windows.md](043-alibaba-token-plan-rate-windows.md) | 2026-07-24 | diff --git a/CodexBarMobile/project.yml b/CodexBarMobile/project.yml index 787547f7e..42d1c191e 100644 --- a/CodexBarMobile/project.yml +++ b/CodexBarMobile/project.yml @@ -35,7 +35,7 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: com.o1xhack.codexbar.sync GENERATE_INFOPLIST_FILE: true MARKETING_VERSION: "1.19.0" - CURRENT_PROJECT_VERSION: "188" + CURRENT_PROJECT_VERSION: "189" CodexBarMobilePushExtension: type: app-extension @@ -56,7 +56,7 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: com.o1xhack.codexbar.mobile.pushextension MARKETING_VERSION: "1.19.0" - CURRENT_PROJECT_VERSION: "188" + CURRENT_PROJECT_VERSION: "189" DEVELOPMENT_TEAM: 3TUERHN53E INFOPLIST_FILE: CodexBarMobilePushExtension/Info.plist CODE_SIGN_ENTITLEMENTS: CodexBarMobilePushExtension/PushExtension.entitlements @@ -82,7 +82,7 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: com.o1xhack.codexbar.mobile.widgets MARKETING_VERSION: "1.19.0" - CURRENT_PROJECT_VERSION: "188" + CURRENT_PROJECT_VERSION: "189" DEVELOPMENT_TEAM: 3TUERHN53E INFOPLIST_FILE: CodexBarMobileWidgets/Info.plist CODE_SIGN_ENTITLEMENTS: CodexBarMobileWidgets/WidgetExtension.entitlements @@ -107,7 +107,7 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: com.o1xhack.codexbar.mobile MARKETING_VERSION: "1.19.0" - CURRENT_PROJECT_VERSION: "188" + CURRENT_PROJECT_VERSION: "189" DEVELOPMENT_TEAM: 3TUERHN53E INFOPLIST_FILE: CodexBarMobile/Info.plist CODE_SIGN_ENTITLEMENTS: CodexBarMobile/CodexBarMobile.entitlements diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index e59f04d03..0b369b99d 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -277,6 +277,19 @@ extension UsageMenuCardView.Model { if input.provider == .factory, snapshot.tertiary != nil { return ("5-hour", L("Weekly"), L("Monthly"), true) } + if input.provider == .alibabatokenplan { + return ( + L(AlibabaTokenPlanProviderDescriptor.rateWindowLabel( + window: snapshot.primary, + fallback: input.metadata.sessionLabel)), + L(AlibabaTokenPlanProviderDescriptor.rateWindowLabel( + window: snapshot.secondary, + fallback: input.metadata.weeklyLabel)), + L(AlibabaTokenPlanProviderDescriptor.rateWindowLabel( + window: snapshot.tertiary, + fallback: "Credits")), + snapshot.tertiary != nil) + } // Legacy request-based Cursor plans track a request quota, not the token-based "Total" pool. let primaryLabel = if input.provider == .cursor, snapshot.cursorRequests != nil { "Requests" diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 5918b696f..2e6dced62 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -669,6 +669,19 @@ struct MenuDescriptor { if provider == .factory, snapshot.tertiary != nil { return ("5-hour", L("Weekly"), L("Monthly"), true) } + if provider == .alibabatokenplan { + return ( + L(AlibabaTokenPlanProviderDescriptor.rateWindowLabel( + window: snapshot.primary, + fallback: metadata.sessionLabel)), + L(AlibabaTokenPlanProviderDescriptor.rateWindowLabel( + window: snapshot.secondary, + fallback: metadata.weeklyLabel)), + L(AlibabaTokenPlanProviderDescriptor.rateWindowLabel( + window: snapshot.tertiary, + fallback: "Credits")), + snapshot.tertiary != nil) + } let primaryLabel = if provider == .grok { GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel } else if provider == .doubao { diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 0fc052901..31f1bd678 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -108,6 +108,7 @@ "Could not start codex login" = "لم أتمكن من بدء تسجيل الدخول إلى الكودكس"; "Could not switch system account" = "لم أتمكن من تغيير حساب النظام"; "Credits" = "الاعتمادات"; +"5-hour" = "5 ساعات"; "Individual credits" = "الاعتمادات الفردية"; "Workspace" = "مساحة العمل"; "Credits history" = "تاريخ الاعتمادات"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 63fb5e624..55de69d8e 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -107,6 +107,7 @@ "Could not start codex login" = "No s'ha pogut iniciar codex login"; "Could not switch system account" = "No s'ha pogut canviar el compte del sistema"; "Credits" = "Crèdits"; +"5-hour" = "5 hores"; "Individual credits" = "Crèdits individuals"; "Workspace" = "Espai de treball"; "Credits history" = "Historial de crèdits"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 69b48e3fa..6f28def9d 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -124,6 +124,7 @@ "Could not start codex login" = "Die Codex-Anmeldung konnte nicht gestartet werden"; "Could not switch system account" = "Das Systemkonto konnte nicht gewechselt werden"; "Credits" = "Credits"; +"5-hour" = "5 Stunden"; "Credits history" = "Credits-Geschichte"; "Cursor login failed" = "Die Cursor-Anmeldung ist fehlgeschlagen"; "Custom" = "Benutzerdefiniert"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 115c845c8..b67e67652 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -108,6 +108,7 @@ "Could not start codex login" = "Could not start codex login"; "Could not switch system account" = "Could not switch system account"; "Credits" = "Credits"; +"5-hour" = "5-hour"; "Individual credits" = "Individual credits"; "Workspace" = "Workspace"; "Credits history" = "Credits history"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 6283d0923..b2366d179 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -123,6 +123,7 @@ "Could not start codex login" = "No se pudo iniciar codex login"; "Could not switch system account" = "No se pudo cambiar la cuenta del sistema"; "Credits" = "Créditos"; +"5-hour" = "5 horas"; "Individual credits" = "Créditos individuales"; "Workspace" = "Espacio de trabajo"; "Credits history" = "Historial de créditos"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index cfa8fd925..36028f71d 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -108,6 +108,7 @@ "Could not start codex login" = "ورود به کدکس فعال نشد"; "Could not switch system account" = "نتوانستم حساب سیستم را تغییر دهم"; "Credits" = "اعتبارات"; +"5-hour" = "۵ ساعت"; "Individual credits" = "اعتبارات فردی"; "Workspace" = "فضای کاری"; "Credits history" = "تاریخچه اعتبارها"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index c61ac165d..f81c8196b 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -124,6 +124,7 @@ "Could not start codex login" = "Impossible de démarrer la connexion à Codex"; "Could not switch system account" = "Impossible de changer de compte système"; "Credits" = "Crédits"; +"5-hour" = "5 heures"; "Individual credits" = "Crédits individuels"; "Workspace" = "Espace de travail"; "Credits history" = "Historique des crédits"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index dae357a27..be0734697 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -106,6 +106,7 @@ "Could not start codex login" = "Non se puido iniciar codex login"; "Could not switch system account" = "Non se puido cambiar a conta do sistema"; "Credits" = "Créditos"; +"5-hour" = "5 horas"; "Individual credits" = "Créditos individuais"; "Workspace" = "Espazo de traballo"; "Credits history" = "Historial de créditos"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 2a1d9eced..383539bbd 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -108,6 +108,7 @@ "Could not start codex login" = "Tidak dapat memulai codex login"; "Could not switch system account" = "Tidak dapat beralih akun sistem"; "Credits" = "Kredit"; +"5-hour" = "5 jam"; "Individual credits" = "Kredit individual"; "Workspace" = "Workspace"; "Credits history" = "Riwayat kredit"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index dfa1e7835..3605ee316 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -108,6 +108,7 @@ "Could not start codex login" = "Impossibile avviare codex login"; "Could not switch system account" = "Impossibile cambiare account di sistema"; "Credits" = "Crediti"; +"5-hour" = "5 ore"; "Individual credits" = "Crediti individuali"; "Workspace" = "Spazio di lavoro"; "Credits history" = "Storico crediti"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 73d5f2ffe..d6e722f2a 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -124,6 +124,7 @@ "Could not start codex login" = "codex login を開始できませんでした"; "Could not switch system account" = "システムアカウントを切り替えられませんでした"; "Credits" = "クレジット"; +"5-hour" = "5時間"; "Individual credits" = "個人クレジット"; "Workspace" = "ワークスペース"; "Credits history" = "クレジット履歴"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index fcc34f8be..8f6cccaf1 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -124,6 +124,7 @@ "Could not start codex login" = "codex login을 시작할 수 없음"; "Could not switch system account" = "시스템 계정을 전환할 수 없음"; "Credits" = "크레딧"; +"5-hour" = "5시간"; "Individual credits" = "개인 크레딧"; "Workspace" = "작업 공간"; "Credits history" = "크레딧 내역"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index c9a27af76..e2188844b 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -124,6 +124,7 @@ "Could not start codex login" = "Kan codex-aanmelding niet starten"; "Could not switch system account" = "Kan van systeemaccount niet wisselen"; "Credits" = "Kredieten"; +"5-hour" = "5 uur"; "Individual credits" = "Individuele kredieten"; "Workspace" = "Werkruimte"; "Credits history" = "Creditgeschiedenis"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 09f34f48c..cc18b4071 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -108,6 +108,7 @@ "Could not start codex login" = "Nie udało się uruchomić logowania Codex"; "Could not switch system account" = "Nie udało się przełączyć konta systemowego"; "Credits" = "Kredyty"; +"5-hour" = "5 godzin"; "Individual credits" = "Kredyty indywidualne"; "Workspace" = "Obszar roboczy"; "Credits history" = "Historia kredytów"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index d737e18c1..a06e3f375 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -124,6 +124,7 @@ "Could not start codex login" = "Não foi possível iniciar o login do Codex"; "Could not switch system account" = "Não foi possível trocar a conta do sistema"; "Credits" = "Créditos"; +"5-hour" = "5 horas"; "Individual credits" = "Créditos individuais"; "Workspace" = "Workspace"; "Credits history" = "Histórico de créditos"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 5ae31e070..f62f662ca 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -125,6 +125,7 @@ "Could not start codex login" = "Не удалось запустить вход в Codex"; "Could not switch system account" = "Не удалось переключить системный аккаунт"; "Credits" = "Кредиты"; +"5-hour" = "5 часов"; "Individual credits" = "Индивидуальные кредиты"; "Workspace" = "Рабочая область"; "Credits history" = "История кредитов"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index a6cf33b4d..0b871141a 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -124,6 +124,7 @@ "Could not start codex login" = "Kunde inte starta codex login"; "Could not switch system account" = "Kunde inte byta systemkonto"; "Credits" = "Krediter"; +"5-hour" = "5 timmar"; "Individual credits" = "Individuella krediter"; "Workspace" = "Arbetsyta"; "Credits history" = "Kredithistorik"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 57515b161..4b68037ec 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -108,6 +108,7 @@ "Could not start codex login" = "ไม่สามารถเริ่มการเข้าสู่ระบบ codex"; "Could not switch system account" = "ไม่สามารถสลับบัญชีระบบได้"; "Credits" = "เครดิต"; +"5-hour" = "5 ชั่วโมง"; "Individual credits" = "หน่วยกิตส่วนบุคคล"; "Workspace" = "พื้นที่ทํางาน"; "Credits history" = "ประวัติเครดิต"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 7292c9946..170da32e6 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -108,6 +108,7 @@ "Could not start codex login" = "codex login başlatılamadı"; "Could not switch system account" = "Sistem hesabı değiştirilemedi"; "Credits" = "Krediler"; +"5-hour" = "5 saat"; "Individual credits" = "Bireysel krediler"; "Workspace" = "Çalışma alanı"; "Credits history" = "Kredi geçmişi"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index daeee90b6..e03666b60 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -124,6 +124,7 @@ "Could not start codex login" = "Не вдалося розпочати вхід до Codex"; "Could not switch system account" = "Не вдалося змінити обліковий запис системи"; "Credits" = "Кредити"; +"5-hour" = "5 годин"; "Individual credits" = "Особисті кредити"; "Workspace" = "Робоча область"; "Credits history" = "Кредитна історія"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index a0dfb8c49..8da43a3ba 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -124,6 +124,7 @@ "Could not start codex login" = "Không thể bắt đầu đăng nhập codex"; "Could not switch system account" = "Không thể không chuyển đổi tài khoản hệ thống"; "Credits" = "Tín dụng"; +"5-hour" = "5 giờ"; "Individual credits" = "Tín dụng cá nhân"; "Workspace" = "Không gian làm việc"; "Credits history" = "Lịch sử tín dụng"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 1c66fd701..d131f1df1 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -126,6 +126,7 @@ "Could not start codex login" = "无法启动 codex login"; "Could not switch system account" = "无法切换系统账户"; "Credits" = "额度"; +"5-hour" = "5 小时"; "Individual credits" = "个人额度"; "Workspace" = "工作区"; "Credits history" = "额度记录"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 9bff1a325..6eac8a66a 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -127,6 +127,7 @@ "Could not start codex login" = "無法啟動 codex login"; "Could not switch system account" = "無法切換系統帳號"; "Credits" = "額度"; +"5-hour" = "5 小時"; "Individual credits" = "個人額度"; "Workspace" = "工作區"; "Credits history" = "額度歷史"; diff --git a/Sources/CodexBar/Sync/SyncCoordinator.swift b/Sources/CodexBar/Sync/SyncCoordinator.swift index 349b04759..50b8df6ba 100644 --- a/Sources/CodexBar/Sync/SyncCoordinator.swift +++ b/Sources/CodexBar/Sync/SyncCoordinator.swift @@ -704,24 +704,43 @@ final class SyncCoordinator { { // Build dynamic rate windows array with labels from metadata. var rateWindows: [SyncRateWindow] = [] + var semanticWindows: (primary: SyncRateWindow?, secondary: SyncRateWindow?) = (nil, nil) if let p = snapshot?.primary { - rateWindows.append(SyncRateWindow( - label: metadata?.sessionLabel, + let label = provider == .alibabatokenplan + ? AlibabaTokenPlanProviderDescriptor.rateWindowLabel( + window: p, + fallback: metadata?.sessionLabel ?? "Credits") + : metadata?.sessionLabel + let window = SyncRateWindow( + label: label, usedPercent: p.usedPercent, windowMinutes: p.windowMinutes, resetsAt: p.resetsAt, - resetDescription: p.resetDescription)) + resetDescription: p.resetDescription) + rateWindows.append(window) + semanticWindows.primary = window } if let s = snapshot?.secondary { - rateWindows.append(SyncRateWindow( - label: metadata?.weeklyLabel, + let label = provider == .alibabatokenplan + ? AlibabaTokenPlanProviderDescriptor.rateWindowLabel( + window: s, + fallback: metadata?.weeklyLabel ?? "Usage") + : metadata?.weeklyLabel + let window = SyncRateWindow( + label: label, usedPercent: s.usedPercent, windowMinutes: s.windowMinutes, resetsAt: s.resetsAt, - resetDescription: s.resetDescription)) + resetDescription: s.resetDescription) + rateWindows.append(window) + semanticWindows.secondary = window } if let t = snapshot?.tertiary { - let label: String? = if let metadata, metadata.supportsOpus { + let label: String? = if provider == .alibabatokenplan { + AlibabaTokenPlanProviderDescriptor.rateWindowLabel( + window: t, + fallback: "Credits") + } else if let metadata, metadata.supportsOpus { metadata.opusLabel ?? "Sonnet" } else { Self.additionalWindowLabel(windowMinutes: t.windowMinutes) @@ -745,8 +764,8 @@ final class SyncCoordinator { } // Legacy primary/secondary for backward compat with older iOS builds. - let primaryWindow = rateWindows.first - let secondaryWindow = rateWindows.count > 1 ? rateWindows[1] : nil + let primaryWindow = provider == .alibabatokenplan ? semanticWindows.primary : rateWindows.first + let secondaryWindow = provider == .alibabatokenplan ? semanticWindows.secondary : rateWindows.dropFirst().first // Provider budget / spend (per-account when snapshot.providerCost is // set per-account by upstream; otherwise shared with active). @@ -2005,6 +2024,10 @@ final class SyncCoordinator { snapshot: UsageSnapshot?) -> SyncAlibabaTokenPlan? { guard provider == .alibabatokenplan, let a = snapshot?.alibabaTokenPlanUsage else { return nil } + let hasCreditProgress = (a.totalQuota ?? 0) > 0 && + (a.usedQuota != nil || a.remainingQuota != nil) + let hasRemainingCredits = a.remainingQuota != nil + guard hasCreditProgress || hasRemainingCredits else { return nil } return SyncAlibabaTokenPlan( planName: a.planName, usedCredits: a.usedQuota, diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index 0a8000d75..42ba4fe1e 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -161,6 +161,23 @@ extension UsageStore { { return rows } + if provider == .alibabatokenplan { + let windows: [(id: String, window: RateWindow?, fallback: String)] = [ + ("primary", snapshot.primary, metadata?.sessionLabel ?? "Session"), + ("secondary", snapshot.secondary, metadata?.weeklyLabel ?? "Weekly"), + ("tertiary", snapshot.tertiary, "Credits"), + ] + return windows.compactMap { candidate in + guard let window = candidate.window else { return nil } + return WidgetSnapshot.WidgetUsageRowSnapshot( + id: candidate.id, + title: L(AlibabaTokenPlanProviderDescriptor.rateWindowLabel( + window: window, + fallback: candidate.fallback)), + percentLeft: window.remainingPercent, + window: window) + } + } let primaryTitle: String = { // Legacy request-based Cursor plans track a request quota, not the token-based "Total" pool. diff --git a/Sources/CodexBarCLI/CLIGuardCommand.swift b/Sources/CodexBarCLI/CLIGuardCommand.swift index 87d1a7d51..2d5da0710 100644 --- a/Sources/CodexBarCLI/CLIGuardCommand.swift +++ b/Sources/CodexBarCLI/CLIGuardCommand.swift @@ -88,6 +88,15 @@ extension CodexBarCLI { return 100 - window.usedPercent } + static func guardRateWindow(_ window: GuardWindow, usage: UsageSnapshot) -> RateWindow? { + switch window { + case .session: + usage.primary + case .weekly: + usage.secondary + } + } + static func runGuard(_ values: ParsedValues) async { let output = CLIOutputPreferences.from(values: values) let json = values.flags.contains("json") @@ -289,7 +298,7 @@ extension CodexBarCLI { switch outcome.result { case let .success(result): let usage = result.usage.scoped(to: provider) - let rateWindow = window == .session ? usage.primary : usage.secondary + let rateWindow = Self.guardRateWindow(window, usage: usage) guard let remaining = Self.guardRemainingHeadroom(for: rateWindow) else { return .unavailable(.windowUnavailable) } diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index be230a092..ebf105ec2 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -797,6 +797,19 @@ enum CLIRenderer { tertiary: "Monthly", showsTertiary: true) } + if provider == .alibabatokenplan { + return RateWindowLabels( + primary: AlibabaTokenPlanProviderDescriptor.rateWindowLabel( + window: snapshot.primary, + fallback: metadata.sessionLabel), + secondary: AlibabaTokenPlanProviderDescriptor.rateWindowLabel( + window: snapshot.secondary, + fallback: metadata.weeklyLabel), + tertiary: AlibabaTokenPlanProviderDescriptor.rateWindowLabel( + window: snapshot.tertiary, + fallback: "Credits"), + showsTertiary: snapshot.tertiary != nil) + } let primaryLabel = if provider == .grok { GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel } else if provider == .sub2api { diff --git a/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift b/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift index 3b7ad1d20..1f1a65953 100644 --- a/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift +++ b/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift @@ -195,6 +195,18 @@ enum DashboardSnapshotBuilder { if provider == .factory, usage.tertiary != nil { return RateWindowLabels(primary: "5-hour", secondary: "Weekly", tertiary: "Monthly") } + if provider == .alibabatokenplan { + return RateWindowLabels( + primary: AlibabaTokenPlanProviderDescriptor.rateWindowLabel( + window: usage.primary, + fallback: metadata?.sessionLabel ?? "Credits"), + secondary: AlibabaTokenPlanProviderDescriptor.rateWindowLabel( + window: usage.secondary, + fallback: metadata?.weeklyLabel ?? "Usage"), + tertiary: AlibabaTokenPlanProviderDescriptor.rateWindowLabel( + window: usage.tertiary, + fallback: "Credits")) + } return RateWindowLabels( primary: metadata?.sessionLabel ?? "Session", diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift index ae7453f90..60c57d005 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift @@ -36,6 +36,76 @@ public enum AlibabaTokenPlanAPIRegion: String, CaseIterable, Sendable { } } + public var personalDashboardURL: URL { + switch self { + case .international: + URL( + string: "https://modelstudio.console.alibabacloud.com/ap-southeast-1/" + + "?tab=plan#/efm/subscription/token-plan/personal")! + case .chinaMainland: + URL( + string: "https://bailian.console.aliyun.com/cn-beijing" + + "?tab=plan#/efm/subscription/token-plan/personal")! + } + } + + public var consoleRPCBaseURLString: String { + switch self { + case .international: + "https://bailian-singapore-cs.alibabacloud.com" + case .chinaMainland: + "https://bailian-cs.console.aliyun.com" + } + } + + public var consoleRPCAction: String { + switch self { + case .international: + "IntlBroadScopeAspnGateway" + case .chinaMainland: + "BroadScopeAspnGateway" + } + } + + public var consoleRPCProduct: String { + "sfm_bailian" + } + + public var rateLimitAPIName: String { + "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage" + } + + public var rateLimitURL: URL { + var components = URLComponents(string: self.consoleRPCBaseURLString)! + components.path = "/data/api.json" + components.queryItems = [ + URLQueryItem(name: "action", value: self.consoleRPCAction), + URLQueryItem(name: "product", value: self.consoleRPCProduct), + URLQueryItem(name: "api", value: self.rateLimitAPIName), + URLQueryItem(name: "_v", value: "undefined"), + ] + return components.url! + } + + public var consoleDomain: String { + switch self { + case .international: + "modelstudio.console.alibabacloud.com" + case .chinaMainland: + "bailian.console.aliyun.com" + } + } + + public var consoleSite: String { + switch self { + case .international: + // This is Alibaba's live console contract, including its historical spelling. + "MODELSTUDIO_ALBABACLOUD" + case .chinaMainland: + "BAILIAN_ALIYUN" + } + } + public var currentRegionID: String { switch self { case .international: diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift index 14a99af97..50fa90afd 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift @@ -6,19 +6,24 @@ import FoundationNetworking struct AlibabaTokenPlanCookieHeaders { private static let cachedAPIHeaderName = "__codexbar_alibaba_token_plan_api" private static let cachedDashboardHeaderName = "__codexbar_alibaba_token_plan_dashboard" + private static let cachedRateLimitHeaderName = "__codexbar_alibaba_token_plan_rate_limit" + private static let missingCachedHeaderValue = "-" let apiCookieHeader: String let dashboardCookieHeader: String + let rateLimitCookieHeader: String? - init(apiCookieHeader: String, dashboardCookieHeader: String) { + init(apiCookieHeader: String, dashboardCookieHeader: String, rateLimitCookieHeader: String?) { self.apiCookieHeader = apiCookieHeader self.dashboardCookieHeader = dashboardCookieHeader + self.rateLimitCookieHeader = rateLimitCookieHeader } init?(singleHeader raw: String?) { guard let normalized = CookieHeaderNormalizer.normalize(raw) else { return nil } self.apiCookieHeader = normalized self.dashboardCookieHeader = normalized + self.rateLimitCookieHeader = normalized } init?(cachedHeader raw: String?) { @@ -28,22 +33,37 @@ struct AlibabaTokenPlanCookieHeaders { } if let encodedAPI = valuesByName[Self.cachedAPIHeaderName], let encodedDashboard = valuesByName[Self.cachedDashboardHeaderName], + let encodedRateLimit = valuesByName[Self.cachedRateLimitHeaderName], let apiHeader = Self.decodeCachedHeader(encodedAPI), let dashboardHeader = Self.decodeCachedHeader(encodedDashboard), let normalizedAPI = CookieHeaderNormalizer.normalize(apiHeader), let normalizedDashboard = CookieHeaderNormalizer.normalize(dashboardHeader) { - self.init(apiCookieHeader: normalizedAPI, dashboardCookieHeader: normalizedDashboard) + let normalizedRateLimit: String? + if encodedRateLimit == Self.missingCachedHeaderValue { + normalizedRateLimit = nil + } else { + guard let rateLimitHeader = Self.decodeCachedHeader(encodedRateLimit), + let normalized = CookieHeaderNormalizer.normalize(rateLimitHeader) + else { return nil } + normalizedRateLimit = normalized + } + self.init( + apiCookieHeader: normalizedAPI, + dashboardCookieHeader: normalizedDashboard, + rateLimitCookieHeader: normalizedRateLimit) return } - - self.init(singleHeader: raw) + return nil } var cacheCookieHeader: String { - [ + let encodedRateLimit = self.rateLimitCookieHeader.map(Self.encodeCachedHeader) ?? + Self.missingCachedHeaderValue + return [ "\(Self.cachedAPIHeaderName)=\(Self.encodeCachedHeader(self.apiCookieHeader))", "\(Self.cachedDashboardHeaderName)=\(Self.encodeCachedHeader(self.dashboardCookieHeader))", + "\(Self.cachedRateLimitHeaderName)=\(encodedRateLimit)", ].joined(separator: "; ") } @@ -55,9 +75,14 @@ struct AlibabaTokenPlanCookieHeaders { Self.cookieNames(from: self.dashboardCookieHeader) } + var rateLimitCookieNames: [String] { + Self.cookieNames(from: self.rateLimitCookieHeader ?? "") + } + func hasCookie(named name: String) -> Bool { Self.cookieNames(from: self.apiCookieHeader).contains(name) || - Self.cookieNames(from: self.dashboardCookieHeader).contains(name) + Self.cookieNames(from: self.dashboardCookieHeader).contains(name) || + Self.cookieNames(from: self.rateLimitCookieHeader ?? "").contains(name) } private static func cookieNames(from header: String) -> [String] { @@ -92,7 +117,15 @@ enum AlibabaTokenPlanCookieHeader { else { return nil } - return AlibabaTokenPlanCookieHeaders(apiCookieHeader: apiHeader, dashboardCookieHeader: dashboardHeader) + let rateLimitHeader = self.header( + from: cookies, + targetURL: AlibabaTokenPlanUsageFetcher.resolveRateLimitURL( + region: region, + environment: environment)) + return AlibabaTokenPlanCookieHeaders( + apiCookieHeader: apiHeader, + dashboardCookieHeader: dashboardHeader, + rateLimitCookieHeader: rateLimitHeader) } static func header(from cookies: [HTTPCookie], targetURL: URL) -> String? { diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift index ddf7970ad..d59bb6d8e 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift @@ -7,6 +7,19 @@ import SweetCookieKit public enum AlibabaTokenPlanProviderDescriptor { public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + public static func rateWindowLabel(window: RateWindow?, fallback: String) -> String { + switch window?.windowMinutes { + case 5 * 60: + "5-hour" + case 7 * 24 * 60: + "Weekly" + case 30 * 24 * 60: + "Credits" + default: + fallback + } + } + static func makeDescriptor() -> ProviderDescriptor { #if os(macOS) let browserOrder: BrowserCookieImportOrder = [ @@ -109,16 +122,20 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { let cookieSource = context.settings?.alibabaTokenPlan?.cookieSource ?? .auto let region = context.settings?.alibabaTokenPlan?.apiRegion ?? .international + let canRefreshImportedCookies = cookieSource != .manual && + AlibabaTokenPlanSettingsReader.cookieHeader(environment: context.env) == nil let cookieHeaders = try Self.resolveCookieHeaders(context: context, allowCached: true, region: region) do { let usage = try await AlibabaTokenPlanUsageFetcher.fetchUsage( apiCookieHeader: cookieHeaders.apiCookieHeader, dashboardCookieHeader: cookieHeaders.dashboardCookieHeader, + rateLimitCookieHeader: cookieHeaders.rateLimitCookieHeader, region: region, - environment: context.env) + environment: context.env, + propagateCredentialFailures: canRefreshImportedCookies) return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "web") } catch let error as AlibabaTokenPlanUsageError - where error.isCredentialFailure && cookieSource != .manual + where error.isCredentialFailure && canRefreshImportedCookies { #if os(macOS) CookieHeaderCache.clear(provider: .alibabatokenplan, scope: region.cookieCacheScope) @@ -126,8 +143,10 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { let usage = try await AlibabaTokenPlanUsageFetcher.fetchUsage( apiCookieHeader: refreshedHeaders.apiCookieHeader, dashboardCookieHeader: refreshedHeaders.dashboardCookieHeader, + rateLimitCookieHeader: refreshedHeaders.rateLimitCookieHeader, region: region, - environment: context.env) + environment: context.env, + propagateCredentialFailures: false) return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "web") #else throw error @@ -161,6 +180,7 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { metadata: [ "apiCookieNames": headers.apiCookieNames.joined(separator: ","), "dashboardCookieNames": headers.dashboardCookieNames.joined(separator: ","), + "rateLimitCookieNames": headers.rateLimitCookieNames.joined(separator: ","), "hasSecToken": headers.hasCookie(named: "sec_token") ? "1" : "0", ]) return headers @@ -174,6 +194,7 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { metadata: [ "apiCookieNames": headers.apiCookieNames.joined(separator: ","), "dashboardCookieNames": headers.dashboardCookieNames.joined(separator: ","), + "rateLimitCookieNames": headers.rateLimitCookieNames.joined(separator: ","), "hasSecToken": headers.hasCookie(named: "sec_token") ? "1" : "0", ]) return headers @@ -190,6 +211,7 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { "source": cached.sourceLabel, "apiCookieNames": headers.apiCookieNames.joined(separator: ","), "dashboardCookieNames": headers.dashboardCookieNames.joined(separator: ","), + "rateLimitCookieNames": headers.rateLimitCookieNames.joined(separator: ","), "hasSecToken": headers.hasCookie(named: "sec_token") ? "1" : "0", ]) return headers @@ -227,6 +249,7 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { "rawCookieNames": rawCookieNames.joined(separator: ","), "apiCookieNames": headers.apiCookieNames.joined(separator: ","), "dashboardCookieNames": headers.dashboardCookieNames.joined(separator: ","), + "rateLimitCookieNames": headers.rateLimitCookieNames.joined(separator: ","), "hasSecToken": headers.hasCookie(named: "sec_token") ? "1" : "0", "importLogLines": "\(importLog.count)", ]) @@ -281,14 +304,3 @@ extension [String] { Array(Set(self)).sorted() } } - -extension AlibabaTokenPlanUsageError { - fileprivate var isCredentialFailure: Bool { - switch self { - case .loginRequired, .invalidCredentials: - true - case .apiError, .networkError, .parseFailed: - false - } - } -} diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index 27aee5a63..0ed8a7071 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -24,13 +24,41 @@ public enum AlibabaTokenPlanUsageError: LocalizedError, Sendable, Equatable { "Could not parse Alibaba Token Plan usage: \(message)" } } + + var isCredentialFailure: Bool { + switch self { + case .loginRequired, .invalidCredentials: + true + case .apiError, .networkError, .parseFailed: + false + } + } } // swiftlint:disable:next type_body_length public struct AlibabaTokenPlanUsageFetcher: Sendable { + private struct SubscriptionSummaryContext: Sendable { + let url: URL + let cookieHeader: String + let secToken: String? + let region: AlibabaTokenPlanAPIRegion + let environment: [String: String] + let now: Date + } + + private struct RateLimitContext: Sendable { + let cookieHeader: String + let secToken: String + let region: AlibabaTokenPlanAPIRegion + let environment: [String: String] + let now: Date + } + private static let log = CodexBarLog.logger("alibaba-token-plan") private static let bssServiceCode = "BssOpenAPI-V3" private static let subscriptionSummaryAction = "GetSubscriptionSummary" + private static let rateLimitMergeGrace: Duration = .seconds(2) + private static let rateLimitFallbackGrace: Duration = .seconds(5) private static let browserLikeUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" @@ -59,6 +87,24 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return region.dashboardURL } + static func personalDashboardURL( + region: AlibabaTokenPlanAPIRegion, + environment: [String: String]) -> URL + { + guard let override = self.rateLimitOverrideBaseURL(environment: environment), + var components = URLComponents(url: override, resolvingAgainstBaseURL: false), + let personalComponents = URLComponents( + url: region.personalDashboardURL, + resolvingAgainstBaseURL: false) + else { + return region.personalDashboardURL + } + components.path = personalComponents.path + components.percentEncodedQuery = personalComponents.percentEncodedQuery + components.fragment = personalComponents.fragment + return components.url ?? region.personalDashboardURL + } + public static func fetchUsage( cookieHeader: String, region: AlibabaTokenPlanAPIRegion = .international, @@ -71,6 +117,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return try await self.fetchUsage( apiCookieHeader: headers.apiCookieHeader, dashboardCookieHeader: headers.dashboardCookieHeader, + rateLimitCookieHeader: headers.rateLimitCookieHeader, region: region, environment: environment, now: now) @@ -79,9 +126,11 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { static func fetchUsage( apiCookieHeader: String, dashboardCookieHeader: String, + rateLimitCookieHeader: String?, region: AlibabaTokenPlanAPIRegion = .international, environment: [String: String] = ProcessInfo.processInfo.environment, now: Date = Date(), + propagateCredentialFailures: Bool = false, session overrideSession: URLSession? = nil) async throws -> AlibabaTokenPlanUsageSnapshot { guard let normalizedAPIHeader = CookieHeaderNormalizer.normalize(apiCookieHeader), @@ -89,14 +138,18 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { else { throw AlibabaTokenPlanSettingsError.invalidCookie } + let normalizedRateLimitHeader = CookieHeaderNormalizer.normalize(rateLimitCookieHeader) let url = self.resolveQuotaURL(region: region, environment: environment) let apiRedirectDiagnostics = RedirectDiagnostics(cookieHeader: normalizedAPIHeader) + let rateLimitRedirectDiagnostics = RedirectDiagnostics(cookieHeader: normalizedRateLimitHeader ?? "") let dashboardRedirectDiagnostics: RedirectDiagnostics? let apiSession: URLSession + let rateLimitSession: URLSession let dashboardSession: URLSession if let overrideSession { apiSession = overrideSession + rateLimitSession = overrideSession dashboardSession = overrideSession dashboardRedirectDiagnostics = nil } else { @@ -105,6 +158,10 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { configuration: .default, delegate: apiRedirectDiagnostics, delegateQueue: nil) + rateLimitSession = URLSession( + configuration: .default, + delegate: rateLimitRedirectDiagnostics, + delegateQueue: nil) dashboardSession = URLSession( configuration: .default, delegate: dashboardDiagnostics, @@ -114,8 +171,20 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { defer { if overrideSession == nil { apiSession.invalidateAndCancel() + rateLimitSession.invalidateAndCancel() dashboardSession.invalidateAndCancel() } + if let dashboardRedirectDiagnostics { + Self.logRedirects( + dashboardRedirectDiagnostics.snapshot(), + message: "Alibaba Token Plan dashboard redirects") + } + Self.logRedirects( + apiRedirectDiagnostics.snapshot(), + message: "Alibaba Token Plan subscription-summary redirects") + Self.logRedirects( + rateLimitRedirectDiagnostics.snapshot(), + message: "Alibaba Token Plan rate-limit redirects") } let secToken = await self.resolveSECToken( dashboardCookieHeader: normalizedDashboardHeader, @@ -123,66 +192,145 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { region: region, environment: environment, session: dashboardSession) + let rateLimitTask: Task? = if let secToken, + let normalizedRateLimitHeader + { + Task { + try await self.fetchRateLimitUsage( + context: RateLimitContext( + cookieHeader: normalizedRateLimitHeader, + secToken: secToken, + region: region, + environment: environment, + now: now), + session: rateLimitSession) + } + } else { + nil + } + defer { + rateLimitTask?.cancel() + } Self.log.info( "Fetching Alibaba Token Plan usage", metadata: [ "apiHost": url.host ?? "unknown", "region": region.rawValue, "apiCookieNames": self.cookieNamesDescription(from: normalizedAPIHeader), + "rateLimitCookieNames": self.cookieNamesDescription(from: normalizedRateLimitHeader ?? ""), "dashboardCookieNames": self.cookieNamesDescription(from: normalizedDashboardHeader), "hasCSRF": self.hasCSRF(in: normalizedAPIHeader) ? "1" : "0", "secTokenSource": secToken == nil ? "missing" : "resolved", ]) - var request = URLRequest(url: url) + let subscriptionSnapshot: AlibabaTokenPlanUsageSnapshot + do { + subscriptionSnapshot = try await self.fetchSubscriptionSummary( + context: SubscriptionSummaryContext( + url: url, + cookieHeader: normalizedAPIHeader, + secToken: secToken, + region: region, + environment: environment, + now: now), + session: apiSession) + } catch { + if propagateCredentialFailures, + let usageError = error as? AlibabaTokenPlanUsageError, + usageError.isCredentialFailure + { + throw error + } + guard let rateLimitTask else { throw error } + switch await BoundedTaskJoin(sourceTask: rateLimitTask) + .value(joinGrace: self.rateLimitFallbackGrace) + { + case let .value(rateLimitSnapshot): + Self.log.info( + "Alibaba Token Plan subscription summary failed; using rate-limit usage", + metadata: ["error": error.localizedDescription]) + return rateLimitSnapshot + case let .failure(rateLimitError): + Self.log.info( + "Alibaba Token Plan rate-limit request also failed", + metadata: ["error": rateLimitError.localizedDescription]) + case .timedOut: + Self.log.info("Alibaba Token Plan rate-limit fallback timed out") + } + throw error + } + return try await self.mergeRateLimitTask( + rateLimitTask, + into: subscriptionSnapshot, + joinGrace: self.rateLimitMergeGrace, + propagateCredentialFailures: propagateCredentialFailures) + } + + static func mergeRateLimitTask( + _ rateLimitTask: Task?, + into subscriptionSnapshot: AlibabaTokenPlanUsageSnapshot, + joinGrace: Duration, + propagateCredentialFailures: Bool = false) async throws -> AlibabaTokenPlanUsageSnapshot + { + guard let rateLimitTask else { return subscriptionSnapshot } + switch await BoundedTaskJoin(sourceTask: rateLimitTask).value(joinGrace: joinGrace) { + case let .value(rateLimitSnapshot): + return rateLimitSnapshot.mergingSubscriptionSummary(subscriptionSnapshot) + case let .failure(error): + if propagateCredentialFailures, + let usageError = error as? AlibabaTokenPlanUsageError, + usageError.isCredentialFailure + { + throw error + } + Self.log.info( + "Alibaba Token Plan rate-limit request failed; using subscription summary", + metadata: ["error": error.localizedDescription]) + case .timedOut: + Self.log.info("Alibaba Token Plan rate-limit request timed out; using subscription summary") + } + return subscriptionSnapshot + } + + private static func fetchSubscriptionSummary( + context: SubscriptionSummaryContext, + session: URLSession) async throws -> AlibabaTokenPlanUsageSnapshot + { + var request = URLRequest(url: context.url) request.httpMethod = "POST" request.timeoutInterval = 20 - request.httpBody = self.subscriptionSummaryRequestBody(region: region, secToken: secToken) + request.httpBody = self.subscriptionSummaryRequestBody( + region: context.region, + secToken: context.secToken) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.setValue("*/*", forHTTPHeaderField: "Accept") - request.setValue(normalizedAPIHeader, forHTTPHeaderField: "Cookie") - if let csrf = self.extractCookieValue(name: "login_aliyunid_csrf", from: normalizedAPIHeader) ?? - self.extractCookieValue(name: "csrf", from: normalizedAPIHeader) + request.setValue(context.cookieHeader, forHTTPHeaderField: "Cookie") + if let csrf = self.extractCookieValue(name: "login_aliyunid_csrf", from: context.cookieHeader) ?? + self.extractCookieValue(name: "csrf", from: context.cookieHeader) { request.setValue(csrf, forHTTPHeaderField: "x-xsrf-token") request.setValue(csrf, forHTTPHeaderField: "x-csrf-token") } request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") request.setValue(Self.browserLikeUserAgent, forHTTPHeaderField: "User-Agent") - request.setValue(region.dashboardOriginURLString, forHTTPHeaderField: "Origin") + request.setValue(context.region.dashboardOriginURLString, forHTTPHeaderField: "Origin") request.setValue( - Self.dashboardURL(region: region, environment: environment).absoluteString, + Self.dashboardURL(region: context.region, environment: context.environment).absoluteString, forHTTPHeaderField: "Referer") let data: Data let response: URLResponse do { - (data, response) = try await apiSession.data(for: request) + (data, response) = try await session.data(for: request) } catch { Self.log.error( "Alibaba Token Plan request failed", metadata: [ - "apiHost": url.host ?? "unknown", + "apiHost": context.url.host ?? "unknown", "error": error.localizedDescription, ]) throw AlibabaTokenPlanUsageError.networkError(error.localizedDescription) } - if let dashboardRedirectDiagnostics, !dashboardRedirectDiagnostics.redirects.isEmpty { - Self.log.info( - "Alibaba Token Plan dashboard redirects", - metadata: [ - "count": "\(dashboardRedirectDiagnostics.redirects.count)", - "items": dashboardRedirectDiagnostics.redirects.joined(separator: " | "), - ]) - } - if !apiRedirectDiagnostics.redirects.isEmpty { - Self.log.info( - "Alibaba Token Plan redirects", - metadata: [ - "count": "\(apiRedirectDiagnostics.redirects.count)", - "items": apiRedirectDiagnostics.redirects.joined(separator: " | "), - ]) - } guard let httpResponse = response as? HTTPURLResponse else { Self.log.error("Alibaba Token Plan response was not HTTP") throw AlibabaTokenPlanUsageError.networkError("Invalid response") @@ -202,7 +350,159 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { throw AlibabaTokenPlanUsageError.apiError("HTTP \(httpResponse.statusCode)") } - return try self.parseUsageSnapshot(from: data, now: now) + return try self.parseUsageSnapshot(from: data, now: context.now) + } + + private static func fetchRateLimitUsage( + context: RateLimitContext, + session: URLSession) async throws -> AlibabaTokenPlanUsageSnapshot + { + let url = self.resolveRateLimitURL( + region: context.region, + environment: context.environment) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 20 + request.httpBody = self.rateLimitRequestBody( + cookieHeader: context.cookieHeader, + secToken: context.secToken, + region: context.region, + environment: context.environment) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("*/*", forHTTPHeaderField: "Accept") + request.setValue(context.cookieHeader, forHTTPHeaderField: "Cookie") + if let csrf = self.extractCookieValue(name: "login_aliyunid_csrf", from: context.cookieHeader) ?? + self.extractCookieValue(name: "csrf", from: context.cookieHeader) + { + request.setValue(csrf, forHTTPHeaderField: "x-xsrf-token") + request.setValue(csrf, forHTTPHeaderField: "x-csrf-token") + } + request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") + request.setValue(Self.browserLikeUserAgent, forHTTPHeaderField: "User-Agent") + request.setValue( + self.rateLimitOriginURLString( + region: context.region, + environment: context.environment), + forHTTPHeaderField: "Origin") + request.setValue( + self.personalDashboardURL( + region: context.region, + environment: context.environment).absoluteString, + forHTTPHeaderField: "Referer") + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw AlibabaTokenPlanUsageError.networkError(error.localizedDescription) + } + guard let httpResponse = response as? HTTPURLResponse else { + throw AlibabaTokenPlanUsageError.networkError("Invalid rate-limit response") + } + guard httpResponse.statusCode == 200 else { + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw AlibabaTokenPlanUsageError.loginRequired + } + throw AlibabaTokenPlanUsageError.apiError("HTTP \(httpResponse.statusCode)") + } + return try self.parseRateLimitUsageSnapshot(from: data, now: context.now) + } + + private static func rateLimitRequestBody( + cookieHeader: String, + secToken: String, + region: AlibabaTokenPlanAPIRegion, + environment: [String: String]) -> Data + { + let traceID = UUID().uuidString.lowercased() + var cornerstoneParam: [String: Any] = [ + "feTraceId": traceID, + "feURL": self.personalDashboardURL(region: region, environment: environment).absoluteString, + "protocol": "V2", + "console": "ONE_CONSOLE", + "productCode": "p_efm", + "switchAgent": 1_233_135, + "switchUserType": 3, + "domain": region.consoleDomain, + "consoleSite": region.consoleSite, + "userNickName": "", + "userPrincipalName": "", + "xsp_lang": "en-US", + ] + if let anonymousID = self.extractCookieValue(name: "cna", from: cookieHeader), + !anonymousID.isEmpty + { + cornerstoneParam["X-Anonymous-Id"] = anonymousID + } + let paramsObject: [String: Any] = [ + "Api": region.rateLimitAPIName, + "V": "1.0", + "Data": [ + "cornerstoneParam": cornerstoneParam, + ], + ] + guard let paramsData = try? JSONSerialization.data(withJSONObject: paramsObject, options: []), + let paramsString = String(data: paramsData, encoding: .utf8) + else { + return Data() + } + var components = URLComponents() + components.queryItems = [ + URLQueryItem(name: "params", value: paramsString), + URLQueryItem(name: "region", value: region.currentRegionID), + URLQueryItem(name: "sec_token", value: secToken), + ] + return Data((components.percentEncodedQuery ?? "").utf8) + } + + static func resolveRateLimitURL( + region: AlibabaTokenPlanAPIRegion, + environment: [String: String]) -> URL + { + guard let override = self.rateLimitOverrideBaseURL(environment: environment), + var components = URLComponents(url: override, resolvingAgainstBaseURL: false), + let rateLimitComponents = URLComponents( + url: region.rateLimitURL, + resolvingAgainstBaseURL: false) + else { + return region.rateLimitURL + } + components.path = rateLimitComponents.path + components.percentEncodedQuery = rateLimitComponents.percentEncodedQuery + components.fragment = rateLimitComponents.fragment + return components.url ?? region.rateLimitURL + } + + static func rateLimitOriginURLString( + region: AlibabaTokenPlanAPIRegion, + environment: [String: String]) -> String + { + guard self.rateLimitOverrideBaseURL(environment: environment) != nil else { + return region.dashboardOriginURLString + } + return self.originURLString(for: self.resolveRateLimitURL( + region: region, + environment: environment)) ?? region.dashboardOriginURLString + } + + private static func rateLimitOverrideBaseURL(environment: [String: String]) -> URL? { + if let quotaURL = AlibabaTokenPlanSettingsReader.quotaURL(environment: environment) { + return quotaURL + } + guard let host = AlibabaTokenPlanSettingsReader.hostOverride(environment: environment) else { + return nil + } + return ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: host) + } + + private static func originURLString(for url: URL) -> String? { + guard let scheme = url.scheme, let host = url.host else { return nil } + var components = URLComponents() + components.scheme = scheme + components.host = host + components.port = url.port + return components.string } static func resolveQuotaURL( @@ -280,6 +580,65 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { updatedAt: now) } + static func parseRateLimitUsageSnapshot( + from data: Data, + now: Date = Date()) throws -> AlibabaTokenPlanUsageSnapshot + { + guard !data.isEmpty else { + throw AlibabaTokenPlanUsageError.parseFailed("Empty response body") + } + let object: Any + do { + object = try JSONSerialization.jsonObject(with: data, options: []) + } catch { + if self.isLikelyLoginHTML(data) { + throw AlibabaTokenPlanUsageError.loginRequired + } + throw AlibabaTokenPlanUsageError.parseFailed("Invalid JSON response") + } + let expanded = self.expandedJSON(object) + guard let dictionary = expanded as? [String: Any] else { + throw AlibabaTokenPlanUsageError.parseFailed("Unexpected payload") + } + try self.throwIfErrorPayload(dictionary) + + let rateKeys = [ + "per5HourPercentage", + "per1WeekPercentage", + "per5HourResetTime", + "per1WeekResetTime", + ] + guard let usage = self.findFirstDictionary(matchingAnyKey: rateKeys, in: dictionary) else { + throw AlibabaTokenPlanUsageError.parseFailed("Missing rate-limit usage") + } + let fiveHour = self.normalizedUsedPercent(self.parseDouble(usage["per5HourPercentage"])) + let sevenDay = self.normalizedUsedPercent(self.parseDouble(usage["per1WeekPercentage"])) + guard fiveHour != nil || sevenDay != nil else { + throw AlibabaTokenPlanUsageError.parseFailed("Missing rate-limit usage") + } + + return AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: nil, + totalQuota: nil, + remainingQuota: nil, + resetsAt: nil, + fiveHourUsedPercent: fiveHour, + fiveHourResetsAt: self.parseDate(usage["per5HourResetTime"]), + sevenDayUsedPercent: sevenDay, + sevenDayResetsAt: self.parseDate(usage["per1WeekResetTime"]), + updatedAt: now) + } + + private static func normalizedUsedPercent(_ raw: Double?) -> Double? { + guard let raw, raw >= 0 else { return nil } + if raw <= 1 { + return raw * 100 + } + guard raw <= 100 else { return nil } + return raw + } + private static func subscriptionSummaryRequestBody(region: AlibabaTokenPlanAPIRegion, secToken: String?) -> Data { let paramsObject = ["ProductCode": region.tokenPlanProductCode] guard let paramsData = try? JSONSerialization.data(withJSONObject: paramsObject, options: []), @@ -426,7 +785,8 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { private final class RedirectDiagnostics: NSObject, URLSessionTaskDelegate, @unchecked Sendable { private let cookieHeader: String - var redirects: [String] = [] + private let lock = NSLock() + private var redirects: [String] = [] init(cookieHeader: String) { self.cookieHeader = cookieHeader @@ -441,13 +801,29 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { { let from = AlibabaTokenPlanUsageFetcher.redactedURLDescription(response.url) let to = AlibabaTokenPlanUsageFetcher.redactedURLDescription(request.url) - self.redirects.append("\(response.statusCode) \(from) -> \(to)") + self.lock.withLock { + self.redirects.append("\(response.statusCode) \(from) -> \(to)") + } completionHandler(AlibabaTokenPlanUsageFetcher.redirectedRequest( response: response, request: request, cookieHeader: self.cookieHeader)) } + + func snapshot() -> [String] { + self.lock.withLock { self.redirects } + } + } + + private static func logRedirects(_ redirects: [String], message: String) { + guard !redirects.isEmpty else { return } + self.log.info( + "\(message)", + metadata: [ + "count": "\(redirects.count)", + "items": redirects.joined(separator: " | "), + ]) } private static func redactedURLDescription(_ url: URL?) -> String { diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift index 60d8b2870..af25abf12 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift @@ -6,6 +6,10 @@ public struct AlibabaTokenPlanUsageSnapshot: Sendable { public let totalQuota: Double? public let remainingQuota: Double? public let resetsAt: Date? + public let fiveHourUsedPercent: Double? + public let fiveHourResetsAt: Date? + public let sevenDayUsedPercent: Double? + public let sevenDayResetsAt: Date? public let updatedAt: Date public init( @@ -14,6 +18,10 @@ public struct AlibabaTokenPlanUsageSnapshot: Sendable { totalQuota: Double?, remainingQuota: Double?, resetsAt: Date?, + fiveHourUsedPercent: Double? = nil, + fiveHourResetsAt: Date? = nil, + sevenDayUsedPercent: Double? = nil, + sevenDayResetsAt: Date? = nil, updatedAt: Date) { self.planName = planName @@ -21,13 +29,31 @@ public struct AlibabaTokenPlanUsageSnapshot: Sendable { self.totalQuota = totalQuota self.remainingQuota = remainingQuota self.resetsAt = resetsAt + self.fiveHourUsedPercent = fiveHourUsedPercent + self.fiveHourResetsAt = fiveHourResetsAt + self.sevenDayUsedPercent = sevenDayUsedPercent + self.sevenDayResetsAt = sevenDayResetsAt self.updatedAt = updatedAt } } extension AlibabaTokenPlanUsageSnapshot { + func mergingSubscriptionSummary(_ summary: Self) -> Self { + Self( + planName: summary.planName ?? self.planName, + usedQuota: summary.usedQuota, + totalQuota: summary.totalQuota, + remainingQuota: summary.remainingQuota, + resetsAt: summary.resetsAt, + fiveHourUsedPercent: self.fiveHourUsedPercent, + fiveHourResetsAt: self.fiveHourResetsAt, + sevenDayUsedPercent: self.sevenDayUsedPercent, + sevenDayResetsAt: self.sevenDayResetsAt, + updatedAt: max(self.updatedAt, summary.updatedAt)) + } + public func toUsageSnapshot() -> UsageSnapshot { - let primary: RateWindow? = Self.usedPercent( + let monthlyCredits: RateWindow? = Self.usedPercent( used: self.usedQuota, total: self.totalQuota, remaining: self.remainingQuota).map { @@ -40,6 +66,27 @@ extension AlibabaTokenPlanUsageSnapshot { total: self.totalQuota, remaining: self.remainingQuota)) } + let fiveHour = self.fiveHourUsedPercent.map { + RateWindow( + usedPercent: $0, + windowMinutes: 5 * 60, + resetsAt: self.fiveHourResetsAt, + resetDescription: nil) + } + let sevenDay = self.sevenDayUsedPercent.map { + RateWindow( + usedPercent: $0, + windowMinutes: 7 * 24 * 60, + resetsAt: self.sevenDayResetsAt, + resetDescription: nil) + } + // Keep the rolling windows in their semantic lanes even when Alibaba + // returns a partial response. Consumers such as CLI guard and dashboard + // JSON interpret primary as session and secondary as weekly. + let hasRollingWindows = fiveHour != nil || sevenDay != nil + let primary = hasRollingWindows ? fiveHour : monthlyCredits + let secondary = hasRollingWindows ? sevenDay : nil + let tertiary = hasRollingWindows ? monthlyCredits : nil let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) let loginMethod = (planName?.isEmpty ?? true) ? nil : planName @@ -51,8 +98,8 @@ extension AlibabaTokenPlanUsageSnapshot { return UsageSnapshot( primary: primary, - secondary: nil, - tertiary: nil, + secondary: secondary, + tertiary: tertiary, providerCost: nil, alibabaTokenPlanUsage: self, updatedAt: self.updatedAt, diff --git a/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift index 9975e9368..bbce85e35 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift @@ -4,6 +4,84 @@ import Testing @testable import CodexBar struct AlibabaTokenPlanMenuCardModelTests { + @Test + func `weekly only rate limit keeps its label`() throws { + let now = Date(timeIntervalSince1970: 10_368_000) + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: nil, + totalQuota: nil, + remainingQuota: nil, + resetsAt: nil, + sevenDayUsedPercent: 12.5, + sevenDayResetsAt: now.addingTimeInterval(6 * 24 * 3600), + updatedAt: now) + .toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.alibabatokenplan]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .alibabatokenplan, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Weekly"]) + } + + @Test + func `rate limits use five hour and weekly labels`() throws { + let now = Date(timeIntervalSince1970: 10_368_000) + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: 250, + totalQuota: 1000, + remainingQuota: 750, + resetsAt: now.addingTimeInterval(30 * 24 * 3600), + fiveHourUsedPercent: 7.69, + fiveHourResetsAt: now.addingTimeInterval(3600), + sevenDayUsedPercent: 2.61, + sevenDayResetsAt: now.addingTimeInterval(6 * 24 * 3600), + updatedAt: now) + .toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.alibabatokenplan]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .alibabatokenplan, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["5-hour", "Weekly", "Credits"]) + } + @Test func `monthly quota shows deficit and run out details`() throws { let now = Date(timeIntervalSince1970: 10_368_000) // 1970-05-01T00:00:00Z diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index 1db85ef8a..e1f334e97 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -66,6 +66,83 @@ struct AlibabaTokenPlanSettingsReaderTests { #expect(url.absoluteString.contains("GetSubscriptionSummary")) #expect(url.absoluteString.contains("BssOpenAPI-V3")) } + + @Test + func `host override also routes rate limit requests`() { + let environment = [ + AlibabaTokenPlanSettingsReader.hostKey: "https://token-plan.test:9443", + ] + let url = AlibabaTokenPlanUsageFetcher.resolveRateLimitURL( + region: .international, + environment: environment) + + #expect(url.host == "token-plan.test") + #expect(url.port == 9443) + #expect(url.path == AlibabaTokenPlanAPIRegion.international.rateLimitURL.path) + #expect(url.query == AlibabaTokenPlanAPIRegion.international.rateLimitURL.query) + #expect(AlibabaTokenPlanUsageFetcher.rateLimitOriginURLString( + region: .international, + environment: environment) == "https://token-plan.test:9443") + } + + @Test + func `quota URL override contains rate limit API traffic`() { + let quotaOverride = "https://quota.token-plan.test:8443/custom/summary" + let environment = [ + AlibabaTokenPlanSettingsReader.quotaURLKey: quotaOverride, + ] + let url = AlibabaTokenPlanUsageFetcher.resolveRateLimitURL( + region: .international, + environment: environment) + + #expect(url.host == "quota.token-plan.test") + #expect(url.port == 8443) + #expect(url.path == AlibabaTokenPlanAPIRegion.international.rateLimitURL.path) + #expect(url.query == AlibabaTokenPlanAPIRegion.international.rateLimitURL.query) + #expect(AlibabaTokenPlanUsageFetcher.rateLimitOriginURLString( + region: .international, + environment: environment) == "https://quota.token-plan.test:8443") + + let urlWithHostOverride = AlibabaTokenPlanUsageFetcher.resolveRateLimitURL( + region: .international, + environment: [ + AlibabaTokenPlanSettingsReader.quotaURLKey: quotaOverride, + AlibabaTokenPlanSettingsReader.hostKey: "https://dashboard.token-plan.test", + ]) + #expect(urlWithHostOverride.host == "quota.token-plan.test") + } + + @Test + func `production rate limit request preserves dashboard origin`() { + #expect(AlibabaTokenPlanUsageFetcher.rateLimitOriginURLString( + region: .international, + environment: [:]) == AlibabaTokenPlanAPIRegion.international.dashboardOriginURLString) + #expect(AlibabaTokenPlanUsageFetcher.rateLimitOriginURLString( + region: .chinaMainland, + environment: [:]) == AlibabaTokenPlanAPIRegion.chinaMainland.dashboardOriginURLString) + } + + @Test + func `rate limit metadata uses personal dashboard page`() { + #expect(AlibabaTokenPlanUsageFetcher.personalDashboardURL( + region: .international, + environment: [:]) == AlibabaTokenPlanAPIRegion.international.personalDashboardURL) + #expect(AlibabaTokenPlanUsageFetcher.personalDashboardURL( + region: .chinaMainland, + environment: [:]) == AlibabaTokenPlanAPIRegion.chinaMainland.personalDashboardURL) + + let override = AlibabaTokenPlanUsageFetcher.personalDashboardURL( + region: .international, + environment: [ + AlibabaTokenPlanSettingsReader.quotaURLKey: "https://quota.token-plan.test:8443/custom/summary", + AlibabaTokenPlanSettingsReader.hostKey: "https://dashboard.token-plan.test", + ]) + #expect(override.host == "quota.token-plan.test") + #expect(override.port == 8443) + #expect(override.path == AlibabaTokenPlanAPIRegion.international.personalDashboardURL.path) + #expect(override.query == AlibabaTokenPlanAPIRegion.international.personalDashboardURL.query) + #expect(override.fragment == AlibabaTokenPlanAPIRegion.international.personalDashboardURL.fragment) + } } struct AlibabaTokenPlanCookieHeaderTests { @@ -76,10 +153,15 @@ struct AlibabaTokenPlanCookieHeaderTests { self.cookie(name: "login_current_pk", value: "account", domain: ".alibabacloud.com"), self.cookie(name: "sec_token", value: "shared", domain: ".console.alibabacloud.com"), self.cookie(name: "sec_token", value: "dashboard", domain: "modelstudio.console.alibabacloud.com"), + self.cookie( + name: "rpc_only", + value: "rpc", + domain: "bailian-singapore-cs.alibabacloud.com"), self.cookie(name: "bailian_only", value: "bailian", domain: "bailian.console.aliyun.com"), ] let headers = try #require(AlibabaTokenPlanCookieHeader.headers(from: cookies)) + let rateLimitHeader = try #require(headers.rateLimitCookieHeader) #expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket")) #expect(headers.apiCookieHeader.contains("login_current_pk=account")) @@ -87,6 +169,9 @@ struct AlibabaTokenPlanCookieHeaderTests { #expect(!headers.apiCookieHeader.contains("bailian_only=bailian")) #expect(headers.dashboardCookieHeader.contains("sec_token=dashboard")) #expect(!headers.dashboardCookieHeader.contains("bailian_only=bailian")) + #expect(rateLimitHeader.contains("login_aliyunid_ticket=ticket")) + #expect(rateLimitHeader.contains("rpc_only=rpc")) + #expect(!rateLimitHeader.contains("sec_token=dashboard")) } @Test @@ -96,10 +181,12 @@ struct AlibabaTokenPlanCookieHeaderTests { self.cookie(name: "login_current_pk", value: "account", domain: ".aliyun.com"), self.cookie(name: "sec_token", value: "shared", domain: ".console.aliyun.com"), self.cookie(name: "sec_token", value: "dashboard", domain: "bailian.console.aliyun.com"), + self.cookie(name: "rpc_only", value: "rpc", domain: "bailian-cs.console.aliyun.com"), self.cookie(name: "modelstudio_only", value: "modelstudio", domain: "modelstudio.console.alibabacloud.com"), ] let headers = try #require(AlibabaTokenPlanCookieHeader.headers(from: cookies, region: .chinaMainland)) + let rateLimitHeader = try #require(headers.rateLimitCookieHeader) #expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket")) #expect(headers.apiCookieHeader.contains("login_current_pk=account")) @@ -107,13 +194,16 @@ struct AlibabaTokenPlanCookieHeaderTests { #expect(!headers.apiCookieHeader.contains("modelstudio_only=modelstudio")) #expect(headers.dashboardCookieHeader.contains("sec_token=dashboard")) #expect(!headers.dashboardCookieHeader.contains("modelstudio_only=modelstudio")) + #expect(rateLimitHeader.contains("rpc_only=rpc")) + #expect(!rateLimitHeader.contains("sec_token=dashboard")) } @Test func `cached token plan headers preserve URL scoping`() throws { let headers = AlibabaTokenPlanCookieHeaders( apiCookieHeader: "login_aliyunid_ticket=ticket; api_only=api", - dashboardCookieHeader: "login_aliyunid_ticket=ticket; dashboard_only=dashboard") + dashboardCookieHeader: "login_aliyunid_ticket=ticket; dashboard_only=dashboard", + rateLimitCookieHeader: "login_aliyunid_ticket=ticket; rpc_only=rpc") let cached = try #require(AlibabaTokenPlanCookieHeaders(cachedHeader: headers.cacheCookieHeader)) @@ -121,6 +211,26 @@ struct AlibabaTokenPlanCookieHeaderTests { #expect(!cached.apiCookieHeader.contains("dashboard_only=dashboard")) #expect(cached.dashboardCookieHeader.contains("dashboard_only=dashboard")) #expect(!cached.dashboardCookieHeader.contains("api_only=api")) + #expect(cached.rateLimitCookieHeader?.contains("rpc_only=rpc") == true) + #expect(cached.rateLimitCookieHeader?.contains("api_only=api") == false) + } + + @Test + func `missing RPC cookies do not discard summary headers`() throws { + let cookies = [ + self.cookie( + name: "summary_only", + value: "summary", + domain: "modelstudio.console.alibabacloud.com"), + ] + + let headers = try #require(AlibabaTokenPlanCookieHeader.headers(from: cookies)) + let cached = try #require(AlibabaTokenPlanCookieHeaders(cachedHeader: headers.cacheCookieHeader)) + + #expect(headers.apiCookieHeader.contains("summary_only=summary")) + #expect(headers.dashboardCookieHeader.contains("summary_only=summary")) + #expect(headers.rateLimitCookieHeader == nil) + #expect(cached.rateLimitCookieHeader == nil) } @Test @@ -148,6 +258,8 @@ struct AlibabaTokenPlanCookieHeaderTests { #expect(!headers.apiCookieHeader.contains("prod_api_only=prod-api")) #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) #expect(!headers.dashboardCookieHeader.contains("prod_dashboard_only=prod-dashboard")) + #expect(headers.rateLimitCookieHeader?.contains("api_only=api") == true) + #expect(headers.rateLimitCookieHeader?.contains("prod_api_only=prod-api") == false) } private func cookie( @@ -204,10 +316,125 @@ struct AlibabaTokenPlanUsageSnapshotTests { #expect(usage.primary == nil) #expect(usage.loginMethod(for: .alibabatokenplan) == "TOKEN PLAN") } + + @Test + func `rate windows merge with the subscription plan name`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let rate = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: nil, + totalQuota: nil, + remainingQuota: nil, + resetsAt: nil, + fiveHourUsedPercent: 25, + updatedAt: now) + let summary = AlibabaTokenPlanUsageSnapshot( + planName: "Bailian Pro", + usedQuota: 100, + totalQuota: 1000, + remainingQuota: 900, + resetsAt: nil, + updatedAt: now) + + let merged = rate.mergingSubscriptionSummary(summary) + + #expect(merged.planName == "Bailian Pro") + #expect(merged.fiveHourUsedPercent == 25) + #expect(merged.remainingQuota == 900) + } } @Suite(.serialized) struct AlibabaTokenPlanUsageParsingTests { + @Test + func `parses token plan rate windows with millisecond resets`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "code": "200", + "data": { + "DataV2": { + "data": { + "success": true, + "data": { + "per5HourPercentage": 0.0769, + "per5HourResetTime": 1700100000000, + "per1WeekPercentage": 0.0261, + "per1WeekResetTime": 1700200000000 + } + }, + "success": true, + "httpStatus": 200 + } + }, + "successResponse": true + } + """ + + let snapshot = try AlibabaTokenPlanUsageFetcher.parseRateLimitUsageSnapshot( + from: Data(json.utf8), + now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(abs((usage.primary?.usedPercent ?? -.infinity) - 7.69) < 0.000_001) + #expect(usage.primary?.windowMinutes == 5 * 60) + #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_700_100_000)) + #expect(abs((usage.secondary?.usedPercent ?? -.infinity) - 2.61) < 0.000_001) + #expect(usage.secondary?.windowMinutes == 7 * 24 * 60) + #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_700_200_000)) + #expect(usage.loginMethod(for: .alibabatokenplan) == "TOKEN PLAN") + } + + @Test + func `preserves a weekly only rate window`() throws { + let json = """ + { + "code": "200", + "data": { + "DataV2": { + "data": { + "success": true, + "data": { + "per1WeekPercentage": 0.125, + "per1WeekResetTime": 1700200000000 + } + } + } + }, + "successResponse": true + } + """ + + let snapshot = try AlibabaTokenPlanUsageFetcher.parseRateLimitUsageSnapshot(from: Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.secondary?.usedPercent == 12.5) + #expect(usage.secondary?.windowMinutes == 7 * 24 * 60) + #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_700_200_000)) + #expect(usage.tertiary == nil) + } + + @Test + func `partial rolling response preserves weekly and credits lanes`() { + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "Bailian Pro", + usedQuota: 250, + totalQuota: 1000, + remainingQuota: 750, + resetsAt: nil, + sevenDayUsedPercent: 12.5, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.secondary?.usedPercent == 12.5) + #expect(usage.secondary?.windowMinutes == 7 * 24 * 60) + #expect(usage.tertiary?.usedPercent == 25) + #expect(usage.tertiary?.windowMinutes == 30 * 24 * 60) + } + @Test func `parses subscription summary payload`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) @@ -415,6 +642,16 @@ struct AlibabaTokenPlanUsageParsingTests { return Self.makeResponse(url: url, body: json, statusCode: 200) } + if url.host == "alibaba-token-plan.test", + request.httpMethod == "POST", + URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .contains(where: { $0.name == "api" }) == true + { + #expect(url.port == 9443) + return Self.makeResponse(url: url, body: "unavailable", statusCode: 500) + } + if url.host == "alibaba-token-plan.test", request.httpMethod == "POST" { #expect(request.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket; raw_only=keep") #expect(request.value(forHTTPHeaderField: "Origin") == "https://modelstudio.console.alibabacloud.com") @@ -447,12 +684,387 @@ struct AlibabaTokenPlanUsageParsingTests { let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( apiCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep", dashboardCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep", + rateLimitCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep", environment: environment, session: session) #expect(snapshot.planName == "TOKEN PLAN") } + @Test + func `fetches authenticated rate windows and merges credit summary`() async throws { + defer { + AlibabaTokenPlanStubURLProtocol.handler = nil + } + let environment = [ + AlibabaTokenPlanSettingsReader.hostKey: "https://rate-limit.test", + ] + let expectedReferer = AlibabaTokenPlanUsageFetcher.personalDashboardURL( + region: .international, + environment: environment).absoluteString + + AlibabaTokenPlanStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + if url.host == "rate-limit.test", request.httpMethod == "GET" { + return Self.makeResponse( + url: url, + body: "", + statusCode: 200) + } + + if url.host == "rate-limit.test", + request.httpMethod == "POST", + URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "api" })? + .value == "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage" + { + #expect(request.value(forHTTPHeaderField: "Origin") == + "https://rate-limit.test") + #expect(request.value(forHTTPHeaderField: "Referer") == + expectedReferer) + let body = Self.requestBodyString(from: request) + #expect(body.contains("sec_token=session-token")) + #expect(body.contains("params=")) + #expect(body.contains("region=ap-southeast-1")) + let params = try #require(Self.requestParamsDictionary(from: body)) + let data = try #require(params["Data"] as? [String: Any]) + let cornerstone = try #require(data["cornerstoneParam"] as? [String: Any]) + #expect(cornerstone["feURL"] as? String == expectedReferer) + let json = """ + { + "code": "200", + "data": { + "DataV2": { + "data": { + "success": true, + "data": { + "per5HourPercentage": 0.0769, + "per5HourResetTime": 1700100000000, + "per1WeekPercentage": 0.0261, + "per1WeekResetTime": 1700200000000 + } + }, + "success": true, + "httpStatus": 200 + } + }, + "successResponse": true + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + if url.host == "rate-limit.test", request.httpMethod == "POST" { + let body = Self.requestBodyString(from: request) + #expect(body.contains("GetSubscriptionSummary")) + let json = """ + { + "Success": true, + "Data": { + "TotalCount": 1, + "TotalValue": 1000, + "TotalSurplusValue": 900 + } + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + throw URLError(.unsupportedURL) + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] + let session = URLSession(configuration: configuration) + let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=ticket", + dashboardCookieHeader: "login_aliyunid_ticket=ticket", + rateLimitCookieHeader: "login_aliyunid_ticket=ticket", + environment: environment, + session: session) + let usage = snapshot.toUsageSnapshot() + + #expect(abs((usage.primary?.usedPercent ?? -.infinity) - 7.69) < 0.000_001) + #expect(usage.primary?.windowMinutes == 300) + #expect(abs((usage.secondary?.usedPercent ?? -.infinity) - 2.61) < 0.000_001) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.tertiary?.usedPercent == 10) + #expect(usage.tertiary?.windowMinutes == 30 * 24 * 60) + } + + @Test + func `keeps rate windows when credit summary fails`() async throws { + defer { + AlibabaTokenPlanStubURLProtocol.handler = nil + } + + AlibabaTokenPlanStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + if url.host == "rate-limit.test", request.httpMethod == "GET" { + return Self.makeResponse( + url: url, + body: "", + statusCode: 200) + } + + if url.host == "rate-limit.test", + request.httpMethod == "POST", + URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .contains(where: { $0.name == "api" }) == true + { + let json = """ + { + "code": "200", + "data": { + "DataV2": { + "data": { + "success": true, + "data": { + "per5HourPercentage": 0.25, + "per1WeekPercentage": 0.5 + } + } + } + }, + "successResponse": true + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + if url.host == "rate-limit.test", request.httpMethod == "POST" { + return Self.makeResponse(url: url, body: "unavailable", statusCode: 500) + } + + throw URLError(.unsupportedURL) + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] + let session = URLSession(configuration: configuration) + let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=ticket", + dashboardCookieHeader: "login_aliyunid_ticket=ticket", + rateLimitCookieHeader: "login_aliyunid_ticket=ticket", + environment: [AlibabaTokenPlanSettingsReader.hostKey: "https://rate-limit.test"], + session: session) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary?.usedPercent == 50) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.tertiary == nil) + } + + @Test + func `rate endpoint rejection propagates before cached cookie refresh`() async { + defer { + AlibabaTokenPlanStubURLProtocol.handler = nil + } + AlibabaTokenPlanStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if request.httpMethod == "GET" { + return Self.makeResponse( + url: url, + body: "", + statusCode: 200) + } + if URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .contains(where: { $0.name == "api" }) == true + { + return Self.makeResponse(url: url, body: "expired", statusCode: 401) + } + return Self.makeResponse( + url: url, + body: """ + { + "Success": true, + "Data": { + "TotalCount": 1, + "TotalValue": 1000, + "TotalSurplusValue": 900 + } + } + """, + statusCode: 200) + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] + let session = URLSession(configuration: configuration) + + do { + _ = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=summary-valid", + dashboardCookieHeader: "login_aliyunid_ticket=dashboard-valid", + rateLimitCookieHeader: "login_aliyunid_ticket=rate-expired", + environment: [AlibabaTokenPlanSettingsReader.hostKey: "https://rate-limit.test"], + propagateCredentialFailures: true, + session: session) + Issue.record("Expected the rate credential failure to propagate") + } catch let error as AlibabaTokenPlanUsageError { + #expect(error == .loginRequired) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func `summary endpoint rejection propagates despite valid rate windows`() async { + defer { + AlibabaTokenPlanStubURLProtocol.handler = nil + } + AlibabaTokenPlanStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if request.httpMethod == "GET" { + return Self.makeResponse( + url: url, + body: "", + statusCode: 200) + } + if URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .contains(where: { $0.name == "api" }) == true + { + return Self.makeResponse( + url: url, + body: """ + { + "code": "200", + "data": { + "DataV2": { + "data": { + "success": true, + "data": { + "per5HourPercentage": 0.25, + "per1WeekPercentage": 0.5 + } + } + } + }, + "successResponse": true + } + """, + statusCode: 200) + } + return Self.makeResponse(url: url, body: "expired", statusCode: 403) + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] + let session = URLSession(configuration: configuration) + + do { + _ = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=summary-expired", + dashboardCookieHeader: "login_aliyunid_ticket=dashboard-valid", + rateLimitCookieHeader: "login_aliyunid_ticket=rate-valid", + environment: [AlibabaTokenPlanSettingsReader.hostKey: "https://rate-limit.test"], + propagateCredentialFailures: true, + session: session) + Issue.record("Expected the summary credential failure to propagate") + } catch let error as AlibabaTokenPlanUsageError { + #expect(error == .loginRequired) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func `slow optional rate limit does not block subscription summary`() async throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let subscriptionSnapshot = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: 100, + totalQuota: 1000, + remainingQuota: 900, + resetsAt: nil, + updatedAt: now) + let rateLimitTask = Task { + try await Task.sleep(for: .seconds(1)) + return AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: nil, + totalQuota: nil, + remainingQuota: nil, + resetsAt: nil, + fiveHourUsedPercent: 25, + updatedAt: now) + } + let clock = ContinuousClock() + let startedAt = clock.now + + let snapshot = try await AlibabaTokenPlanUsageFetcher.mergeRateLimitTask( + rateLimitTask, + into: subscriptionSnapshot, + joinGrace: .milliseconds(20)) + let elapsed = startedAt.duration(to: clock.now) + let usage = snapshot.toUsageSnapshot() + + #expect(elapsed < .milliseconds(500)) + #expect(usage.primary?.usedPercent == 10) + #expect(usage.primary?.windowMinutes == 30 * 24 * 60) + #expect(usage.secondary == nil) + } + + @Test + func `rate credential failure requests a cookie refresh`() async { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let subscriptionSnapshot = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: 100, + totalQuota: 1000, + remainingQuota: 900, + resetsAt: nil, + updatedAt: now) + let rateLimitTask = Task { + throw AlibabaTokenPlanUsageError.loginRequired + } + + do { + _ = try await AlibabaTokenPlanUsageFetcher.mergeRateLimitTask( + rateLimitTask, + into: subscriptionSnapshot, + joinGrace: .seconds(1), + propagateCredentialFailures: true) + Issue.record("Expected the rate credential failure to propagate") + } catch let error as AlibabaTokenPlanUsageError { + #expect(error == .loginRequired) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func `rate credential failure falls back after cookie refresh`() async throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let subscriptionSnapshot = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: 100, + totalQuota: 1000, + remainingQuota: 900, + resetsAt: nil, + updatedAt: now) + let rateLimitTask = Task { + throw AlibabaTokenPlanUsageError.loginRequired + } + + let snapshot = try await AlibabaTokenPlanUsageFetcher.mergeRateLimitTask( + rateLimitTask, + into: subscriptionSnapshot, + joinGrace: .seconds(1), + propagateCredentialFailures: false) + + #expect(snapshot.totalQuota == 1000) + #expect(snapshot.fiveHourUsedPercent == nil) + } + @Test func `SEC token preflight uses injected session`() async throws { AlibabaTokenPlanStubURLProtocol.handler = { request in @@ -465,6 +1077,15 @@ struct AlibabaTokenPlanUsageParsingTests { statusCode: 200) } + if url.host == "session-token.test", + request.httpMethod == "POST", + URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .contains(where: { $0.name == "api" }) == true + { + return Self.makeResponse(url: url, body: "unavailable", statusCode: 500) + } + if url.host == "session-token.test", request.httpMethod == "POST" { let body = Self.requestBodyString(from: request) #expect(body.contains("sec_token=session-html-token")) @@ -493,6 +1114,7 @@ struct AlibabaTokenPlanUsageParsingTests { let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( apiCookieHeader: "login_aliyunid_ticket=ticket", dashboardCookieHeader: "login_aliyunid_ticket=ticket", + rateLimitCookieHeader: "login_aliyunid_ticket=ticket", environment: [AlibabaTokenPlanSettingsReader.hostKey: "https://session-token.test"], session: session) @@ -585,6 +1207,18 @@ struct AlibabaTokenPlanUsageParsingTests { } return "" } + + private static func requestParamsDictionary(from body: String) -> [String: Any]? { + guard let components = URLComponents(string: "https://example.invalid/?\(body)"), + let params = components.queryItems?.first(where: { $0.name == "params" })?.value, + let data = params.data(using: .utf8) + else { + return nil + } + + let object = try? JSONSerialization.jsonObject(with: data, options: []) + return object as? [String: Any] + } } @Suite(.serialized) @@ -780,12 +1414,18 @@ struct AlibabaTokenPlanWebStrategyTests { CookieHeaderCache.store( provider: .alibabatokenplan, scope: AlibabaTokenPlanAPIRegion.international.cookieCacheScope, - cookieHeader: "login_aliyunid_ticket=intl-ticket; gateway=intl", + cookieHeader: AlibabaTokenPlanCookieHeaders( + apiCookieHeader: "login_aliyunid_ticket=intl-ticket; gateway=intl", + dashboardCookieHeader: "login_aliyunid_ticket=intl-ticket; gateway=intl", + rateLimitCookieHeader: "login_aliyunid_ticket=intl-ticket; rpc=intl").cacheCookieHeader, sourceLabel: "International fixture") CookieHeaderCache.store( provider: .alibabatokenplan, scope: AlibabaTokenPlanAPIRegion.chinaMainland.cookieCacheScope, - cookieHeader: "login_aliyunid_ticket=cn-ticket; gateway=cn", + cookieHeader: AlibabaTokenPlanCookieHeaders( + apiCookieHeader: "login_aliyunid_ticket=cn-ticket; gateway=cn", + dashboardCookieHeader: "login_aliyunid_ticket=cn-ticket; gateway=cn", + rateLimitCookieHeader: "login_aliyunid_ticket=cn-ticket; rpc=cn").cacheCookieHeader, sourceLabel: "China fixture") try AlibabaCodingPlanCookieImporter.withImportSessionOverrideForTesting { _, _ in throw AlibabaCodingPlanSettingsError.missingCookie(details: "unexpected import") @@ -809,7 +1449,7 @@ struct AlibabaTokenPlanWebStrategyTests { } @Test - func `legacy unscoped cache migrates only to China gateway`() throws { + func `legacy unscoped cache refreshes without crossing regions`() throws { try self.withIsolatedCookieCache { self.clearCookieCaches() defer { self.clearCookieCaches() } @@ -839,7 +1479,22 @@ struct AlibabaTokenPlanWebStrategyTests { } let china = try AlibabaCodingPlanCookieImporter.withImportSessionOverrideForTesting { _, _ in - throw AlibabaCodingPlanSettingsError.missingCookie(details: "unexpected China import") + AlibabaCodingPlanCookieImporter.SessionInfo( + cookies: [ + self.cookie( + name: "login_aliyunid_ticket", + value: "cn", + domain: ".aliyun.com"), + self.cookie( + name: "gateway", + value: "fresh-cn", + domain: "bailian.console.aliyun.com"), + self.cookie( + name: "rpc", + value: "fresh-cn", + domain: "bailian-cs.console.aliyun.com"), + ], + sourceLabel: "China fixture") } operation: { try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders( context: context, @@ -849,7 +1504,9 @@ struct AlibabaTokenPlanWebStrategyTests { #expect(international.apiCookieHeader.contains("gateway=intl")) #expect(!international.apiCookieHeader.contains("gateway=legacy-cn")) - #expect(china.apiCookieHeader.contains("gateway=legacy-cn")) + #expect(china.apiCookieHeader.contains("gateway=fresh-cn")) + #expect(!china.apiCookieHeader.contains("gateway=legacy-cn")) + #expect(china.rateLimitCookieHeader?.contains("rpc=fresh-cn") == true) #expect(CookieHeaderCache.load(provider: .alibabatokenplan) == nil) #expect(CookieHeaderCache.load( provider: .alibabatokenplan, @@ -921,7 +1578,9 @@ final class AlibabaTokenPlanStubURLProtocol: URLProtocol { override static func canInit(with request: URLRequest) -> Bool { guard let host = request.url?.host else { return false } return host == "bailian.console.aliyun.com" || + host == "bailian-singapore-cs.alibabacloud.com" || host == "alibaba-token-plan.test" || + host == "rate-limit.test" || host == "session-token.test" } diff --git a/Tests/CodexBarTests/AlibabaTokenPlanSyncTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanSyncTests.swift new file mode 100644 index 000000000..ca5928317 --- /dev/null +++ b/Tests/CodexBarTests/AlibabaTokenPlanSyncTests.swift @@ -0,0 +1,94 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct AlibabaTokenPlanSyncTests { + @Test + func `sync preserves duration labels for iOS`() async throws { + let suite = "AlibabaTokenPlanSyncTests-labels" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.iCloudSyncEnabled = true + try settings.setProviderEnabled( + provider: .alibabatokenplan, + metadata: #require(ProviderDefaults.metadata[.alibabatokenplan]), + enabled: true) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting( + AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: 300, + totalQuota: 1000, + remainingQuota: 700, + resetsAt: nil, + fiveHourUsedPercent: 10, + fiveHourResetsAt: nil, + sevenDayUsedPercent: 20, + sevenDayResetsAt: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + .toUsageSnapshot(), + provider: .alibabatokenplan) + let mock = MockSyncPusher() + let coordinator = SyncCoordinator(store: store, settings: settings, syncManager: mock) + + await coordinator.pushCurrentSnapshot() + + let provider = try #require(mock.lastSnapshot?.providers + .first(where: { $0.providerID == UsageProvider.alibabatokenplan.rawValue })) + #expect(provider.rateWindows.map(\.label) == ["5-hour", "Weekly", "Credits"]) + } + + @Test + func `sync preserves weekly semantic lane for partial responses`() async throws { + let suite = "AlibabaTokenPlanSyncTests-weekly-lane" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.iCloudSyncEnabled = true + try settings.setProviderEnabled( + provider: .alibabatokenplan, + metadata: #require(ProviderDefaults.metadata[.alibabatokenplan]), + enabled: true) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting( + AlibabaTokenPlanUsageSnapshot( + planName: "Bailian Pro", + usedQuota: 300, + totalQuota: 1000, + remainingQuota: 700, + resetsAt: nil, + sevenDayUsedPercent: 20, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + .toUsageSnapshot(), + provider: .alibabatokenplan) + let mock = MockSyncPusher() + let coordinator = SyncCoordinator(store: store, settings: settings, syncManager: mock) + + await coordinator.pushCurrentSnapshot() + + let provider = try #require(mock.lastSnapshot?.providers + .first(where: { $0.providerID == UsageProvider.alibabatokenplan.rawValue })) + #expect(provider.primary == nil) + #expect(provider.secondary?.label == "Weekly") + #expect(provider.secondary?.windowMinutes == 7 * 24 * 60) + #expect(provider.rateWindows.map(\.label) == ["Weekly", "Credits"]) + } +} diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift index d352d7444..9b4dccd46 100644 --- a/Tests/CodexBarTests/CLISnapshotTests.swift +++ b/Tests/CodexBarTests/CLISnapshotTests.swift @@ -60,6 +60,31 @@ struct CLISnapshotTests { #expect(!output.contains("Premium:")) } + @Test + func `renders every Alibaba Token Plan rate window with duration labels`() { + let snapshot = UsageSnapshot( + primary: .init(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: .init(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: .init(usedPercent: 30, windowMinutes: 43200, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 0)) + + let output = CLIRenderer.renderText( + provider: .alibabatokenplan, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Alibaba Token Plan", + status: nil, + useColor: false, + resetStyle: .absolute)) + + #expect(output.contains("5-hour: 90% left")) + #expect(output.contains("Weekly: 80% left")) + #expect(output.contains("Credits: 70% left")) + #expect(!output.contains("Usage:")) + #expect(!output.contains("Tertiary:")) + } + @Test func `renders Factory legacy billing with pool labels`() { let snap = UsageSnapshot( diff --git a/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift b/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift index b4b7b0e7a..2a85f53bd 100644 --- a/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift +++ b/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift @@ -124,6 +124,96 @@ struct DashboardSnapshotBuilderTests { #expect(display["priority"] as? String == "normal") } + @Test + func `Alibaba Token Plan dashboard windows use duration labels`() throws { + let usage = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + tertiary: RateWindow( + usedPercent: 30, + windowMinutes: 43200, + resetsAt: nil, + resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 0)) + let payload = ProviderPayload( + provider: .alibabatokenplan, + account: nil, + version: nil, + source: "web", + status: nil, + usage: usage, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil) + + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [payload], + costPayloads: [], + config: CodexBarConfig(providers: [ + ProviderConfig(id: .alibabatokenplan, enabled: true), + ]), + identityMode: .none, + generatedAt: Date(timeIntervalSince1970: 0), + refreshInterval: 60, + codexBarVersion: nil) + let object = try self.jsonObject(snapshot) + let provider = try #require((object["providers"] as? [[String: Any]])?.first) + let windows = try #require(provider["windows"] as? [[String: Any]]) + + #expect(windows.map { $0["label"] as? String } == ["5-hour", "Weekly", "Credits"]) + } + + @Test + func `Alibaba weekly only dashboard window stays in weekly lane`() throws { + let usage = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: nil, + totalQuota: nil, + remainingQuota: nil, + resetsAt: nil, + sevenDayUsedPercent: 20, + updatedAt: Date(timeIntervalSince1970: 0)) + .toUsageSnapshot() + let payload = ProviderPayload( + provider: .alibabatokenplan, + account: nil, + version: nil, + source: "web", + status: nil, + usage: usage, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil) + + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [payload], + costPayloads: [], + config: CodexBarConfig(providers: [ + ProviderConfig(id: .alibabatokenplan, enabled: true), + ]), + identityMode: .none, + generatedAt: Date(timeIntervalSince1970: 0), + refreshInterval: 60, + codexBarVersion: nil) + let object = try self.jsonObject(snapshot) + let provider = try #require((object["providers"] as? [[String: Any]])?.first) + let windows = try #require(provider["windows"] as? [[String: Any]]) + + #expect(windows.count == 1) + #expect(windows[0]["kind"] as? String == "weekly") + #expect(windows[0]["label"] as? String == "Weekly") + } + @Test func `dashboard identity mode none emits null identity`() throws { let usage = UsageSnapshot( diff --git a/Tests/CodexBarTests/MenuDescriptorAlibabaTokenPlanTests.swift b/Tests/CodexBarTests/MenuDescriptorAlibabaTokenPlanTests.swift new file mode 100644 index 000000000..7d2c29ebd --- /dev/null +++ b/Tests/CodexBarTests/MenuDescriptorAlibabaTokenPlanTests.swift @@ -0,0 +1,54 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct MenuDescriptorAlibabaTokenPlanTests { + @Test + func `rate limits use five hour and weekly labels`() throws { + let suite = "MenuDescriptorAlibabaTokenPlanTests-rate-limits" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: 250, + totalQuota: 1000, + remainingQuota: 750, + resetsAt: nil, + fiveHourUsedPercent: 7.69, + fiveHourResetsAt: nil, + sevenDayUsedPercent: 2.61, + sevenDayResetsAt: nil, + updatedAt: Date(timeIntervalSince1970: 1)) + .toUsageSnapshot() + store._setSnapshotForTesting(snapshot, provider: .alibabatokenplan) + + let descriptor = MenuDescriptor.build( + provider: .alibabatokenplan, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + let lines = descriptor.sections.flatMap(\.entries).compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(lines.contains(where: { $0.hasPrefix("5-hour:") })) + #expect(lines.contains(where: { $0.hasPrefix("Weekly:") })) + #expect(lines.contains(where: { $0.hasPrefix("Credits:") })) + #expect(!lines.contains(where: { $0.hasPrefix("Usage:") })) + } +} diff --git a/Tests/CodexBarTests/SyncProviderMapperTests.swift b/Tests/CodexBarTests/SyncProviderMapperTests.swift index dacfa179e..9cc8843e0 100644 --- a/Tests/CodexBarTests/SyncProviderMapperTests.swift +++ b/Tests/CodexBarTests/SyncProviderMapperTests.swift @@ -344,6 +344,25 @@ struct SyncProviderMapperTests { provider: .alibabatokenplan, snapshot: self.snapshot()) == nil) } + @Test + func `mapAlibabaTokenPlan: suppresses an empty rate-only credit card`() { + let rateOnly = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: nil, + totalQuota: nil, + remainingQuota: nil, + resetsAt: nil, + fiveHourUsedPercent: 25, + sevenDayUsedPercent: 50, + updatedAt: Self.now) + + #expect(SyncCoordinator.mapAlibabaTokenPlan( + provider: .alibabatokenplan, + snapshot: self.snapshot(alibaba: rateOnly)) == nil) + #expect(rateOnly.toUsageSnapshot().primary?.windowMinutes == 300) + #expect(rateOnly.toUsageSnapshot().secondary?.windowMinutes == 10080) + } + @Test func `mapAlibabaTokenPlan: maps plan name and quota → credits`() throws { let plan = try #require(SyncCoordinator.mapAlibabaTokenPlan( diff --git a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift index 670e68e3e..c5b69665f 100644 --- a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift @@ -52,6 +52,60 @@ struct UsageStoreWidgetSnapshotTests { #expect(entry.usageRows?.last?.window?.resetsAt == now.addingTimeInterval(3600)) } + @Test + func `widget snapshot preserves Alibaba rate windows and credits`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-alibaba-rate-windows" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date() + store._setSnapshotForTesting( + AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: 100, + totalQuota: 1000, + remainingQuota: 900, + resetsAt: nil, + fiveHourUsedPercent: 25, + sevenDayUsedPercent: 50, + updatedAt: now).toUsageSnapshot(), + provider: .alibabatokenplan) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "alibaba-rate-windows-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .alibabatokenplan }) + #expect(entry.usageRows?.map(\.id) == ["primary", "secondary", "tertiary"]) + #expect(entry.usageRows?.map(\.title) == ["5-hour", "Weekly", "Credits"]) + #expect(entry.usageRows?.compactMap(\.percentLeft) == [75, 50, 90]) + #expect(entry.usageRows?.compactMap { $0.window?.windowMinutes } == [300, 10080, 43200]) + + let japaneseTitles = CodexBarLocalizationOverride.$appLanguage.withValue("ja") { + store.persistWidgetSnapshot(reason: "alibaba-rate-windows-ja-test") + return store.widgetSnapshotPersistTask + } + await japaneseTitles?.value + #expect(widgetSnapshots.last?.entries + .first(where: { $0.provider == .alibabatokenplan })? + .usageRows? + .map(\.title) == ["5時間", "週間", "クレジット"]) + } + @Test func `widget snapshot includes Kimi subscription quota rows`() async throws { let suite = "UsageStoreWidgetSnapshotTests-kimi-subscription-rows" diff --git a/TestsLinux/CLIGuardDecisionTests.swift b/TestsLinux/CLIGuardDecisionTests.swift index 7cf84db13..1938171a2 100644 --- a/TestsLinux/CLIGuardDecisionTests.swift +++ b/TestsLinux/CLIGuardDecisionTests.swift @@ -129,4 +129,20 @@ struct CLIGuardDecisionTests { let remaining = CodexBarCLI.guardRemainingHeadroom(for: self.window(usedPercent: 100, synthetic: false)) #expect(remaining == 0) } + + @Test + func `Alibaba weekly only response is unavailable for session guard`() { + let usage = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: nil, + totalQuota: nil, + remainingQuota: nil, + resetsAt: nil, + sevenDayUsedPercent: 25, + updatedAt: Date(timeIntervalSince1970: 0)) + .toUsageSnapshot() + + #expect(CodexBarCLI.guardRateWindow(.session, usage: usage) == nil) + #expect(CodexBarCLI.guardRateWindow(.weekly, usage: usage)?.usedPercent == 25) + } } diff --git a/version.env b/version.env index c104a1d6f..9ab063c35 100644 --- a/version.env +++ b/version.env @@ -1,5 +1,5 @@ -MARKETING_VERSION=0.45.2.1 -BUILD_NUMBER=109.1 +MARKETING_VERSION=0.45.2.2 +BUILD_NUMBER=109.2 MOBILE_VERSION=1.19.0 # Upstream tag currently merged into this branch/release train. # Once the release is live, this becomes the shipped upstream baseline for the