mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-30 11:25:08 +00:00
Merge pull request #799 from getagentseal/fix/kimicode-visibility
kimicode: session discovery fixes + subscription quota in menubar
This commit is contained in:
commit
d2aa1e8984
19 changed files with 1141 additions and 15 deletions
|
|
@ -8,14 +8,23 @@ MoonshotAI Kimi Code local session usage and tool activity.
|
|||
|
||||
## Where it reads from
|
||||
|
||||
The provider reads `~/.kimi-code` by default and honors the Kimi Code CLI's `KIMI_CODE_HOME` environment variable. It scans:
|
||||
By default the provider scans every known Kimi Code runtime store:
|
||||
|
||||
```text
|
||||
$KIMI_CODE_HOME/sessions/wd_*/session_*/
|
||||
~/.kimi-code
|
||||
~/Library/Application Support/kimi-desktop/daimon-share/daimon/runtime/kimi-code/home (Kimi desktop / IDE embedded runtime)
|
||||
```
|
||||
|
||||
Setting `KIMI_CODE_HOME` (or passing a home override) narrows the scan to that single home. Inside each home it scans:
|
||||
|
||||
```text
|
||||
$HOME/sessions/wd_*/<session-dir>/
|
||||
├── state.json
|
||||
└── agents/<agent-id>/wire.jsonl
|
||||
```
|
||||
|
||||
Session directory naming depends on the host product: the CLI uses `session_*`, embedded runtimes use `conv-*` / `ctitle-*`. Any directory is accepted; the `agents/*/wire.jsonl` probe gates real sessions.
|
||||
|
||||
Every agent wire is a cache source. Main-agent and subagent calls share the session ID from the `session_*` directory. `state.json.workDir` supplies the project name and path. `probeRoots()` reports the resolved Kimi Code home for `codeburn doctor` even when there are no sessions.
|
||||
|
||||
## Storage format
|
||||
|
|
|
|||
|
|
@ -138,11 +138,19 @@ final class AppStore {
|
|||
var codexError: String?
|
||||
var codexLoadState: SubscriptionLoadState = CodexCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped
|
||||
|
||||
var kimiUsage: KimiUsage?
|
||||
var kimiError: String?
|
||||
// No keychain dance for Kimi — "connected" just means the CLI's
|
||||
// credential file exists, so we start dormant and auto-activate on the
|
||||
// first refresh tick.
|
||||
var kimiLoadState: SubscriptionLoadState = KimiSubscriptionService.hasCredential ? .dormant : .notBootstrapped
|
||||
|
||||
/// Generation tokens for the in-flight refresh tasks. Incremented on every
|
||||
/// disconnect / reset so a fetch that started before the disconnect cannot
|
||||
/// resume after the await and re-populate the freshly-cleared state.
|
||||
private var claudeRefreshGen: Int = 0
|
||||
private var codexRefreshGen: Int = 0
|
||||
private var kimiRefreshGen: Int = 0
|
||||
|
||||
private var cache: [PayloadCacheKey: CachedPayload] = [:]
|
||||
private var cacheDate: String = ""
|
||||
|
|
@ -1104,6 +1112,90 @@ final class AppStore {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Kimi Code
|
||||
|
||||
/// Unlike Claude/Codex there is no keychain bootstrap: reading the CLI's
|
||||
/// credential file is prompt-free, so the first refresh tick activates
|
||||
/// the dormant state automatically.
|
||||
func bootstrapKimi() async {
|
||||
// Capture the generation before the await so a disconnect that lands
|
||||
// mid-fetch cannot be resurrected into .loaded when the fetch returns.
|
||||
let gen = kimiRefreshGen
|
||||
kimiLoadState = .bootstrapping
|
||||
do {
|
||||
let usage = try await KimiSubscriptionService.refresh()
|
||||
guard gen == kimiRefreshGen else { return }
|
||||
kimiUsage = usage
|
||||
kimiError = nil
|
||||
kimiLoadState = .loaded
|
||||
} catch let err as KimiSubscriptionService.FetchError {
|
||||
guard gen == kimiRefreshGen else { return }
|
||||
applyKimiFetchError(err)
|
||||
} catch {
|
||||
guard gen == kimiRefreshGen else { return }
|
||||
kimiError = sanitizeForUI(String(describing: error))
|
||||
kimiLoadState = .failed
|
||||
}
|
||||
}
|
||||
|
||||
func refreshKimi() async {
|
||||
_ = await refreshKimiReportingSuccess()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func refreshKimiReportingSuccess() async -> Bool {
|
||||
if case .dormant = kimiLoadState {
|
||||
await bootstrapKimi()
|
||||
return kimiLoadState == .loaded
|
||||
}
|
||||
guard KimiSubscriptionService.hasCredential else {
|
||||
if kimiLoadState != .notBootstrapped { kimiLoadState = .notBootstrapped }
|
||||
return false
|
||||
}
|
||||
let gen = kimiRefreshGen
|
||||
if kimiUsage == nil { kimiLoadState = .loading }
|
||||
do {
|
||||
let usage = try await KimiSubscriptionService.refresh()
|
||||
guard gen == kimiRefreshGen else { return false }
|
||||
kimiUsage = usage
|
||||
kimiError = nil
|
||||
kimiLoadState = .loaded
|
||||
return true
|
||||
} catch let err as KimiSubscriptionService.FetchError {
|
||||
guard gen == kimiRefreshGen else { return false }
|
||||
applyKimiFetchError(err)
|
||||
return false
|
||||
} catch {
|
||||
guard gen == kimiRefreshGen else { return false }
|
||||
kimiError = sanitizeForUI(String(describing: error))
|
||||
kimiLoadState = .failed
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func disconnectKimi() {
|
||||
KimiSubscriptionService.disconnect()
|
||||
kimiRefreshGen &+= 1
|
||||
kimiUsage = nil
|
||||
kimiError = nil
|
||||
kimiLoadState = .notBootstrapped
|
||||
NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil)
|
||||
}
|
||||
|
||||
private func applyKimiFetchError(_ err: KimiSubscriptionService.FetchError) {
|
||||
let sanitized = sanitizeForUI(err.errorDescription)
|
||||
kimiError = sanitized
|
||||
if case .noCredentials = err {
|
||||
kimiLoadState = .noCredentials
|
||||
} else if err.isTerminal {
|
||||
kimiLoadState = .terminalFailure(reason: sanitized)
|
||||
} else if let retryAt = err.rateLimitRetryAt {
|
||||
kimiLoadState = .transientFailure(retryAt: retryAt)
|
||||
} else {
|
||||
kimiLoadState = .failed
|
||||
}
|
||||
}
|
||||
|
||||
private func applyFetchError(_ err: ClaudeSubscriptionService.FetchError) {
|
||||
let sanitized = sanitizeForUI(err.errorDescription)
|
||||
subscriptionError = sanitized
|
||||
|
|
@ -1172,6 +1264,10 @@ final class AppStore {
|
|||
let worst = max(usage.primary?.usedPercent ?? 0, usage.secondary?.usedPercent ?? 0)
|
||||
if worst > 0 { providers.append(("Codex", worst)) }
|
||||
}
|
||||
if let usage = kimiUsage, shouldIncludeCachedQuota(loadState: kimiLoadState) {
|
||||
let worst = max(usage.primary?.usedPercent ?? 0, usage.details.map(\.usedPercent).max() ?? 0)
|
||||
if worst > 0 { providers.append(("Kimi Code", worst)) }
|
||||
}
|
||||
let worst = providers.map(\.percent).max() ?? 0
|
||||
let severity = QuotaSummary.severity(for: worst / 100)
|
||||
let sorted = providers.sorted { $0.percent > $1.percent }
|
||||
|
|
@ -1192,6 +1288,7 @@ final class AppStore {
|
|||
switch filter {
|
||||
case .claude: return claudeQuotaSummary(filter: filter)
|
||||
case .codex: return codexQuotaSummary(filter: filter)
|
||||
case .kimiCode: return kimiQuotaSummary(filter: filter)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -1297,6 +1394,43 @@ final class AppStore {
|
|||
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: footerLines)
|
||||
}
|
||||
|
||||
private func kimiQuotaSummary(filter: ProviderFilter) -> QuotaSummary? {
|
||||
if case .notBootstrapped = kimiLoadState { return nil }
|
||||
if case .bootstrapping = kimiLoadState { return nil }
|
||||
if case .noCredentials = kimiLoadState { return nil }
|
||||
|
||||
let connection: QuotaSummary.Connection = {
|
||||
switch kimiLoadState {
|
||||
case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected
|
||||
case .loading: return kimiUsage == nil ? .loading : .stale
|
||||
case .loaded: return .connected
|
||||
case .failed: return kimiUsage == nil ? .loading : .stale
|
||||
// Kimi tokens expire ~every 15 min and only the CLI renews them, so
|
||||
// terminal is the steady state between CLI uses. Keep the last-known
|
||||
// bars (marked stale) instead of flapping the chip to a reconnect
|
||||
// card; the reconnect card is reserved for the genuinely-no-data case.
|
||||
case let .terminalFailure(reason): return kimiUsage == nil ? .terminalFailure(reason: reason) : .stale
|
||||
case .transientFailure: return .transientFailure
|
||||
}
|
||||
}()
|
||||
|
||||
var primary: QuotaSummary.Window?
|
||||
var details: [QuotaSummary.Window] = []
|
||||
if let usage = kimiUsage {
|
||||
if let w = usage.primary {
|
||||
let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt)
|
||||
primary = row
|
||||
details.append(row)
|
||||
}
|
||||
for w in usage.details {
|
||||
let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt)
|
||||
if primary == nil { primary = row }
|
||||
details.append(row)
|
||||
}
|
||||
}
|
||||
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: kimiUsage?.plan ?? "Kimi Code", footerLines: [])
|
||||
}
|
||||
|
||||
/// Persist one snapshot per window so we can answer "what did the prior cycle end at?"
|
||||
/// when the current window has just reset and projection from current data isn't meaningful.
|
||||
/// Also computes the effective_tokens consumed inside each 7-day window from local history,
|
||||
|
|
@ -1400,6 +1534,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
case ibmBob = "IBM Bob"
|
||||
case kiro = "Kiro"
|
||||
case kimi = "Kimi"
|
||||
case kimiCode = "Kimi Code"
|
||||
case lingtaiTui = "LingTai TUI"
|
||||
case kiloCode = "KiloCode"
|
||||
case openclaw = "OpenClaw"
|
||||
|
|
@ -1432,6 +1567,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
case .grok: ["grok", "grok build"]
|
||||
case .hermes: ["hermes", "hermes agent"]
|
||||
case .lingtaiTui: ["lingtai-tui", "lingtai tui"]
|
||||
case .kimiCode: ["kimicode", "kimi code"]
|
||||
default: [rawValue.lowercased()]
|
||||
}
|
||||
}
|
||||
|
|
@ -1453,6 +1589,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
case .kiloCode: "kilo-code"
|
||||
case .kiro: "kiro"
|
||||
case .kimi: "kimi"
|
||||
case .kimiCode: "kimicode"
|
||||
case .lingtaiTui: "lingtai-tui"
|
||||
case .openclaw: "openclaw"
|
||||
case .opencode: "opencode"
|
||||
|
|
|
|||
|
|
@ -519,6 +519,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
|
||||
fileprivate var lastSubscriptionRefreshAt: Date?
|
||||
fileprivate var lastCodexRefreshAt: Date?
|
||||
fileprivate var lastKimiRefreshAt: Date?
|
||||
private var claudeQuotaFailureCount = 0
|
||||
private var nextClaudeQuotaRefreshAt: Date?
|
||||
|
||||
|
|
@ -614,8 +615,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
if let task = codexQuotaRefreshTask {
|
||||
return await task.value
|
||||
}
|
||||
let task = Task { [store] in
|
||||
await store.refreshCodexReportingSuccess()
|
||||
// Kimi Code rides the same tick, but with its own cadence anchor:
|
||||
// when Codex is not connected its refresh returns false immediately,
|
||||
// so lastCodexRefreshAt never advances — anchoring Kimi on it would
|
||||
// poll api.kimi.com on every payload tick instead of the configured
|
||||
// quota cadence. Anchor on attempt (not success) so a failing Kimi
|
||||
// endpoint also respects the cadence.
|
||||
let kimiDue: Bool = {
|
||||
let cadence = SubscriptionRefreshCadence.current
|
||||
guard cadence != .manual else { return false }
|
||||
return Date().timeIntervalSince(lastKimiRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue)
|
||||
}()
|
||||
if kimiDue { lastKimiRefreshAt = Date() }
|
||||
let task = Task { [store, kimiDue] in
|
||||
async let codex = store.refreshCodexReportingSuccess()
|
||||
if kimiDue {
|
||||
_ = await store.refreshKimiReportingSuccess()
|
||||
}
|
||||
return await codex
|
||||
}
|
||||
codexQuotaRefreshTask = task
|
||||
let result = await task.value
|
||||
|
|
@ -833,6 +850,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
func resetSubscriptionCadenceAnchor() {
|
||||
lastSubscriptionRefreshAt = nil
|
||||
lastCodexRefreshAt = nil
|
||||
lastKimiRefreshAt = nil
|
||||
claudeQuotaFailureCount = 0
|
||||
nextClaudeQuotaRefreshAt = nil
|
||||
}
|
||||
|
|
|
|||
51
mac/Sources/CodeBurnMenubar/Data/KimiQuotaPresentation.swift
Normal file
51
mac/Sources/CodeBurnMenubar/Data/KimiQuotaPresentation.swift
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import Foundation
|
||||
|
||||
/// Pure display-decision helpers for the Kimi Code quota surfaces.
|
||||
///
|
||||
/// Kimi Code tokens live ~15 min and only the Kimi CLI renews them, so the
|
||||
/// load state sits in `.terminalFailure` as its dominant steady state between
|
||||
/// CLI uses. The always-visible surfaces (Plan tab, tab-strip chip) must keep
|
||||
/// showing the last good snapshot with a quiet caption instead of flapping to
|
||||
/// a reconnect screen every cycle. The reconnect screen is reserved for the
|
||||
/// no-data case, where there is genuinely nothing to show.
|
||||
enum KimiQuotaPresentation {
|
||||
/// Which Plan-tab subview to render, given the load state and whether a
|
||||
/// last-known snapshot exists.
|
||||
enum PlanContent: Equatable {
|
||||
case noCredentials
|
||||
case loading
|
||||
case failed
|
||||
case transientFailed
|
||||
case reconnect(reason: String?)
|
||||
/// Render the loaded usage bars. `idle` is true when the login has
|
||||
/// gone terminal but a snapshot is still on hand — the caller stamps a
|
||||
/// quiet "run the CLI" caption instead of hiding the data.
|
||||
case usage(idle: Bool)
|
||||
}
|
||||
|
||||
static func planContent(loadState: SubscriptionLoadState, hasUsage: Bool) -> PlanContent {
|
||||
switch loadState {
|
||||
case .notBootstrapped, .noCredentials:
|
||||
return .noCredentials
|
||||
case .dormant, .bootstrapping:
|
||||
return .loading
|
||||
case .loading, .loaded:
|
||||
return hasUsage ? .usage(idle: false) : .loading
|
||||
case .failed:
|
||||
return .failed
|
||||
case .transientFailure:
|
||||
return hasUsage ? .usage(idle: false) : .transientFailed
|
||||
case .terminalFailure(let reason):
|
||||
return hasUsage ? .usage(idle: true) : .reconnect(reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot age past which a loaded view stamps an "as of <time>" caption,
|
||||
/// so a bar can never silently masquerade as current. Kimi's ~15-min token
|
||||
/// life makes 10 minutes the point where a snapshot is likely a cycle old.
|
||||
static let stalenessThreshold: TimeInterval = 10 * 60
|
||||
|
||||
static func isStale(fetchedAt: Date, now: Date = Date()) -> Bool {
|
||||
now.timeIntervalSince(fetchedAt) > stalenessThreshold
|
||||
}
|
||||
}
|
||||
372
mac/Sources/CodeBurnMenubar/Data/KimiSubscriptionService.swift
Normal file
372
mac/Sources/CodeBurnMenubar/Data/KimiSubscriptionService.swift
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
import Foundation
|
||||
|
||||
/// Live quota snapshot for a Kimi Code subscription, returned by
|
||||
/// GET https://api.kimi.com/coding/v1/usages. Shape mirrors what
|
||||
/// steipete/CodexBar consumes: a top-level `usage` envelope plus a
|
||||
/// `limits` array of additional rate-limit windows. Numeric fields are
|
||||
/// decoded leniently (String or Int/Double) because the API has shipped
|
||||
/// both.
|
||||
struct KimiUsage: Sendable, Equatable {
|
||||
struct Window: Sendable, Equatable {
|
||||
let label: String
|
||||
let limit: Double
|
||||
let used: Double
|
||||
let remaining: Double?
|
||||
let resetsAt: Date?
|
||||
|
||||
var usedPercent: Double { // 0.0 ... 100.0
|
||||
guard limit > 0 else { return 0 }
|
||||
return min(100, max(0, used / limit * 100))
|
||||
}
|
||||
}
|
||||
|
||||
/// The top-level `usage` envelope — treated as the primary window.
|
||||
let primary: Window?
|
||||
/// Additional windows from the `limits` array (e.g. 5-hour rate limit).
|
||||
let details: [Window]
|
||||
/// Membership tier from user.membership.level (e.g. "Intermediate").
|
||||
let plan: String?
|
||||
/// Max parallel sessions from parallel.limit, when reported.
|
||||
let parallelLimit: Int?
|
||||
let fetchedAt: Date
|
||||
}
|
||||
|
||||
/// Mirror of CodexSubscriptionService for Kimi Code. Reads the CLI's
|
||||
/// credential file directly (~/.kimi-code/credentials/kimi-code.json) —
|
||||
/// no keychain bootstrap, no OAuth refresh. Tokens are short-lived
|
||||
/// (~15 min) and only the Kimi CLI refreshes them, so an expired token is
|
||||
/// a terminal state: the UI tells the user to run the CLI once.
|
||||
enum KimiSubscriptionService {
|
||||
private static let usageURL = URL(string: "https://api.kimi.com/coding/v1/usages")!
|
||||
private static let usageBlockedUntilKey = "codeburn.kimi.usage.blockedUntil"
|
||||
|
||||
enum FetchError: Error, LocalizedError {
|
||||
case noCredentials
|
||||
case tokenExpired
|
||||
case rateLimited(retryAt: Date)
|
||||
case usageHTTPError(Int, String?)
|
||||
case usageDecodeFailed
|
||||
case network(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noCredentials:
|
||||
return "No Kimi Code credentials found. Sign in with the Kimi CLI first."
|
||||
case .tokenExpired:
|
||||
return "Kimi Code login expired. Run the Kimi CLI once to refresh, then try again."
|
||||
case let .rateLimited(retryAt):
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.unitsStyle = .short
|
||||
return "Kimi rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))."
|
||||
case let .usageHTTPError(code, body):
|
||||
return "Kimi quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
|
||||
case .usageDecodeFailed: return "Kimi quota response was malformed."
|
||||
case let .network(err): return "Network error: \(err.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
var isTerminal: Bool {
|
||||
if case .tokenExpired = self { return true }
|
||||
if case .noCredentials = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var rateLimitRetryAt: Date? {
|
||||
if case let .rateLimited(retryAt) = self { return retryAt }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Credential file
|
||||
|
||||
private struct CredentialFile: Decodable {
|
||||
let accessToken: String
|
||||
let expiresAt: Double
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case expiresAt = "expires_at"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
accessToken = try c.decode(String.self, forKey: .accessToken)
|
||||
// Tolerate Int / Double / String epoch-seconds.
|
||||
if let n = try? c.decode(Double.self, forKey: .expiresAt) {
|
||||
expiresAt = n
|
||||
} else if let s = try? c.decode(String.self, forKey: .expiresAt), let n = Double(s) {
|
||||
expiresAt = n
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .expiresAt, in: c, debugDescription: "expires_at missing or not numeric")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static var credentialsURL: URL {
|
||||
let home = ProcessInfo.processInfo.environment["KIMI_CODE_HOME"]
|
||||
?? NSHomeDirectory() + "/.kimi-code"
|
||||
return URL(fileURLWithPath: home + "/credentials/kimi-code.json")
|
||||
}
|
||||
|
||||
static var hasCredential: Bool {
|
||||
FileManager.default.fileExists(atPath: credentialsURL.path)
|
||||
}
|
||||
|
||||
/// Returns the access token only when it is still fresh (60s skew).
|
||||
/// Throws noCredentials / tokenExpired otherwise.
|
||||
private static func freshToken() throws -> String {
|
||||
guard let data = FileManager.default.contents(atPath: credentialsURL.path),
|
||||
let cred = try? JSONDecoder().decode(CredentialFile.self, from: data),
|
||||
!cred.accessToken.isEmpty else {
|
||||
throw FetchError.noCredentials
|
||||
}
|
||||
guard cred.expiresAt > Date().timeIntervalSince1970 + 60 else {
|
||||
throw FetchError.tokenExpired
|
||||
}
|
||||
return cred.accessToken
|
||||
}
|
||||
|
||||
private static func deviceId() -> String? {
|
||||
let home = ProcessInfo.processInfo.environment["KIMI_CODE_HOME"]
|
||||
?? NSHomeDirectory() + "/.kimi-code"
|
||||
guard let data = FileManager.default.contents(atPath: home + "/device_id"),
|
||||
let id = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!id.isEmpty else { return nil }
|
||||
return id
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
|
||||
static func refresh() async throws -> KimiUsage {
|
||||
if let until = usageBlockedUntil(), until > Date() {
|
||||
throw FetchError.rateLimited(retryAt: until)
|
||||
}
|
||||
let token = try freshToken()
|
||||
|
||||
var request = URLRequest(url: usageURL)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = 30
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent")
|
||||
// Kimi server expects these platform headers (same as CodexBar sends).
|
||||
request.setValue("kimi_code_cli", forHTTPHeaderField: "X-Msh-Platform")
|
||||
if let deviceId = deviceId() {
|
||||
request.setValue(deviceId, forHTTPHeaderField: "X-Msh-Device-Id")
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await URLSession.shared.data(for: request)
|
||||
} catch {
|
||||
throw FetchError.network(error)
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw FetchError.usageHTTPError(-1, nil)
|
||||
}
|
||||
|
||||
switch http.statusCode {
|
||||
case 200:
|
||||
clearUsageBlock()
|
||||
do {
|
||||
return try parseUsage(data: data)
|
||||
} catch {
|
||||
// Never log the body — account data readable via `log stream`.
|
||||
NSLog("CodeBurn: kimi usage decode failed: %@", String(describing: error))
|
||||
throw FetchError.usageDecodeFailed
|
||||
}
|
||||
case 401, 403:
|
||||
// We don't self-refresh; surface as terminal so the UI prompts
|
||||
// the user to run the CLI.
|
||||
throw FetchError.tokenExpired
|
||||
case 429:
|
||||
let retryAfter = parseRetryAfterHeader(http.value(forHTTPHeaderField: "Retry-After"))
|
||||
let until = recordUsageRateLimit(retryAfterSeconds: retryAfter)
|
||||
throw FetchError.rateLimited(retryAt: until)
|
||||
default:
|
||||
throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Decode (internal so tests can drive fixtures)
|
||||
|
||||
private struct LenientDouble: Decodable {
|
||||
let value: Double?
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.singleValueContainer()
|
||||
if let n = try? c.decode(Double.self) { value = n }
|
||||
else if let s = try? c.decode(String.self) { value = Double(s) }
|
||||
else { value = nil }
|
||||
}
|
||||
}
|
||||
|
||||
private struct UsageEnvelopeDTO: Decodable {
|
||||
let limit: LenientDouble?
|
||||
let used: LenientDouble?
|
||||
let remaining: LenientDouble?
|
||||
let resetTime: LenientString?
|
||||
let resetAt: LenientString?
|
||||
let reset_time: LenientString?
|
||||
let reset_at: LenientString?
|
||||
|
||||
var resetsAtRaw: String? {
|
||||
resetTime?.value ?? resetAt?.value ?? reset_time?.value ?? reset_at?.value
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset timestamps are normally ISO-8601 strings, but decode numbers
|
||||
/// tolerantly too (epoch seconds) so a schema drift can't fail the whole
|
||||
/// response. A plain String? would throw on a JSON number.
|
||||
private struct LenientString: Decodable {
|
||||
let value: String?
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.singleValueContainer()
|
||||
if let s = try? c.decode(String.self) { value = s }
|
||||
else if let n = try? c.decode(Double.self) { value = String(n) }
|
||||
else { value = nil }
|
||||
}
|
||||
}
|
||||
|
||||
private struct LimitDTO: Decodable {
|
||||
let window: WindowDTO?
|
||||
let detail: UsageEnvelopeDTO?
|
||||
|
||||
struct WindowDTO: Decodable {
|
||||
let duration: LenientDouble?
|
||||
let timeUnit: String?
|
||||
}
|
||||
}
|
||||
|
||||
private struct ResponseDTO: Decodable {
|
||||
let usage: UsageEnvelopeDTO?
|
||||
let limits: [LimitDTO]?
|
||||
let user: UserDTO?
|
||||
let parallel: ParallelDTO?
|
||||
|
||||
struct UserDTO: Decodable {
|
||||
let membership: MembershipDTO?
|
||||
struct MembershipDTO: Decodable {
|
||||
let level: String?
|
||||
}
|
||||
}
|
||||
struct ParallelDTO: Decodable {
|
||||
let limit: LenientDouble?
|
||||
}
|
||||
}
|
||||
|
||||
static func parseUsage(data: Data, now: Date = Date()) throws -> KimiUsage {
|
||||
let root = try JSONDecoder().decode(ResponseDTO.self, from: data)
|
||||
// The top-level usage envelope is the account's weekly quota (its
|
||||
// reset lands ~7 days out), so label it like Claude's weekly window.
|
||||
let primary = makeWindow(label: "Weekly", dto: root.usage)
|
||||
let details: [KimiUsage.Window] = (root.limits ?? []).compactMap { limit in
|
||||
guard let dto = limit.detail else { return nil }
|
||||
let label = windowLabel(duration: limit.window?.duration?.value, timeUnit: limit.window?.timeUnit)
|
||||
return makeWindow(label: label, dto: dto)
|
||||
}
|
||||
guard primary != nil || !details.isEmpty else {
|
||||
throw FetchError.usageDecodeFailed
|
||||
}
|
||||
return KimiUsage(
|
||||
primary: primary,
|
||||
details: details,
|
||||
plan: planName(from: root.user?.membership?.level),
|
||||
parallelLimit: root.parallel?.limit?.value.map { Int($0) },
|
||||
fetchedAt: now
|
||||
)
|
||||
}
|
||||
|
||||
/// "LEVEL_INTERMEDIATE" → "Intermediate". Unknown / missing → nil so the
|
||||
/// UI falls back to the plain "Kimi Code" label.
|
||||
private static func planName(from level: String?) -> String? {
|
||||
guard var raw = level, !raw.isEmpty else { return nil }
|
||||
if raw.hasPrefix("LEVEL_") { raw = String(raw.dropFirst("LEVEL_".count)) }
|
||||
return raw.replacingOccurrences(of: "_", with: " ").capitalized
|
||||
}
|
||||
|
||||
private static func makeWindow(label: String, dto: UsageEnvelopeDTO?) -> KimiUsage.Window? {
|
||||
guard let dto, let limit = dto.limit?.value, limit > 0 else { return nil }
|
||||
// Rate-limit windows report only limit + remaining — derive used.
|
||||
let used = dto.used?.value ?? max(0, limit - (dto.remaining?.value ?? limit))
|
||||
return KimiUsage.Window(
|
||||
label: label,
|
||||
limit: limit,
|
||||
used: used,
|
||||
remaining: dto.remaining?.value,
|
||||
resetsAt: dto.resetsAtRaw.flatMap(parseResetTime)
|
||||
)
|
||||
}
|
||||
|
||||
/// Window size → human label. The API sends enum-style units
|
||||
/// ("TIME_UNIT_MINUTE", duration 300) as well as plain ones ("hour"),
|
||||
/// so normalize first; sub-hour durations roll up to hours when exact.
|
||||
private static func windowLabel(duration: Double?, timeUnit: String?) -> String {
|
||||
guard let duration, let rawUnit = timeUnit else { return "Rate Limit" }
|
||||
var unit = rawUnit.lowercased()
|
||||
if unit.hasPrefix("time_unit_") { unit = String(unit.dropFirst("time_unit_".count)) }
|
||||
var d = duration
|
||||
// Roll up exact sub-day durations: 300 minutes → 5 hours.
|
||||
if unit == "minute" || unit == "minutes", d.truncatingRemainder(dividingBy: 60) == 0, d >= 60 {
|
||||
d /= 60; unit = "hour"
|
||||
}
|
||||
let i = Int(d)
|
||||
switch unit {
|
||||
case "minute", "minutes": return i == 1 ? "Minutely" : "\(i)-min"
|
||||
case "hour", "hours": return i == 1 ? "Hourly" : "\(i)-hour"
|
||||
case "day", "days":
|
||||
if i == 1 { return "Daily" }
|
||||
if i == 7 { return "Weekly" }
|
||||
return "\(i)-day"
|
||||
case "week", "weeks": return i == 1 ? "Weekly" : "\(i)-week"
|
||||
case "month", "months": return i == 1 ? "Monthly" : "\(i)-month"
|
||||
default: return "\(i) \(unit)"
|
||||
}
|
||||
}
|
||||
|
||||
/// resetTime arrives as ISO-8601 (fractional seconds optional) or epoch seconds.
|
||||
private static func parseResetTime(_ raw: String) -> Date? {
|
||||
let fractional = ISO8601DateFormatter()
|
||||
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = fractional.date(from: raw) { return date }
|
||||
let plain = ISO8601DateFormatter()
|
||||
plain.formatOptions = [.withInternetDateTime]
|
||||
if let date = plain.date(from: raw) { return date }
|
||||
if let epoch = Double(raw) { return Date(timeIntervalSince1970: epoch) }
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - 429 backoff
|
||||
|
||||
private static func usageBlockedUntil() -> Date? {
|
||||
UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date
|
||||
}
|
||||
|
||||
private static func clearUsageBlock() {
|
||||
UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey)
|
||||
}
|
||||
|
||||
private static func parseRetryAfterHeader(_ value: String?) -> Int? {
|
||||
guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return nil }
|
||||
if let seconds = Int(value), seconds >= 0 { return seconds }
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
|
||||
if let date = f.date(from: value) {
|
||||
return max(0, Int(date.timeIntervalSinceNow))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date {
|
||||
let seconds = max(retryAfterSeconds ?? 300, 60)
|
||||
let until = Date().addingTimeInterval(TimeInterval(seconds))
|
||||
UserDefaults.standard.set(until, forKey: usageBlockedUntilKey)
|
||||
return until
|
||||
}
|
||||
|
||||
static func disconnect() {
|
||||
clearUsageBlock()
|
||||
}
|
||||
}
|
||||
|
|
@ -494,6 +494,7 @@ extension ProviderFilter {
|
|||
case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0)
|
||||
case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0)
|
||||
case .kimi: return Color(red: 0xA4/255.0, green: 0xC6/255.0, blue: 0x39/255.0)
|
||||
case .kimiCode: return Color(red: 0xA3/255.0, green: 0xE6/255.0, blue: 0x35/255.0)
|
||||
case .lingtaiTui: return Color(red: 0x22/255.0, green: 0xA7/255.0, blue: 0xA0/255.0)
|
||||
case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0)
|
||||
case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0)
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ struct HeatmapSection: View {
|
|||
// their own quota data sources.
|
||||
InsightMode.allCases.filter { mode in
|
||||
if mode == .plan {
|
||||
return store.selectedProvider == .claude || store.selectedProvider == .codex
|
||||
return store.selectedProvider == .claude || store.selectedProvider == .codex || store.selectedProvider == .kimiCode
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -80,6 +80,8 @@ struct HeatmapSection: View {
|
|||
case .plan:
|
||||
if store.selectedProvider == .codex {
|
||||
CodexPlanInsight()
|
||||
} else if store.selectedProvider == .kimiCode {
|
||||
KimiPlanInsight()
|
||||
} else {
|
||||
PlanInsight(usage: store.subscription)
|
||||
}
|
||||
|
|
@ -2158,6 +2160,117 @@ private struct CodexPlanInsight: View {
|
|||
}
|
||||
}
|
||||
|
||||
/// Plan tab for Kimi Code. Reads the CLI credential file (no keychain, no
|
||||
/// OAuth refresh — tokens are short-lived and only the CLI renews them), so
|
||||
/// terminal failure means "run the CLI once to refresh your login".
|
||||
private struct KimiPlanInsight: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch KimiQuotaPresentation.planContent(loadState: store.kimiLoadState, hasUsage: store.kimiUsage != nil) {
|
||||
case .noCredentials:
|
||||
PlanNoCredentialsView(
|
||||
title: "No Kimi Code credentials found",
|
||||
message: "Sign in with the Kimi CLI first. Then click Try Again."
|
||||
) { Task { await store.bootstrapKimi() } }
|
||||
case .loading:
|
||||
PlanLoadingView(message: "Reading Kimi Code credentials...")
|
||||
case .failed:
|
||||
PlanFailedView(
|
||||
error: store.kimiError
|
||||
) { Task { await store.refreshKimi() } }
|
||||
case .transientFailed:
|
||||
PlanFailedView(
|
||||
error: store.kimiError ?? "Kimi temporarily unreachable — retrying."
|
||||
) { Task { await store.refreshKimi() } }
|
||||
case let .reconnect(reason):
|
||||
PlanReconnectView(
|
||||
title: "Refresh Kimi Code login",
|
||||
reason: reason,
|
||||
fallback: "Kimi Code tokens are short-lived. Run the Kimi CLI once to refresh your login, then click Reconnect."
|
||||
) { Task { await store.bootstrapKimi() } }
|
||||
case let .usage(idle):
|
||||
if let usage = store.kimiUsage {
|
||||
loadedBody(usage: usage, idle: idle)
|
||||
} else {
|
||||
PlanLoadingView(message: "Reading Kimi Code credentials...")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func loadedBody(usage: KimiUsage, idle: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(usage.plan ?? "Kimi Code")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
if let resetsAt = usage.primary?.resetsAt {
|
||||
Text("Resets \(relativeReset(resetsAt))")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let primary = usage.primary {
|
||||
UtilizationRow(
|
||||
label: "\(primary.label) window",
|
||||
percent: primary.usedPercent,
|
||||
resetsAt: primary.resetsAt,
|
||||
projection: nil
|
||||
)
|
||||
}
|
||||
ForEach(Array(usage.details.enumerated()), id: \.offset) { _, window in
|
||||
UtilizationRow(
|
||||
label: "\(window.label) window",
|
||||
percent: window.usedPercent,
|
||||
resetsAt: window.resetsAt,
|
||||
projection: nil
|
||||
)
|
||||
}
|
||||
if let parallel = usage.parallelLimit, parallel > 0 {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Parallel sessions")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("\(parallel)")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if idle {
|
||||
Text("Login idle. Run the Kimi CLI to refresh.")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
if KimiQuotaPresentation.isStale(fetchedAt: usage.fetchedAt) {
|
||||
Text("as of \(shortTime(usage.fetchedAt))")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private func relativeReset(_ date: Date) -> String {
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.unitsStyle = .short
|
||||
return f.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
|
||||
private func shortTime(_ date: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.timeStyle = .short
|
||||
f.dateStyle = .none
|
||||
return f.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WindowProjection {
|
||||
enum Source { case linear, historicalBaseline }
|
||||
let percent: Double
|
||||
|
|
|
|||
|
|
@ -92,6 +92,10 @@ struct MenuBarContent: View {
|
|||
|
||||
private var isFilteredEmpty: Bool {
|
||||
guard store.selectedProvider != .all else { return false }
|
||||
// Plan-capable providers keep their sections visible so the Plan tab
|
||||
// (live subscription quota) stays reachable even on days with no
|
||||
// local usage — the quota endpoint doesn't depend on local sessions.
|
||||
if store.selectedProvider == .claude || store.selectedProvider == .codex || store.selectedProvider == .kimiCode { return false }
|
||||
if store.payload.current.cost > 0 || store.payload.current.calls > 0 { return false }
|
||||
if providerHasCostInAllPayload { return false }
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ struct SettingsView: View {
|
|||
.tabItem { Label("Codex", systemImage: "chevron.left.forwardslash.chevron.right") }
|
||||
.tag("codex")
|
||||
|
||||
KimiSettingsTab()
|
||||
.tabItem { Label("Kimi", systemImage: "moon.stars") }
|
||||
.tag("kimi")
|
||||
|
||||
DevinSettingsTab()
|
||||
.tabItem { Label("Devin", systemImage: "flame.fill") }
|
||||
.tag("devin")
|
||||
|
|
@ -29,7 +33,9 @@ struct SettingsView: View {
|
|||
.tabItem { Label("About", systemImage: "info.circle") }
|
||||
.tag("about")
|
||||
}
|
||||
.frame(width: 520, height: 430)
|
||||
// 6 tabs need ~600pt to render as a visible tab bar; narrower widths
|
||||
// make SwiftUI collapse the tab bar into a ">>" overflow menu.
|
||||
.frame(width: 600, height: 430)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -606,6 +612,131 @@ private struct CodexConnectionRow: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Kimi Code
|
||||
|
||||
private struct KimiSettingsTab: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Connection") {
|
||||
KimiConnectionRow()
|
||||
}
|
||||
Section {
|
||||
Text("Kimi Code live-quota tracking reads `~/.kimi-code/credentials/kimi-code.json` directly — nothing is copied or stored. Access tokens are short-lived (~15 minutes) and only the Kimi CLI refreshes them, so if the connection shows as expired, run the Kimi CLI once and click Reconnect.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
} header: {
|
||||
Text("How it works")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private struct KimiConnectionRow: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@State private var showDisconnectConfirm = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: stateIcon)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(stateTint)
|
||||
.frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(stateTitle)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
Text(stateDetail)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
actionButton
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private var stateIcon: String {
|
||||
switch store.kimiLoadState {
|
||||
case .loaded: return "checkmark.circle.fill"
|
||||
case .terminalFailure: return "exclamationmark.triangle.fill"
|
||||
case .transientFailure: return "clock.arrow.circlepath"
|
||||
case .bootstrapping, .loading: return "ellipsis.circle"
|
||||
case .notBootstrapped, .dormant, .noCredentials: return "link.circle"
|
||||
case .failed: return "xmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private var stateTint: Color {
|
||||
switch store.kimiLoadState {
|
||||
case .loaded: return .green
|
||||
case .terminalFailure, .failed: return .red
|
||||
case .transientFailure: return .orange
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var stateTitle: String {
|
||||
switch store.kimiLoadState {
|
||||
case .loaded: return "Connected"
|
||||
case let .terminalFailure(reason): return reason ?? "Login refresh required"
|
||||
case .transientFailure: return "Backing off"
|
||||
case .bootstrapping: return "Connecting…"
|
||||
case .loading: return "Refreshing…"
|
||||
case .dormant: return "Ready"
|
||||
case .notBootstrapped, .noCredentials: return "Not connected"
|
||||
case .failed: return "Couldn't load Kimi quota"
|
||||
}
|
||||
}
|
||||
|
||||
private var stateDetail: String {
|
||||
switch store.kimiLoadState {
|
||||
case .loaded:
|
||||
return "Live quota tracked from api.kimi.com."
|
||||
case .terminalFailure:
|
||||
return "Run the Kimi CLI once to refresh your login, then click Reconnect."
|
||||
case .transientFailure: return store.kimiError ?? "Kimi rate-limited; auto-retrying."
|
||||
case .bootstrapping: return "Reading ~/.kimi-code credentials."
|
||||
case .loading: return "Background refresh in progress."
|
||||
case .dormant: return "Tap Load Quota to fetch live usage from api.kimi.com."
|
||||
case .notBootstrapped, .noCredentials:
|
||||
return "Sign in with the Kimi CLI first, then click Connect."
|
||||
case .failed: return store.kimiError ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionButton: some View {
|
||||
switch store.kimiLoadState {
|
||||
case .loaded, .transientFailure, .loading:
|
||||
Button("Disconnect") { showDisconnectConfirm = true }
|
||||
.confirmationDialog(
|
||||
"Disconnect Kimi Code?",
|
||||
isPresented: $showDisconnectConfirm
|
||||
) {
|
||||
Button("Disconnect", role: .destructive) {
|
||||
store.disconnectKimi()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("CodeBurn will stop tracking Kimi Code quota. Your ~/.kimi-code credentials are untouched — the Kimi CLI keeps working.")
|
||||
}
|
||||
case .terminalFailure, .noCredentials, .failed:
|
||||
Button("Reconnect") { Task { await store.bootstrapKimi() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .dormant:
|
||||
Button("Load Quota") { Task { await store.bootstrapKimi() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .notBootstrapped:
|
||||
Button("Connect") { Task { await store.bootstrapKimi() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .bootstrapping:
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Devin
|
||||
|
||||
private struct DevinSettingsTab: View {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
/// Kimi Code tokens live ~15 min and only the CLI renews them, so
|
||||
/// `.terminalFailure` is the dominant steady state between CLI uses. These
|
||||
/// tests pin the display decision: a terminal login with a snapshot on hand
|
||||
/// must keep showing the bars (with a quiet idle caption), and only the
|
||||
/// no-data case falls through to the reconnect screen.
|
||||
@Suite("Kimi quota presentation")
|
||||
struct KimiQuotaPresentationTests {
|
||||
typealias Presentation = KimiQuotaPresentation
|
||||
|
||||
@Test("terminal failure with a snapshot keeps the usage bars, flagged idle")
|
||||
func terminalWithUsageShowsIdleUsage() {
|
||||
let content = Presentation.planContent(loadState: .terminalFailure(reason: "expired"), hasUsage: true)
|
||||
#expect(content == .usage(idle: true))
|
||||
}
|
||||
|
||||
@Test("terminal failure with no snapshot falls through to reconnect")
|
||||
func terminalWithoutUsageShowsReconnect() {
|
||||
let content = Presentation.planContent(loadState: .terminalFailure(reason: "expired"), hasUsage: false)
|
||||
#expect(content == .reconnect(reason: "expired"))
|
||||
}
|
||||
|
||||
@Test("loaded with a snapshot shows usage without the idle caption")
|
||||
func loadedShowsPlainUsage() {
|
||||
#expect(Presentation.planContent(loadState: .loaded, hasUsage: true) == .usage(idle: false))
|
||||
}
|
||||
|
||||
@Test("transient failure keeps the last snapshot, else shows the retry screen")
|
||||
func transientFailureFallsBackToUsage() {
|
||||
#expect(Presentation.planContent(loadState: .transientFailure(retryAt: nil), hasUsage: true) == .usage(idle: false))
|
||||
#expect(Presentation.planContent(loadState: .transientFailure(retryAt: nil), hasUsage: false) == .transientFailed)
|
||||
}
|
||||
|
||||
@Test("credential-absent states route to the connect prompt")
|
||||
func credentialStatesRouteToNoCredentials() {
|
||||
#expect(Presentation.planContent(loadState: .notBootstrapped, hasUsage: false) == .noCredentials)
|
||||
#expect(Presentation.planContent(loadState: .noCredentials, hasUsage: false) == .noCredentials)
|
||||
}
|
||||
|
||||
@Test("dormant and bootstrapping render the loading state")
|
||||
func dormantAndBootstrappingLoad() {
|
||||
#expect(Presentation.planContent(loadState: .dormant, hasUsage: false) == .loading)
|
||||
#expect(Presentation.planContent(loadState: .bootstrapping, hasUsage: false) == .loading)
|
||||
}
|
||||
|
||||
@Test("loading with a snapshot shows usage; without one, the loading state")
|
||||
func loadingPrefersExistingSnapshot() {
|
||||
#expect(Presentation.planContent(loadState: .loading, hasUsage: true) == .usage(idle: false))
|
||||
#expect(Presentation.planContent(loadState: .loading, hasUsage: false) == .loading)
|
||||
}
|
||||
|
||||
@Test("a fresh snapshot is not stamped stale")
|
||||
func freshSnapshotIsNotStale() {
|
||||
let now = Date()
|
||||
let fetchedAt = now.addingTimeInterval(-60) // 1 min old
|
||||
#expect(Presentation.isStale(fetchedAt: fetchedAt, now: now) == false)
|
||||
}
|
||||
|
||||
@Test("a snapshot older than the threshold is stamped stale")
|
||||
func oldSnapshotIsStale() {
|
||||
let now = Date()
|
||||
let fetchedAt = now.addingTimeInterval(-11 * 60) // 11 min old
|
||||
#expect(Presentation.isStale(fetchedAt: fetchedAt, now: now) == true)
|
||||
}
|
||||
}
|
||||
114
mac/Tests/CodeBurnMenubarTests/KimiUsageParsingTests.swift
Normal file
114
mac/Tests/CodeBurnMenubarTests/KimiUsageParsingTests.swift
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import XCTest
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
/// Fixture-driven decode tests for the Kimi Code /coding/v1/usages response.
|
||||
/// The API has shipped numbers as both JSON numbers and strings, and the
|
||||
/// reset timestamp under several key spellings (resetTime / reset_at / ...),
|
||||
/// so the parser must tolerate all of them.
|
||||
final class KimiUsageParsingTests: XCTestCase {
|
||||
|
||||
func testParsesNumericShapeWithResetTime() throws {
|
||||
let json = """
|
||||
{
|
||||
"usage": {"limit": 100, "used": 40, "remaining": 60, "resetTime": "2026-07-30T12:00:00Z"},
|
||||
"limits": [
|
||||
{"window": {"duration": 5, "timeUnit": "hour"},
|
||||
"detail": {"limit": 20, "used": 10, "remaining": 10, "resetTime": "2026-07-23T21:00:00Z"}}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let usage = try KimiSubscriptionService.parseUsage(data: json)
|
||||
XCTAssertEqual(usage.primary?.limit, 100)
|
||||
XCTAssertEqual(usage.primary?.used, 40)
|
||||
XCTAssertEqual(usage.primary?.usedPercent ?? -1, 40, accuracy: 0.001)
|
||||
XCTAssertEqual(usage.primary?.remaining, 60)
|
||||
XCTAssertNotNil(usage.primary?.resetsAt)
|
||||
XCTAssertEqual(usage.details.count, 1)
|
||||
XCTAssertEqual(usage.details.first?.label, "5-hour")
|
||||
XCTAssertEqual(usage.details.first?.usedPercent ?? -1, 50, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testParsesStringNumbersAndSnakeCaseReset() throws {
|
||||
let json = """
|
||||
{
|
||||
"usage": {"limit": "500", "used": "123", "remaining": "377", "reset_at": "2026-07-30T12:00:00.000Z"},
|
||||
"limits": []
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let usage = try KimiSubscriptionService.parseUsage(data: json)
|
||||
XCTAssertEqual(usage.primary?.limit, 500)
|
||||
XCTAssertEqual(usage.primary?.used, 123)
|
||||
XCTAssertNotNil(usage.primary?.resetsAt)
|
||||
}
|
||||
|
||||
func testWeeklyWindowLabel() throws {
|
||||
let json = """
|
||||
{
|
||||
"limits": [
|
||||
{"window": {"duration": 7, "timeUnit": "day"},
|
||||
"detail": {"limit": 1000, "used": 250}}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let usage = try KimiSubscriptionService.parseUsage(data: json)
|
||||
XCTAssertNil(usage.primary)
|
||||
XCTAssertEqual(usage.details.first?.label, "Weekly")
|
||||
}
|
||||
|
||||
func testEpochResetTime() throws {
|
||||
let json = """
|
||||
{"usage": {"limit": 10, "used": 5, "resetTime": "1784900000"}}
|
||||
""".data(using: .utf8)!
|
||||
let usage = try KimiSubscriptionService.parseUsage(data: json)
|
||||
XCTAssertEqual(usage.primary?.resetsAt, Date(timeIntervalSince1970: 1_784_900_000))
|
||||
}
|
||||
|
||||
func testNumericEpochResetTime() throws {
|
||||
// A JSON number (not string) must not fail the whole decode.
|
||||
let json = """
|
||||
{"usage": {"limit": 10, "used": 5, "resetTime": 1784900000}}
|
||||
""".data(using: .utf8)!
|
||||
let usage = try KimiSubscriptionService.parseUsage(data: json)
|
||||
XCTAssertEqual(usage.primary?.resetsAt, Date(timeIntervalSince1970: 1_784_900_000))
|
||||
}
|
||||
|
||||
func testLiveResponseShape() {
|
||||
// Captured from GET https://api.kimi.com/coding/v1/usages (2026-07-23).
|
||||
let json = """
|
||||
{
|
||||
"user": {"userId": "x", "region": "REGION_OVERSEA",
|
||||
"membership": {"level": "LEVEL_INTERMEDIATE"}},
|
||||
"usage": {"limit": "100", "used": "5", "remaining": "95",
|
||||
"resetTime": "2026-07-30T13:27:17.211180Z"},
|
||||
"limits": [
|
||||
{"window": {"duration": 300, "timeUnit": "TIME_UNIT_MINUTE"},
|
||||
"detail": {"limit": "100", "remaining": "100",
|
||||
"resetTime": "2026-07-23T23:27:17.211180Z"}}
|
||||
],
|
||||
"parallel": {"limit": "20"}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let usage = try! KimiSubscriptionService.parseUsage(data: json)
|
||||
XCTAssertEqual(usage.plan, "Intermediate")
|
||||
XCTAssertEqual(usage.parallelLimit, 20)
|
||||
XCTAssertEqual(usage.primary?.label, "Weekly")
|
||||
XCTAssertEqual(usage.primary?.usedPercent ?? -1, 5, accuracy: 0.001)
|
||||
// 300 minutes rolls up to a 5-hour label; used derives from remaining.
|
||||
XCTAssertEqual(usage.details.count, 1)
|
||||
XCTAssertEqual(usage.details.first?.label, "5-hour")
|
||||
XCTAssertEqual(usage.details.first?.usedPercent ?? -1, 0, accuracy: 0.001)
|
||||
XCTAssertNotNil(usage.details.first?.resetsAt)
|
||||
}
|
||||
|
||||
func testEmptyEnvelopeThrows() {
|
||||
let json = "{}".data(using: .utf8)!
|
||||
XCTAssertThrowsError(try KimiSubscriptionService.parseUsage(data: json))
|
||||
}
|
||||
|
||||
func testZeroLimitWindowDropped() throws {
|
||||
let json = """
|
||||
{"usage": {"limit": 0, "used": 0}, "limits": []}
|
||||
""".data(using: .utf8)!
|
||||
XCTAssertThrowsError(try KimiSubscriptionService.parseUsage(data: json))
|
||||
}
|
||||
}
|
||||
|
|
@ -83,6 +83,7 @@ const PROVIDER_COLORS: Record<string, string> = {
|
|||
opencode: '#A78BFA',
|
||||
pi: '#F472B6',
|
||||
kimi: '#B6E34A',
|
||||
kimicode: '#A3E635',
|
||||
all: '#FF8C42',
|
||||
}
|
||||
|
||||
|
|
@ -670,6 +671,7 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
|||
opencode: 'OpenCode',
|
||||
pi: 'Pi',
|
||||
kimi: 'Kimi',
|
||||
kimicode: 'Kimi Code',
|
||||
}
|
||||
function getProviderDisplayName(name: string): string { return PROVIDER_DISPLAY_NAMES[name] ?? name }
|
||||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export type ProviderCost = {
|
|||
import type { OptimizeResult } from './optimize.js'
|
||||
import { getCurrency } from './currency.js'
|
||||
import type { GranularHistory } from './granular-history.js'
|
||||
import { getShortModelName } from './models.js'
|
||||
import type { ReworkedFile } from './workflow-insights.js'
|
||||
import type { PrRow, BranchRow } from './sessions-report.js'
|
||||
|
||||
|
|
@ -369,10 +370,24 @@ function buildTopActivities(categories: PeriodData['categories']): MenubarPayloa
|
|||
}
|
||||
|
||||
function buildTopModels(models: PeriodData['models']): MenubarPayload['current']['topModels'] {
|
||||
return models
|
||||
.filter(m => m.name !== SYNTHETIC_MODEL_NAME)
|
||||
// Day entries key models by the raw provider id (day-aggregator), so resolve
|
||||
// display names here — the menubar shows "Kimi K3" rather than "k3". Ids that
|
||||
// collapse to one display name (e.g. k3 and kimi-k3) merge into a single row.
|
||||
const merged = new Map<string, { cost: number; calls: number; savingsUSD: number; estimatedCostUSD: number }>()
|
||||
for (const m of models) {
|
||||
if (m.name === SYNTHETIC_MODEL_NAME) continue
|
||||
const name = getShortModelName(m.name)
|
||||
const acc = merged.get(name) ?? { cost: 0, calls: 0, savingsUSD: 0, estimatedCostUSD: 0 }
|
||||
acc.cost += m.cost
|
||||
acc.calls += m.calls
|
||||
acc.savingsUSD += m.savingsUSD ?? 0
|
||||
acc.estimatedCostUSD += m.estimatedCostUSD ?? 0
|
||||
merged.set(name, acc)
|
||||
}
|
||||
return [...merged.entries()]
|
||||
.sort(([, a], [, b]) => b.cost - a.cost)
|
||||
.slice(0, TOP_MODELS_LIMIT)
|
||||
.map(m => ({ name: m.name, cost: m.cost, calls: m.calls, savingsUSD: m.savingsUSD, savingsBaselineModel: '', estimatedCostUSD: m.estimatedCostUSD ?? 0 }))
|
||||
.map(([name, d]) => ({ name, cost: d.cost, calls: d.calls, savingsUSD: d.savingsUSD, savingsBaselineModel: '', estimatedCostUSD: d.estimatedCostUSD }))
|
||||
}
|
||||
|
||||
function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] {
|
||||
|
|
|
|||
|
|
@ -290,6 +290,12 @@ const BUILTIN_ALIASES: Record<string, string> = {
|
|||
'kimi-auto': 'kimi-k2-thinking',
|
||||
'kimi-code': 'kimi-k2-thinking',
|
||||
'kimi-for-coding': 'kimi-k2-thinking',
|
||||
// Kimi Code wires report the bare `k3` id in llm.request.model; without an
|
||||
// alias those calls priced at $0 and the provider looked absent in the UI.
|
||||
'k3': 'kimi-k3',
|
||||
// Kimi desktop/IDE embedded runtime serves `k3-agent` / `k2d6-agent`.
|
||||
'k3-agent': 'kimi-k3',
|
||||
'k2d6-agent': 'kimi-k2p6',
|
||||
'mimo-v2-flash': 'xiaomi/mimo-v2-flash',
|
||||
'kat-coder-pro-v1': 'kwaipilot/kat-coder-pro',
|
||||
// Cursor emits dot-version tier-last names plus tier/reasoning suffixes
|
||||
|
|
@ -879,6 +885,8 @@ const SHORT_NAMES: Record<string, string> = {
|
|||
'gemini-2.5-flash': 'Gemini 2.5 Flash',
|
||||
'kimi-k2-thinking-turbo': 'Kimi K2 Thinking Turbo',
|
||||
'kimi-k2-thinking': 'Kimi K2 Thinking',
|
||||
'kimi-k3': 'Kimi K3',
|
||||
'kimi-k2p6': 'Kimi K2.6',
|
||||
'kimi-thinking-preview': 'Kimi Thinking',
|
||||
'kimi-k2.6': 'Kimi K2.6',
|
||||
'kimi-k2.5': 'Kimi K2.5',
|
||||
|
|
|
|||
|
|
@ -71,8 +71,18 @@ function timestampIso(value: unknown): string {
|
|||
return Number.isNaN(date.getTime()) ? '' : date.toISOString()
|
||||
}
|
||||
|
||||
function kimicodeHome(override?: string): string {
|
||||
return resolve(override || process.env['KIMI_CODE_HOME'] || join(homedir(), '.kimi-code'))
|
||||
function kimicodeHomes(override?: string): string[] {
|
||||
const explicit = override || process.env['KIMI_CODE_HOME']
|
||||
if (explicit) return [resolve(explicit)]
|
||||
// Default stores. Beyond the CLI's own ~/.kimi-code, embedded runtimes keep
|
||||
// the same wire layout under their own home (Kimi desktop app, Kimi Code
|
||||
// IDE); each home is scanned so embedded-agent usage is not invisible.
|
||||
const home = homedir()
|
||||
const homes = [
|
||||
join(home, '.kimi-code'),
|
||||
join(home, 'Library', 'Application Support', 'kimi-desktop', 'daimon-share', 'daimon', 'runtime', 'kimi-code', 'home'),
|
||||
]
|
||||
return [...new Set(homes.map(h => resolve(h)))]
|
||||
}
|
||||
|
||||
async function directoryEntries(path: string) {
|
||||
|
|
@ -120,7 +130,10 @@ async function discoverSources(root: string): Promise<SessionSource[]> {
|
|||
const workDirPath = join(sessionsDir, workDirEntry.name)
|
||||
|
||||
for (const sessionEntry of await directoryEntries(workDirPath)) {
|
||||
if (!sessionEntry.isDirectory() || !sessionEntry.name.startsWith('session_')) continue
|
||||
// Session dir naming differs by host product: the CLI uses session_*,
|
||||
// embedded runtimes (desktop app, IDE) use conv-*/ctitle-*. Any directory
|
||||
// is accepted; the agents/*/wire.jsonl probe below gates real sessions.
|
||||
if (!sessionEntry.isDirectory()) continue
|
||||
const sessionDir = join(workDirPath, sessionEntry.name)
|
||||
const state = await readState(sessionDir)
|
||||
const project = projectFromWorkDir(state.workDir ?? '', workDirEntry.name)
|
||||
|
|
@ -344,11 +357,15 @@ export function createKimicodeProvider(homeOverride?: string): Provider {
|
|||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return [{ path: kimicodeHome(homeOverride), label: 'Kimi Code home' }]
|
||||
return kimicodeHomes(homeOverride).map(path => ({ path, label: 'Kimi Code home' }))
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverSources(kimicodeHome(homeOverride))
|
||||
const all: SessionSource[] = []
|
||||
for (const home of kimicodeHomes(homeOverride)) {
|
||||
all.push(...await discoverSources(home))
|
||||
}
|
||||
return all.sort((a, b) => a.path.localeCompare(b.path))
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
|
|
|
|||
|
|
@ -159,6 +159,31 @@ describe('buildMenubarPayload', () => {
|
|||
expect(payload.current.topModels[0].name).toBe('Model0')
|
||||
})
|
||||
|
||||
it('resolves raw model ids to display names in topModels and merges rows that collapse', () => {
|
||||
// Day entries key models by the raw wire id (day-aggregator); the menubar
|
||||
// must show friendly names and merge ids that share one (k3 + kimi-k3).
|
||||
const period: PeriodData = {
|
||||
label: 'Today',
|
||||
cost: 0, calls: 0, sessions: 0,
|
||||
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
categories: [],
|
||||
models: [
|
||||
{ name: 'k3', cost: 2.5, calls: 78 },
|
||||
{ name: 'kimi-k3', cost: 0.5, calls: 2 },
|
||||
{ name: 'kimi-for-coding', cost: 0.06, calls: 13 },
|
||||
{ name: 'k3-agent', cost: 1.2, calls: 40 },
|
||||
],
|
||||
}
|
||||
const payload = buildMenubarPayload(period, [], null)
|
||||
const kimiK3 = payload.current.topModels.find(m => m.name === 'Kimi K3')!
|
||||
expect(kimiK3.cost).toBeCloseTo(2.5 + 0.5 + 1.2)
|
||||
expect(kimiK3.calls).toBe(78 + 2 + 40)
|
||||
const k2 = payload.current.topModels.find(m => m.name === 'Kimi K2 Thinking')!
|
||||
expect(k2.cost).toBeCloseTo(0.06)
|
||||
expect(payload.current.topModels.find(m => m.name === 'k3')).toBeUndefined()
|
||||
expect(payload.current.topModels.find(m => m.name === 'k3-agent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('caps topActivities at 20 so all task categories can surface', () => {
|
||||
const period: PeriodData = {
|
||||
label: 'Today',
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ describe('buildMenubarPayload: local-model savings', () => {
|
|||
const local = payload.current.topModels.find(m => m.name === 'Local Model')!
|
||||
expect(local.savingsUSD).toBe(5)
|
||||
expect(local.cost).toBe(0)
|
||||
const paid = payload.current.topModels.find(m => m.name === 'gpt-4o')!
|
||||
const paid = payload.current.topModels.find(m => m.name === 'GPT-4o')!
|
||||
expect(paid.savingsUSD).toBe(0)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -672,6 +672,9 @@ describe('observed provider model aliases', () => {
|
|||
const cases: Array<[string, string]> = [
|
||||
['MiMo-V2-Flash', 'xiaomi/mimo-v2-flash'],
|
||||
['KAT-Coder-Pro-V1', 'kwaipilot/kat-coder-pro'],
|
||||
// Kimi Code wires report bare `k3` in llm.request.model; it must price
|
||||
// through the kimi-k3 table entry, not fall through to $0.
|
||||
['k3', 'kimi-k3'],
|
||||
]
|
||||
|
||||
for (const [input, expectedModel] of cases) {
|
||||
|
|
@ -685,6 +688,10 @@ describe('observed provider model aliases', () => {
|
|||
})
|
||||
}
|
||||
|
||||
it('k3 shows the Kimi K3 display name', () => {
|
||||
expect(getShortModelName('k3')).toBe('Kimi K3')
|
||||
})
|
||||
|
||||
it('does not map dated Qwen3 Max to a reseller price without provider context', () => {
|
||||
expect(getModelCosts('qwen3-max-2026-01-23')).toBeNull()
|
||||
expect(calculateCost('qwen3-max-2026-01-23', 1_000_000, 1_000_000, 0, 0, 0)).toBe(0)
|
||||
|
|
|
|||
|
|
@ -167,6 +167,40 @@ describe('Kimi Code provider', () => {
|
|||
expect(sources.every(source => source.sourcePath === '/workspace/neutral-project')).toBe(true)
|
||||
})
|
||||
|
||||
it('discovers embedded-runtime conv-* and ctitle-* session directories', async () => {
|
||||
// Embedded runtimes (Kimi desktop app, Kimi Code IDE) name session dirs
|
||||
// conv-*/ctitle-* instead of session_*; their wires carry the same event
|
||||
// format and must be discovered and priced the same way.
|
||||
for (const dirName of ['conv-abc123def456', 'ctitle-019f8f78-db81']) {
|
||||
const agentDir = join(fixtureHome, 'sessions', 'wd_neutral-project_0123456789ab', dirName, 'agents', 'main')
|
||||
await mkdir(agentDir, { recursive: true })
|
||||
await writeFile(join(agentDir, '..', 'state.json'), JSON.stringify({
|
||||
createdAt: '2026-07-01T10:00:00.000Z',
|
||||
updatedAt: '2026-07-01T10:05:00.000Z',
|
||||
workDir: '/workspace/neutral-project',
|
||||
}))
|
||||
await writeFile(join(agentDir, 'wire.jsonl'), [
|
||||
JSON.stringify({ type: 'metadata', protocol_version: '1.4', created_at: 1782900000000 }),
|
||||
JSON.stringify(prompt('hello from an embedded runtime', 1782900000000)),
|
||||
JSON.stringify(request('0.1', 'k3-agent', 'k3-agent', 1782900001000)),
|
||||
JSON.stringify(usage('k3-agent', 1782900002000, { input: 100, output: 50 })),
|
||||
'',
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
const provider = createKimicodeProvider(fixtureHome)
|
||||
const sources = await provider.discoverSessions()
|
||||
expect(sources).toHaveLength(2)
|
||||
expect(sources.every(source => source.project === 'neutral-project')).toBe(true)
|
||||
|
||||
const seen = new Set<string>()
|
||||
const calls = (await Promise.all(sources.map(source => collect(provider, source, seen)))).flat()
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls.every(call => call.model === 'k3-agent')).toBe(true)
|
||||
// k3-agent prices through the kimi-k3 alias, never $0.
|
||||
expect(calls.every(call => call.costUSD > 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('parses single-turn usage with the real model id and estimated token pricing', async () => {
|
||||
const [wirePath] = await writeSession('single-turn', [{ id: 'main', lines: [
|
||||
prompt('Summarize the neutral module.', 1782900000000),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue