Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ public enum DoubaoProviderDescriptor {
metadata: ProviderMetadata(
id: .doubao,
displayName: "Doubao",
sessionLabel: "Requests",
weeklyLabel: "Monthly",
opusLabel: nil,
supportsOpus: false,
sessionLabel: "5h",
weeklyLabel: "Week",
opusLabel: "Month",
supportsOpus: true,
supportsCredits: false,
creditsHint: "",
toggleTitle: "Show Doubao usage",
Expand Down
136 changes: 134 additions & 2 deletions Sources/CodexBarCore/Providers/Trae/TraeUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,69 @@ public struct TraeUsageFetcher: Sendable {
URL(string: self.globalBase)!
}

// Step 2: Fetch user profile and usage stats in parallel
// Step 2: Fetch user profile, usage stats, and dollar-usage entitlement in parallel
async let profileResult = self.getUserInfo(base: regionalBase, session: session)
async let statsResult = self.getUserStats(base: regionalBase, session: session)
async let entitlementResult = self.fetchEntitlements(session: session)

let profile = try await profileResult
let stats = try? await statsResult // stats failure is non-fatal
let entitlements = try? await entitlementResult // dollar usage failure is non-fatal

return TraeUsageSnapshot(
checkLogin: loginResult, profile: profile, stats: stats, updatedAt: now)
checkLogin: loginResult,
profile: profile,
stats: stats,
entitlements: entitlements,
updatedAt: now)
}

// MARK: - Dollar Usage (entitlement flow)

/// Fetches the JWT required for api-sg-central.trae.ai pay endpoints.
/// Session cookies (sid_guard, sessionid, X-Cloudide-Session) authenticate
/// this request; the returned JWT is used as `Authorization: Cloud-IDE-JWT <token>`.
private static func fetchJWT(session: TraeSessionInfo) async throws -> String {
let url = URL(string: "https://api-sg-central.trae.ai/cloudide/api/v3/common/GetUserToken")!
var request = self.makeRequest(url: url, session: session)
request.httpBody = Data() // POST with empty body

let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw TraeAPIError.apiError("GetUserToken HTTP error")
}
let envelope = try JSONDecoder().decode(
TraeVolcResponse<TraeUserTokenResult>.self, from: data)
if let error = envelope.responseMetadata.error {
throw TraeAPIError.apiError("GetUserToken: \(error.message ?? error.code)")
}
guard let token = envelope.result?.token, !token.isEmpty else {
throw TraeAPIError.parseFailed("GetUserToken returned no token")
}
return token
}

private static func fetchEntitlements(
session: TraeSessionInfo) async throws -> TraeEntitlementList
{
let jwt = try await self.fetchJWT(session: session)
let url = URL(string:
"https://api-sg-central.trae.ai/trae/api/v1/pay/user_current_entitlement_list")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = Data("{}".utf8)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("https://www.trae.ai", forHTTPHeaderField: "Origin")
request.setValue("https://www.trae.ai/", forHTTPHeaderField: "Referer")
request.setValue(session.cookieHeader, forHTTPHeaderField: "Cookie")
request.setValue("Cloud-IDE-JWT \(jwt)", forHTTPHeaderField: "Authorization")

let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw TraeAPIError.apiError("user_current_entitlement_list HTTP error")
}
return try JSONDecoder().decode(TraeEntitlementList.self, from: data)
}

// MARK: - CheckLogin
Expand Down Expand Up @@ -405,3 +459,81 @@ public enum TraeAPIError: LocalizedError, Sendable {
}
}
}

// MARK: - Dollar usage (entitlement) models

struct TraeUserTokenResult: Codable, Sendable {
let token: String?

enum CodingKeys: String, CodingKey {
case token = "Token"
}
}

public struct TraeEntitlementList: Codable, Sendable {
public let isDollarUsageBilling: Bool?
public let userEntitlementPackList: [TraeEntitlementPack]?

enum CodingKeys: String, CodingKey {
case isDollarUsageBilling = "is_dollar_usage_billing"
case userEntitlementPackList = "user_entitlement_pack_list"
}
}

public struct TraeEntitlementPack: Codable, Sendable {
public let displayDesc: String?
public let entitlementBaseInfo: TraeEntitlementBaseInfo?
public let usage: TraeEntitlementUsage?
public let nextBillingTime: TimeInterval?
public let expireTime: TimeInterval?

enum CodingKeys: String, CodingKey {
case displayDesc = "display_desc"
case entitlementBaseInfo = "entitlement_base_info"
case usage
case nextBillingTime = "next_billing_time"
case expireTime = "expire_time"
}
}

public struct TraeEntitlementBaseInfo: Codable, Sendable {
public let endTime: TimeInterval?
public let productExtra: TraeProductExtra?

enum CodingKeys: String, CodingKey {
case endTime = "end_time"
case productExtra = "product_extra"
}
}

public struct TraeProductExtra: Codable, Sendable {
public let packageExtra: TraePackageExtra?

enum CodingKeys: String, CodingKey {
case packageExtra = "package_extra"
}
}

