Files
2026-07-13 12:22:33 +08:00

304 lines
12 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
public struct KimiUsageFetcher: Sendable {
private static let log = CodexBarLog.logger(LogCategories.kimiAPI)
private static let subscriptionGraceSeconds: TimeInterval = 2
private static let usageURL =
URL(string: "https://www.kimi.com/apiv2/kimi.gateway.billing.v1.BillingService/GetUsages")!
private static let subscriptionStatsURL =
URL(string: "https://www.kimi.com/apiv2/kimi.gateway.membership.v2.MembershipService/GetSubscriptionStats")!
public static func fetchCodeAPIUsage(
apiKey: String,
baseURL: URL = KimiSettingsReader.defaultCodeAPIBaseURL,
identityHeaders: [String: String] = [:],
now: Date = Date(),
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> KimiUsageSnapshot
{
guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw KimiAPIError.missingAPIKey
}
guard let validatedBaseURL = ProviderEndpointOverrideValidator().validatedURL(baseURL.absoluteString) else {
throw KimiAPIError.invalidRequest("Kimi Code API base URL must use HTTPS without user info")
}
let endpoint = self.codeAPIUsageEndpoint(baseURL: validatedBaseURL)
var request = URLRequest(url: endpoint)
request.httpMethod = "GET"
for (name, value) in identityHeaders {
request.setValue(value, forHTTPHeaderField: name)
}
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
let response = try await transport.response(for: request)
let data = response.data
guard response.statusCode == 200 else {
let responseBody = String(data: data, encoding: .utf8) ?? "<binary data>"
Self.log.error("Kimi Code API returned \(response.statusCode): \(responseBody)")
throw self.codeAPIError(statusCode: response.statusCode)
}
return try self.parseCodeAPIUsage(from: data, now: now)
}
static func _parseCodeAPIUsageForTesting(_ data: Data, now: Date = Date()) throws -> KimiUsageSnapshot {
try self.parseCodeAPIUsage(from: data, now: now)
}
static func _codeAPIUsageEndpointForTesting(baseURL: URL) -> URL {
self.codeAPIUsageEndpoint(baseURL: baseURL)
}
static func _codeAPIErrorForTesting(statusCode: Int) -> KimiAPIError {
self.codeAPIError(statusCode: statusCode)
}
public static func fetchUsage(authToken: String, now: Date = Date()) async throws -> KimiUsageSnapshot {
try await self.fetchUsage(
authToken: authToken,
now: now,
transport: ProviderHTTPClient.shared,
subscriptionGrace: .seconds(self.subscriptionGraceSeconds))
}
static func _fetchUsageForTesting(
authToken: String,
now: Date = Date(),
transport: any ProviderHTTPTransport,
subscriptionGrace: Duration) async throws -> KimiUsageSnapshot
{
try await self.fetchUsage(
authToken: authToken,
now: now,
transport: transport,
subscriptionGrace: subscriptionGrace)
}
private static func fetchUsage(
authToken: String,
now: Date,
transport: any ProviderHTTPTransport,
subscriptionGrace: Duration) async throws -> KimiUsageSnapshot
{
let sessionInfo = self.decodeSessionInfo(from: authToken)
let subscriptionTask = Task<KimiSubscriptionStatsResponse?, Error> {
try await self.fetchSubscriptionStats(
authToken: authToken,
sessionInfo: sessionInfo,
transport: transport)
}
let subscriptionRace = BoundedTaskJoin(sourceTask: subscriptionTask)
let subscriptionOutcomeTask = Task {
await subscriptionRace.value(joinGrace: subscriptionGrace)
}
let codingUsage: KimiUsage
do {
codingUsage = try await withTaskCancellationHandler {
try await self.fetchRequiredUsage(
authToken: authToken,
sessionInfo: sessionInfo,
transport: transport)
} onCancel: {
subscriptionOutcomeTask.cancel()
}
} catch {
subscriptionOutcomeTask.cancel()
_ = await subscriptionOutcomeTask.value
throw error
}
let subscriptionOutcome = await withTaskCancellationHandler {
await subscriptionOutcomeTask.value
} onCancel: {
subscriptionOutcomeTask.cancel()
}
try Task.checkCancellation()
let subscriptionStats: KimiSubscriptionStatsResponse?
switch subscriptionOutcome {
case let .value(response):
subscriptionStats = response
case .timedOut:
Self.log.warning("Kimi subscription stats timed out")
subscriptionStats = nil
case let .failure(error):
Self.log.warning("Kimi subscription stats unavailable: \(error.localizedDescription)")
subscriptionStats = nil
}
return KimiUsageSnapshot(
weekly: codingUsage.detail,
rateLimit: codingUsage.limits?.first?.detail,
subscriptionBalance: subscriptionStats?.subscriptionBalance,
subscriptionCodeWeeklyLimit: subscriptionStats?.ratelimitCode7d,
updatedAt: now)
}
private static func fetchRequiredUsage(
authToken: String,
sessionInfo: SessionInfo?,
transport: any ProviderHTTPTransport) async throws -> KimiUsage
{
var request = self.webRequest(url: self.usageURL, authToken: authToken, sessionInfo: sessionInfo)
let requestBody = ["scope": ["FEATURE_CODING"]]
request.httpBody = try? JSONSerialization.data(withJSONObject: requestBody)
let response = try await transport.response(for: request)
let data = response.data
guard response.statusCode == 200 else {
let responseBody = String(data: data, encoding: .utf8) ?? "<binary data>"
Self.log.error("Kimi API returned \(response.statusCode): \(responseBody)")
if response.statusCode == 401 {
throw KimiAPIError.invalidToken
}
if response.statusCode == 403 {
throw KimiAPIError.invalidToken
}
if response.statusCode == 400 {
throw KimiAPIError.invalidRequest("Bad request")
}
throw KimiAPIError.apiError("HTTP \(response.statusCode)")
}
let usageResponse = try JSONDecoder().decode(KimiUsageResponse.self, from: data)
guard let codingUsage = usageResponse.usages.first(where: { $0.scope == "FEATURE_CODING" }) else {
throw KimiAPIError.parseFailed("FEATURE_CODING scope not found in response")
}
return codingUsage
}
private static func parseCodeAPIUsage(from data: Data, now: Date) throws -> KimiUsageSnapshot {
let response = try JSONDecoder().decode(KimiCodeAPIUsageResponse.self, from: data)
return KimiUsageSnapshot(
weekly: response.usage,
rateLimit: response.limits?.first?.detail,
updatedAt: now)
}
private static func codeAPIUsageEndpoint(baseURL: URL) -> URL {
let normalizedPath = baseURL.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if normalizedPath == "coding/v1" || normalizedPath.hasSuffix("/coding/v1") {
return baseURL.appendingPathComponent("usages")
}
if normalizedPath == "coding" || normalizedPath.hasSuffix("/coding") {
return baseURL
.appendingPathComponent("v1")
.appendingPathComponent("usages")
}
return baseURL
.appendingPathComponent("coding")
.appendingPathComponent("v1")
.appendingPathComponent("usages")
}
private static func fetchSubscriptionStats(
authToken: String,
sessionInfo: SessionInfo?,
transport: any ProviderHTTPTransport) async throws -> KimiSubscriptionStatsResponse?
{
var request = self.webRequest(url: self.subscriptionStatsURL, authToken: authToken, sessionInfo: sessionInfo)
request.httpBody = Data("{}".utf8)
do {
let response = try await transport.response(for: request)
guard response.statusCode == 200 else {
Self.log.warning("Kimi subscription stats returned \(response.statusCode)")
return nil
}
return try JSONDecoder().decode(KimiSubscriptionStatsResponse.self, from: response.data)
} catch {
if error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled {
throw CancellationError()
}
Self.log.warning("Kimi subscription stats unavailable: \(error.localizedDescription)")
return nil
}
}
private static func webRequest(url: URL, authToken: String, sessionInfo: SessionInfo?) -> URLRequest {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
request.setValue("kimi-auth=\(authToken)", forHTTPHeaderField: "Cookie")
request.setValue("https://www.kimi.com", forHTTPHeaderField: "Origin")
request.setValue("https://www.kimi.com/code/console", forHTTPHeaderField: "Referer")
request.setValue("*/*", forHTTPHeaderField: "Accept")
request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
let userAgent = "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"
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
request.setValue("1", forHTTPHeaderField: "connect-protocol-version")
request.setValue("en-US", forHTTPHeaderField: "x-language")
request.setValue("web", forHTTPHeaderField: "x-msh-platform")
request.setValue(TimeZone.current.identifier, forHTTPHeaderField: "r-timezone")
if let deviceId = sessionInfo?.deviceId {
request.setValue(deviceId, forHTTPHeaderField: "x-msh-device-id")
}
if let sessionId = sessionInfo?.sessionId {
request.setValue(sessionId, forHTTPHeaderField: "x-msh-session-id")
}
if let trafficId = sessionInfo?.trafficId {
request.setValue(trafficId, forHTTPHeaderField: "x-traffic-id")
}
return request
}
private static func codeAPIError(statusCode: Int) -> KimiAPIError {
switch statusCode {
case 400:
.invalidRequest("Bad request")
case 401:
.invalidAPIKey
case 403:
.apiError("HTTP 403 (permission or quota denied)")
default:
.apiError("HTTP \(statusCode)")
}
}
private static func decodeSessionInfo(from jwt: String) -> SessionInfo? {
let parts = jwt.split(separator: ".", maxSplits: 2)
guard parts.count == 3 else { return nil }
// Convert base64url to base64 for JWT decoding
// base64url uses - and _ instead of + and /
var payload = String(parts[1])
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
// Add padding if needed
while payload.count % 4 != 0 {
payload += "="
}
guard let payloadData = Data(base64Encoded: payload),
let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
else {
return nil
}
return SessionInfo(
deviceId: json["device_id"] as? String,
sessionId: json["ssid"] as? String,
trafficId: json["sub"] as? String)
}
private struct SessionInfo {
let deviceId: String?
let sessionId: String?
let trafficId: String?
}
}