public struct TraePackageExtra: Codable, Sendable {
public let quota: TraePackageQuota?
}

public struct TraePackageQuota: Codable, Sendable {
public let basicUsageLimit: Double?
public let bonusUsageLimit: Double?

enum CodingKeys: String, CodingKey {
case basicUsageLimit = "basic_usage_limit"
case bonusUsageLimit = "bonus_usage_limit"
}
}

public struct TraeEntitlementUsage: Codable, Sendable {
public let basicUsageAmount: Double?
public let bonusUsageAmount: Double?

enum CodingKeys: String, CodingKey {
case basicUsageAmount = "basic_usage_amount"
case bonusUsageAmount = "bonus_usage_amount"
}
}
66 changes: 58 additions & 8 deletions Sources/CodexBarCore/Providers/Trae/TraeUsageSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,44 @@ public struct TraeUsageSnapshot: Sendable {
let checkLogin: TraeCheckLoginResult
let profile: TraeProfileResult
let stats: TraeStatsResult?
let entitlements: TraeEntitlementList?
public let updatedAt: Date

init(
checkLogin: TraeCheckLoginResult,
profile: TraeProfileResult,
stats: TraeStatsResult?,
entitlements: TraeEntitlementList? = nil,
updatedAt: Date)
{
self.checkLogin = checkLogin
self.profile = profile
self.stats = stats
self.entitlements = entitlements
self.updatedAt = updatedAt
}
}

extension TraeUsageSnapshot {
public func toUsageSnapshot() -> UsageSnapshot {
// Prefer dollar-usage bars when entitlements are available
// (Pro plan + optional Extra package), then fall back to 7-day activity.
let primary: RateWindow
let secondary: RateWindow?

if let stats {
// Sum 7-day AI interaction counts
let total7d = (stats.codeAiAcceptCnt7d ?? 0) + (stats.codeCompCnt7d ?? 0)
if let packs = self.entitlements?.userEntitlementPackList, !packs.isEmpty {
let proPack = packs.first { ($0.displayDesc ?? "").lowercased().contains("pro") }
?? packs[0]
primary = Self.makeDollarWindow(from: proPack)

// Build model breakdown string
let proLabel = proPack.displayDesc
let extraPack = packs.first { pack in
pack.displayDesc != proLabel
&& (pack.displayDesc?.lowercased().contains("extra") ?? false)
}
secondary = extraPack.map { Self.makeDollarWindow(from: $0) }
} else if let stats {
let total7d = (stats.codeAiAcceptCnt7d ?? 0) + (stats.codeCompCnt7d ?? 0)
let modelBreakdown = stats.codeCompDiffModelCnt7d?
.sorted { $0.value > $1.value }
.map { "\($0.key): \($0.value)" }
Expand All @@ -27,22 +53,22 @@ extension TraeUsageSnapshot {
"\(total7d) AI actions (7d)"
}

// Trae has no hard usage cap, so show activity level instead of percent
primary = RateWindow(
usedPercent: 0,
windowMinutes: 7 * 24 * 60,
resetsAt: nil,
resetDescription: description)
secondary = nil
} else {
primary = RateWindow(
usedPercent: 0,
windowMinutes: nil,
resetsAt: nil,
resetDescription: "Active — logged in")
secondary = nil
}

let accountName = self.profile.screenName
?? self.checkLogin.userID
let accountName = self.profile.screenName ?? self.checkLogin.userID
let regionInfo = self.checkLogin.region ?? self.profile.aiRegion

let identity = ProviderIdentitySnapshot(
Expand All @@ -53,10 +79,34 @@ extension TraeUsageSnapshot {

return UsageSnapshot(
primary: primary,
secondary: nil,
secondary: secondary,
tertiary: nil,
providerCost: nil,
updatedAt: self.updatedAt,
identity: identity)
}

private static func makeDollarWindow(from pack: TraeEntitlementPack) -> RateWindow {
let usage = pack.usage
let used = (usage?.basicUsageAmount ?? 0) + (usage?.bonusUsageAmount ?? 0)
let limit = (pack.entitlementBaseInfo?.productExtra?.packageExtra?.quota?.basicUsageLimit ?? 0)
+ (pack.entitlementBaseInfo?.productExtra?.packageExtra?.quota?.bonusUsageLimit ?? 0)
let percent: Double = limit > 0
? min(100, max(0, used / limit * 100))
: 0
let resetsAt: Date? = pack.nextBillingTime.flatMap {
$0 > 0 ? Date(timeIntervalSince1970: $0) : nil
} ?? pack.entitlementBaseInfo?.endTime.flatMap {
$0 > 0 ? Date(timeIntervalSince1970: $0) : nil
}
let label = pack.displayDesc ?? "Plan"
let description = limit > 0
? String(format: "%@: $%.2f / $%.2f", label, used, limit)
: String(format: "%@: $%.2f used", label, used)
return RateWindow(
usedPercent: percent,
windowMinutes: nil,
resetsAt: resetsAt,
resetDescription: description)
}
}
Loading