diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5e6c06..a61be50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,3 +82,13 @@ jobs: run: | test "$MACOS_RESULT" = success test "$FIRMWARE_RESULT" = success + + resources: + needs: build-test + runs-on: macos-15 + env: + DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer + steps: + - uses: actions/checkout@v4 + - name: Enforce local-app resource budgets + run: bash scripts/resource_check.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 71813c1..136e816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ Notable TokenLink changes are recorded here. The project follows semantic versioning while it is pre-1.0; hardware verification is reported separately in `docs/validation`. +## Unreleased + +- Added an opt-in Costs beta that keeps provider-reported OpenRouter and + DeepSeek balances separate from coding-plan quota. +- Added seven-day local `Estimated/API-equivalent` cost estimates for Codex, + Claude, and Kimi, backed by a versioned, reviewed price catalog. +- Added bounded streaming transcript scans, explicit cost provenance and + freshness, and CI gates for privacy, memory, runtime, and executable size. + ## 0.2.2 — Unreleased - Added a Universal 2 `TokenLink-0.2.2.dmg` with a drag-to-Applications layout, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 45d2897..91d6033 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,6 +90,7 @@ bash scripts/test.sh swift format lint --strict Package.swift swift format lint --recursive --strict Sources Tests bash scripts/privacy_scan.sh +bash scripts/resource_check.sh ``` On a Command-Line-Tools-only machine, compilation may work while the Swift @@ -100,10 +101,13 @@ use the macOS CI result as the test evidence. TokenLink uses a narrow, adapter-oriented architecture: -- `TokenLinkCore` owns provider-neutral identifiers, quota snapshots, and state; +- `TokenLinkCore` owns provider-neutral identifiers, quota and cost snapshots, + and state; - `TokenLinkProviders` contains isolated provider parsers and fetchers; -- providers implement `QuotaProvider` and emit a normalized `QuotaSnapshot`; - and +- quota providers implement `QuotaProvider` and emit a normalized + `QuotaSnapshot`; +- authoritative cost providers use their own capability, adapter, snapshot, + store, and refresh path; and - the app supplies credentials and HTTP access through narrow interfaces. Adapters are currently compiled into TokenLink. They are plugin-like extension @@ -147,6 +151,30 @@ home paths, subscription identifiers, or exact quota values when they could identify an account. It is acceptable to obscure account-specific values while showing that the expected windows, reset behavior, and status are present. +## Cost provider and pricing contributions + +Quota, authoritative balances, and local cost estimates are separate domains. +A cost-only provider must not receive a synthetic quota snapshot, affect quota +severity or notifications, or enter a StopWatch payload. + +An authoritative cost adapter must: + +- use an official account or billing endpoint with a narrow HTTPS host policy; +- require an explicit Keychain credential rather than browser state or an + unrelated CLI credential; +- preserve returned currencies and valid zero values without conversion or + inference from balance changes; +- distinguish authentication, timeout, decoding, and partial-source failures; + and +- include synthetic fixture tests without real account payloads or amounts. + +A price-catalog change must include the catalog version and effective date, +first-party pricing references, explicit model aliases, and independent rates +for every supported token bucket. Never guess an unknown model price, silently +price a partially covered record, convert currencies, or remove the visible +`Estimated/API-equivalent` label. Update estimator tests and the resource +workload when a new local transcript format is introduced. + ## Hardware adapter contributions Hardware and firmware access is not assumed. A hardware contribution must state: diff --git a/Package.swift b/Package.swift index 29008e2..8e60194 100644 --- a/Package.swift +++ b/Package.swift @@ -12,7 +12,10 @@ let package = Package( ], targets: [ .target(name: "TokenLinkCore"), - .target(name: "TokenLinkProviders", dependencies: ["TokenLinkCore"]), + .target( + name: "TokenLinkProviders", + dependencies: ["TokenLinkCore"], + resources: [.process("Resources")]), .target(name: "TokenLinkDevice", dependencies: ["TokenLinkCore"]), .executableTarget( name: "TokenLinkApp", diff --git a/README.md b/README.md index c1c5608..fee6c92 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,8 @@ or M5Stack. Provider names and trademarks belong to their respective owners. ## What it does -- Native `MenuBarExtra` plus a four-route Control Center: Overview, Providers, - StopWatch, and Settings & Diagnostics. +- Native `MenuBarExtra` plus a five-route Control Center: Overview, Providers, + Costs, StopWatch, and Settings & Diagnostics. - Normalizes several quota windows without inventing plan limits. - Projects burn rate per window ("runs out in ~3h at this pace") from recent local samples — no extra API calls. @@ -109,6 +109,9 @@ or M5Stack. Provider names and trademarks belong to their respective owners. even consumption. - Offers an opt-in beta scan of documented local Codex, Claude, and Kimi CLI transcript directories to summarize recent token counters on-device. +- Keeps cost data in a separate opt-in beta: official OpenRouter/DeepSeek + balances stay authoritative, while local Codex/Claude/Kimi usage is always + labelled `Estimated/API-equivalent` and priced from a reviewed bundled catalog. - Sends macOS notifications when a window runs low, a window resets, or a stored credential is rejected (toggle in Settings). - Keeps last-known-good snapshots and marks them stale when refresh fails. @@ -290,6 +293,35 @@ TokenLink supports the current numeric-unit/camelCase quota shape plus an explic legacy compatibility branch. It preserves returned windows and does not estimate limits from a plan name. +### Costs beta + +Enable **Settings & Diagnostics → Beta → Costs** to load the separate Costs +page. Cost refresh, failure, and caching are independent from quota refresh, +notifications, menu-bar severity, and StopWatch payloads. The menu bar can show +one fixed cost metric after the primary quota; unavailable selections fall back +to quota-only text. + +The two cost domains are intentionally not merged: + +- **Authoritative balances** come from an official provider account API. + OpenRouter calls `/api/v1/credits` and `/api/v1/key` independently. An explicit + Management Key can expose account credits; a regular API key may expose only + current-key spend, which TokenLink presents as partial data instead of + inventing a balance. DeepSeek calls `/user/balance` and preserves every + returned currency separately, including valid zero balances. TokenLink does + not convert currencies or infer spend from balance changes. +- **`Estimated/API-equivalent`** values price the last seven days of supported + local CLI token records using the bundled catalog's version and effective + date. They estimate what equivalent API traffic would cost; they do not + estimate, allocate, or assign monetary value to a Coding Plan subscription. + Unknown models and token categories without a reviewed price are excluded and + surfaced as warnings. + +Cost credentials are explicit Keychain entries. TokenLink does not reuse +browser state, organization-admin credentials, or unrelated CLI credentials for +OpenRouter or DeepSeek. Balances and estimated monetary totals remain in memory +and are not written to configuration or diagnostics. + Provider and Codex-path changes are persisted immediately and take effect after restarting the app. Region, account, and refresh-interval changes take effect immediately. @@ -347,9 +379,12 @@ candidate validation layer. See the latest report in [`docs/validation`](docs/va with user-only permissions. - No browser-cookie access, Full Disk Access, analytics, or remote TokenLink service. -- The optional local-usage beta reads only `.codex/sessions`, `.claude/projects`, - and `.kimi-code/sessions`; it extracts token counters locally and never sends - transcript data over the network. +- The optional local-usage and cost betas read only `.codex/sessions`, + `.claude/projects`, and `.kimi-code/sessions`; they extract token counters + locally and never send transcript data over the network. +- Local scans stream 64 KiB chunks, process files sequentially, skip files over + 50 MiB and records over 1 MiB, and retain neither raw transcript content nor + monetary snapshots. There is no telemetry. - Provider URLs are HTTPS and checked against narrow official-host allowlists before credential-bearing requests. - Diagnostics are redacted before they are written to a user-selected file. @@ -401,6 +436,21 @@ Each provider owns a fixture-tested parser and emits a shared `QuotaSnapshot`. The app is the only UI state owner. The device layer receives deliberate v1 or v2 projections and never receives provider credentials. +Cost-only providers use separate adapters, state, refresh coordination, and UI +models; they never receive synthetic quota snapshots or enter watch payloads. + +## Resource and privacy gates + +CI runs `scripts/resource_check.sh` after the regular macOS test job. The gate +streams and parses a deterministic 64 MiB workload through the production JSONL +reader, then enforces a maximum 160 MiB RSS, 30-second elapsed time, and 15 MiB +release executable. Compiler processes are excluded from the measurement. + +`scripts/privacy_scan.sh` rejects secret-like values and production logging of +balances, raw monetary values, authorization headers, raw response bodies, or +transcript paths. Diagnostics separately test that amounts, model identifiers, +account labels/UUIDs, error text, and paths cannot enter exported metadata. + ## Protocol-v2 status The Mac side implements payload projection, capability negotiation, v1 fallback, diff --git a/README.zh-Hans.md b/README.zh-Hans.md index a012e99..970b6cd 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -86,12 +86,14 @@ TokenLink 是独立的开源项目,与 OpenAI、Anthropic、Moonshot AI、Mini ## 功能 -- 原生 `MenuBarExtra` 菜单栏 + 管控中心四个页面:概览、额度源、StopWatch、设置与诊断。 +- 原生 `MenuBarExtra` 菜单栏 + 管控中心五个页面:概览、额度源、成本、StopWatch、设置与诊断。 - 统一归一化多家、多窗口额度,绝不凭空推算套餐限额。 - 烧速预测:根据本地最近样本推算"按当前速度约几小时后耗尽",不额外调用 API。 - 可选合理用量参考线,标出额度窗口在均匀消耗情况下应该所处的位置。 - 可选 Beta 本地用量观测:只读扫描 Codex、Claude、Kimi CLI 的已知会话目录, 仅在本机汇总近期 token 计数。 +- 成本作为独立的可选 Beta:OpenRouter/DeepSeek 官方余额保持权威数据语义; + Codex/Claude/Kimi 本地用量始终标注 `Estimated/API-equivalent`,并使用随包审阅的价格目录计价。 - macOS 系统通知:窗口额度告急、窗口重置、凭据被拒时提醒(可在设置中关闭)。 - 刷新失败时保留最近一次成功快照并标记为 stale,绝不显示虚构的实时值。 - API key 只存 macOS 钥匙串(显示名称 `TokenLink`,service @@ -248,6 +250,26 @@ Token Plan」页面获取,与按量付费 API Key 不互通),并选择 Glo 保存 GLM Coding Plan API key,并选择 Global(Z.AI)或中国(BigModel)区域。 解析器保留服务端实际返回的各窗口,不根据套餐名称推算额度。 +### 成本 Beta + +在 **设置与诊断 → Beta 功能 → 成本** 启用后,会出现独立的成本页面。成本刷新、失败与 +缓存均不影响额度刷新、通知、菜单栏严重程度或 StopWatch payload。菜单栏可以在主额度 +后显示一个固定成本指标;所选数据不可用时自动退回仅显示额度。 + +两类成本数据绝不混为一谈: + +- **权威余额**来自服务商官方账户 API。OpenRouter 独立请求 `/api/v1/credits` 与 + `/api/v1/key`:显式 Management Key 可以读取账户余额,普通 API key 可能只能读取 + 当前 key 的支出;后者会显示为部分数据,不会凭空推算余额。DeepSeek 通过 + `/user/balance` 返回余额,TokenLink 保留每一种币种和有效的零余额,不换汇,也不从 + 余额变化反推支出。 +- **`Estimated/API-equivalent`** 根据价格目录的版本和生效日期,为近 7 天支持的本地 + CLI token 记录估算等价 API 流量成本。它不估算编程订阅套餐的价值,也不会把额度百分比 + 换算成金额。未知模型或缺少已审阅价格的 token 类别会从金额中排除并显示警告。 + +成本凭据必须显式存入钥匙串;OpenRouter/DeepSeek 不复用浏览器状态、组织管理员凭据或 +无关 CLI 凭据。余额和估算金额仅保存在内存中,不写入配置与诊断。 + 额度源启用状态和 Codex 路径的修改在重启 app 后生效;区域、账户、刷新间隔、语言、 通知开关的修改立即生效。 @@ -289,8 +311,10 @@ payload 送达 C152。0.2.2 候选新增完整分页、稳定优先级槽位、 - 显式 API key:只存 macOS 钥匙串。 - 非敏感配置:`~/Library/Application Support/TokenLink/config.json`,仅本人权限。 - 不读浏览器 Cookie,不需要完全磁盘访问权限,无埋点,无远端服务。 -- 可选本地用量 Beta 只读取 `.codex/sessions`、`.claude/projects`、 +- 可选本地用量与成本 Beta 只读取 `.codex/sessions`、`.claude/projects`、 `.kimi-code/sessions`,仅在本机提取 token 计数,不上传会话内容。 +- 本地扫描按 64 KiB 分块、逐文件处理,跳过超过 50 MiB 的文件与超过 1 MiB 的记录; + 不保留原始会话内容或金额快照,也没有遥测。 - 所有厂商请求都是 HTTPS,且在携带凭据前校验官方 host allowlist。 - 诊断导出前脱敏。 @@ -337,6 +361,19 @@ firmware/ 声明式 `ProviderSpec` 注册表接入。app 是唯一的 UI 状态所有者;设备层只接收经过 显式 v1/v2 投影的字段,不接触任何凭据。 +纯成本 provider 使用独立的适配器、状态、刷新协调与 UI 模型,不会获得伪造的额度快照, +也不会进入手表 payload。 + +## 资源与隐私门禁 + +常规 macOS 测试任务通过后,CI 会运行 `scripts/resource_check.sh`:将确定性的 64 MiB +工作负载流过生产 JSONL reader 与解析器,并限制最大 RSS 为 160 MiB、耗时为 30 秒、 +release 可执行文件为 15 MiB。编译器进程不计入测量。 + +`scripts/privacy_scan.sh` 会拒绝疑似密钥,以及把余额、原始金额、Authorization header、 +原始响应 body 或会话路径写入生产日志的代码。诊断测试另行保证金额、模型标识、账户 +标签/UUID、错误文本与路径不会进入导出的元数据。 + ## 协议 v2 状态 Mac 端已经实现 payload 投影、能力协商、v1 回退、provider 轮转、三个可见工作单元、 diff --git a/SECURITY.md b/SECURITY.md index 1490a53..8549845 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,9 +31,15 @@ the old items as a recovery fallback. token field. Neither adapter writes a CLI credential or refresh token. - The initial destination of each credential-bearing request is validated against its provider's HTTPS host allowlist before the request is sent. +- Authoritative cost adapters use only explicitly configured Keychain + credentials. Provider balances, spend values, local estimates, and priceable + model identifiers remain in memory and are excluded from diagnostics. - Codex quota and task state reuse the local Codex CLI sign-in. The optional usage observer reads only documented CLI session roots and summarizes token counters locally. +- Local usage and cost scans accept regular files only, refuse symbolic links + and special files, enforce 50 MiB per-file and 1 MiB per-record limits while + reading, and never retain prompt or response content. - Protocol v2 sends provider/window labels, quota values, display settings, and up to three short visible Codex task titles/states to the explicitly bound watch. It does not send credentials, account identifiers, raw prompts, @@ -66,5 +72,7 @@ investigation before public disclosure. Provider and hardware adapter contributions require compatibility evidence, but that evidence must not weaken user security. Public pull requests should contain only synthetic fixtures and redacted screenshots, recordings, or log excerpts. +Cost-provider evidence must also obscure account-specific monetary values and +must not include raw billing responses or organization identifiers. If reproducing a security-sensitive integration requires private material, coordinate through the reporting channels above before submitting the adapter. diff --git a/Sources/TokenLinkApp/AppModel.swift b/Sources/TokenLinkApp/AppModel.swift index da220cb..338c98d 100644 --- a/Sources/TokenLinkApp/AppModel.swift +++ b/Sources/TokenLinkApp/AppModel.swift @@ -114,6 +114,7 @@ public final class AppModel { public private(set) var lastWatchSyncFailureAt: Date? public private(set) var lastWatchFocusOutcome: WatchFocusOutcome? public private(set) var lastWatchFocusAt: Date? + public private(set) var costDashboard: CostDashboardModel @ObservationIgnored private var refresher: any AppRefreshing @ObservationIgnored private let refresherBuilder: @@ -141,6 +142,7 @@ public final class AppModel { @ObservationIgnored private var notificationPolicy = NotificationPolicy() @ObservationIgnored private let notificationManager: (any NotificationManaging)? @ObservationIgnored private let localUsageObserver: LocalUsageObserver? + @ObservationIgnored private let costDashboardBuilder: ((AppConfiguration) -> CostDashboardModel)? @ObservationIgnored private let workItemStore: WorkItemStore @ObservationIgnored private let codexWorkItemTracker: CodexWorkItemTracker? @ObservationIgnored private let codexDesktopActivator: any CodexDesktopActivating @@ -171,6 +173,8 @@ public final class AppModel { loginController: LoginItemController? = nil, notificationManager: (any NotificationManaging)? = nil, localUsageObserver: LocalUsageObserver? = nil, + costDashboard: CostDashboardModel? = nil, + costDashboardBuilder: ((AppConfiguration) -> CostDashboardModel)? = nil, workItemStore: WorkItemStore = WorkItemStore(), codexWorkItemTracker: CodexWorkItemTracker? = nil, codexDesktopActivator: any CodexDesktopActivating = SystemCodexDesktopActivator(), @@ -188,6 +192,19 @@ public final class AppModel { self.loginController = loginController self.notificationManager = notificationManager self.localUsageObserver = localUsageObserver + self.costDashboard = + costDashboard + ?? CostDashboardModel( + enabled: configuration.betaCostsEnabled, + authoritativeSources: [], + estimateProviders: [], + authoritativeLoader: { _ in + .failure(.configuration("No authoritative cost source is configured.")) + }, + estimateLoader: { _ in + .failure(.configuration("No local cost source is configured.")) + }) + self.costDashboardBuilder = costDashboardBuilder self.workItemStore = workItemStore self.codexWorkItemTracker = codexWorkItemTracker self.codexDesktopActivator = codexDesktopActivator @@ -225,11 +242,21 @@ public final class AppModel { let vault = KeychainVault() let http = URLSessionHTTPClient() let store = ProviderStore() + let localUsageObserver = LocalUsageObserver() + let catalog = try? PriceCatalog.bundled() let makeCoordinator: @Sendable (AppConfiguration) -> RefreshCoordinator = { configuration in RefreshCoordinator( providers: makeProviders(configuration: configuration, http: http, vault: vault), store: store) } + let makeCostDashboard: (AppConfiguration) -> CostDashboardModel = { configuration in + Self.makeCostDashboard( + configuration: configuration, + http: http, + vault: vault, + observer: localUsageObserver, + catalog: catalog) + } return AppModel( refresher: makeCoordinator(configuration), refresherBuilder: makeCoordinator, @@ -246,7 +273,9 @@ public final class AppModel { // running unpackaged (e.g. `swift run`). notificationManager: Bundle.main.bundleURL.pathExtension == "app" ? SystemNotificationManager() : NullNotificationManager(), - localUsageObserver: LocalUsageObserver(), + localUsageObserver: localUsageObserver, + costDashboard: makeCostDashboard(configuration), + costDashboardBuilder: makeCostDashboard, codexWorkItemTracker: CodexWorkItemTracker( executable: CodexExecutableResolver.resolve(configuredPath: configuration.codexPath), transport: ProcessAppServerTransport( @@ -259,7 +288,7 @@ public final class AppModel { vault: any CredentialReader ) -> [AccountProvider] { var providers: [AccountProvider] = [] - for account in configuration.accounts where account.enabled { + for account in quotaAccounts(in: configuration) { let isDefault = configuration.isDefaultAccount(account) // Codex and Claude rely on local CLI sign-ins and stay single-instance. if account.provider == .codex || account.provider == .claude, !isDefault { continue } @@ -292,15 +321,104 @@ public final class AppModel { base: vault, allowsKeychainCredential: configuration.claudeCredentialAccessAuthorized) : vault) + case .openrouter, .deepseek: + continue } providers.append(AccountProvider(accountID: account.id, provider: provider)) } return providers } + nonisolated static func quotaAccounts( + in configuration: AppConfiguration + ) -> [ProviderAccount] { + configuration.accounts.filter { + $0.enabled && ProviderRegistry.capabilities(for: $0.provider).contains(.quota) + } + } + + private static func makeCostDashboard( + configuration: AppConfiguration, + http: any HTTPClient, + vault: KeychainVault, + observer: LocalUsageObserver, + catalog: PriceCatalog? + ) -> CostDashboardModel { + var providers: [AccountCostProvider] = [] + for account in configuration.accounts where account.enabled { + guard ProviderRegistry.capabilities(for: account.provider).contains(.authoritativeCost) + else { continue } + let credentialAccount = KeychainVault.keychainAccountName( + provider: account.provider, + accountID: account.id, + isDefault: configuration.isDefaultAccount(account)) + let provider: any AuthoritativeCostProvider + switch account.provider { + case .openrouter: + provider = OpenRouterCostProvider( + credentialAccount: credentialAccount, + http: http, + credentials: vault) + case .deepseek: + provider = DeepSeekCostProvider( + credentialAccount: credentialAccount, + http: http, + credentials: vault) + default: + continue + } + providers.append(AccountCostProvider(accountID: account.id, provider: provider)) + } + let sources = providers.map { + AuthoritativeCostSource(accountID: $0.accountID, provider: $0.provider.id) + } + let costProviders = providers + let estimateProviders = ProviderRegistry.localCostEstimateProviderIDs + let estimator = catalog.map { + LocalCostEstimator(observer: observer, catalog: $0) + } + return CostDashboardModel( + enabled: configuration.betaCostsEnabled, + authoritativeSources: sources, + estimateProviders: estimateProviders, + authoritativeLoader: { source in + guard let entry = costProviders.first(where: { $0.accountID == source.accountID }) else { + return .failure(.configuration("Cost provider configuration is unavailable.")) + } + return await entry.provider.fetch() + }, + estimateLoader: { provider in + guard let estimator else { + return .failure(.configuration("The bundled price catalog is unavailable.")) + } + let task = Task.detached(priority: .utility) { + () -> Result in + do { + try Task.checkCancellation() + let through = Date() + return .success( + try estimator.estimate( + provider: provider, + since: through.addingTimeInterval(-7 * 86_400), + through: through)) + } catch is CancellationError { + return .failure(.timeout("Local cost scan was cancelled.")) + } catch { + return .failure( + .init(kind: .localRead, message: "Local usage could not be read.")) + } + } + return await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + }) + } + /// Enabled accounts grouped by provider, in `ProviderID.allCases` order. public var accountGroups: [ProviderAccountGroup] { - ProviderID.allCases.compactMap { provider in + ProviderRegistry.quotaProviderIDs.compactMap { provider in let rows = configuration.accounts .filter { $0.provider == provider && $0.enabled } @@ -320,6 +438,39 @@ public final class AppModel { } } + public var costAccountGroups: [ProviderAccountGroup] { + ProviderRegistry.authoritativeCostProviderIDs.compactMap { provider in + let rows = + configuration.accounts + .filter { $0.provider == provider && $0.enabled } + .map { account in + AccountRow( + id: account.id, + provider: provider, + label: account.label, + isDefault: configuration.isDefaultAccount(account), + state: ProviderState(phase: .disabled)) + } + guard !rows.isEmpty else { return nil } + return ProviderAccountGroup( + provider: provider, + displayName: Self.displayName(for: provider), + accounts: rows) + } + } + + public var watchEligibleProviders: [ProviderID] { + ProviderRegistry.quotaProviderIDs.filter { provider in + configuration.defaultAccount(for: provider)?.enabled == true + } + } + + public var enabledWatchProviders: [ProviderID] { + watchEligibleProviders.filter { provider in + configuration.watchSettings.syncedProviders.contains(provider) + } + } + /// Minimal single-account projection used by the current UI: one row per /// provider, backed by its default account. public var orderedProviderRows: [ProviderRow] { @@ -351,8 +502,98 @@ public final class AppModel { public var menuBarLabel: String { guard let highlight else { return "TokenLink" } - return + let quota = "\(Self.displayName(for: highlight.provider)) \(Int(highlight.window.remainingPercent.rounded()))%" + guard configuration.betaCostsEnabled, let supplement = menuBarCostSupplement else { + return quota + } + return "\(quota) · \(supplement)" + } + + public var menuBarAccessibilityLabel: String { + guard let highlight else { return "TokenLink" } + let quota = String( + format: text(.menubarQuotaAccessibilityFormat), + Self.displayName(for: highlight.provider), + Int(highlight.window.remainingPercent.rounded())) + guard configuration.betaCostsEnabled else { return quota } + switch configuration.menuBarCostMetric { + case .none: + return quota + case .localEstimate(let provider): + guard let row = costDashboard.estimateRows.first(where: { $0.provider == provider }), + Self.canPresentCost(row.state.phase), + let totals = row.state.snapshot?.totals, + totals.count == 1, + let amount = totals.first + else { return quota } + return String( + format: text(.menubarEstimateAccessibilityFormat), + quota, + Self.displayName(for: row.provider), + CostFormatting.amount(amount, language: currentLanguage), + costFreshness(row.state.phase)) + case .authoritativeBalance(let accountID, let currency): + guard + let row = costDashboard.authoritativeRows.first(where: { $0.id == accountID }), + Self.canPresentCost(row.state.phase), + let balance = row.state.snapshot?.balances.first(where: { + $0.available.currency.caseInsensitiveCompare(currency) == .orderedSame + }) + else { return quota } + return String( + format: text(.menubarBalanceAccessibilityFormat), + quota, + Self.displayName(for: row.source.provider), + CostFormatting.amount(balance.available, language: currentLanguage), + costFreshness(row.state.phase)) + } + } + + private var menuBarCostSupplement: String? { + switch configuration.menuBarCostMetric { + case .none: + return nil + case .localEstimate(let provider): + guard let row = costDashboard.estimateRows.first(where: { $0.provider == provider }), + Self.canPresentCost(row.state.phase), + let totals = row.state.snapshot?.totals, + totals.count == 1, + let amount = totals.first + else { return nil } + return String( + format: text(.menubarEstimateCompactFormat), + CostFormatting.amount(amount, language: currentLanguage)) + case .authoritativeBalance(let accountID, let currency): + guard + let row = costDashboard.authoritativeRows.first(where: { $0.id == accountID }), + Self.canPresentCost(row.state.phase), + let balance = row.state.snapshot?.balances.first(where: { + $0.available.currency.caseInsensitiveCompare(currency) == .orderedSame + }) + else { return nil } + return String( + format: text(.menubarBalanceCompactFormat), + CostFormatting.abbreviation(for: row.source.provider), + CostFormatting.amount(balance.available, language: currentLanguage)) + } + } + + private static func canPresentCost(_ phase: ProviderPhase) -> Bool { + phase == .healthy || phase == .stale || phase == .refreshing + } + + private func costFreshness(_ phase: ProviderPhase) -> String { + switch phase { + case .healthy: + text(.menubarCostFresh) + case .stale: + text(.menubarCostStale) + case .refreshing: + text(.menubarCostRefreshing) + case .disabled, .missingCredential, .error: + text(.phaseError) + } } public var deviceStatusText: String { @@ -542,7 +783,11 @@ public final class AppModel { label: trimmed.isEmpty ? Self.displayName(for: provider) : trimmed) configuration.accounts.append(account) try saveConfiguration() - rebuildRefresher(reason: "Account added; refreshing") + if ProviderRegistry.capabilities(for: provider).contains(.quota) { + rebuildRefresher(reason: "Account added; refreshing") + } else { + rebuildCostDashboard() + } Task { await refreshCredentialStates() } return account } @@ -570,7 +815,11 @@ public final class AppModel { } } try saveConfiguration() - rebuildRefresher(reason: "Account removed; refreshing") + if ProviderRegistry.capabilities(for: account.provider).contains(.quota) { + rebuildRefresher(reason: "Account removed; refreshing") + } else { + rebuildCostDashboard() + } await refreshCredentialStates() record("Removed \(Self.displayName(for: account.provider)) account") } @@ -731,12 +980,16 @@ public final class AppModel { configuration.accounts[index].enabled = false } } + let capabilities = ProviderRegistry.capabilities(for: provider) try saveConfiguration() if provider == .claude { rebuildRefresher(reason: "Claude provider setting applied") - } else { + } else if capabilities.contains(.quota) { configurationRestartRequired = true } + if capabilities.contains(.authoritativeCost) { + rebuildCostDashboard() + } } /// The only path that may initiate access to Claude Code's Keychain item. @@ -880,6 +1133,9 @@ public final class AppModel { } public func setWatchSyncedProvider(_ provider: ProviderID, enabled: Bool) throws { + guard ProviderRegistry.capabilities(for: provider).contains(.quota) else { + throw ProviderFailure.configuration("Only quota providers can sync to StopWatch.") + } if enabled { configuration.watchSettings.syncedProviders.insert(provider) } else { @@ -927,6 +1183,52 @@ public final class AppModel { } } + public func setBetaCostsEnabled(_ enabled: Bool) async throws { + let previousConfiguration = configuration + configuration.betaCostsEnabled = enabled + if enabled, configuration.menuBarCostMetric == .none { + configuration.menuBarCostMetric = .localEstimate(.codex) + } else if !enabled { + configuration.menuBarCostMetric = .none + } + + if enabled { + do { + try saveConfiguration() + } catch { + configuration = previousConfiguration + throw error + } + await costDashboard.setEnabled(true) + } else { + do { + try saveConfiguration() + } catch { + configuration = previousConfiguration + throw error + } + await costDashboard.disable() + } + } + + public func setMenuBarCostMetric(_ metric: MenuBarCostMetric) throws { + guard configuration.betaCostsEnabled || metric == .none else { + throw ProviderFailure.configuration("Enable Costs beta before selecting a cost metric.") + } + configuration.menuBarCostMetric = metric + try saveConfiguration() + } + + public func loadCostsIfNeeded() async { + guard configuration.betaCostsEnabled else { return } + await costDashboard.loadIfNeeded() + } + + public func refreshCosts(force: Bool) async { + guard configuration.betaCostsEnabled else { return } + await costDashboard.refreshCosts(force: force) + } + /// Beta: scans local CLI transcripts (last 7 days) on a background task. public func scanLocalUsage() async { guard configuration.betaLocalUsageEnabled, let localUsageObserver, @@ -942,7 +1244,34 @@ public final class AppModel { } public func diagnosticObject() -> [String: Any] { - [ + let costMetadata = costDashboard.diagnosticMetadata + let lastCostRefresh: Any = + if let value = costMetadata.lastRefreshAt { + ISO8601DateFormatter().string(from: value) + } else { + NSNull() + } + let costSources: [[String: Any]] = costMetadata.sources.map { source in + let errorKind: Any = + if let value = source.errorKind?.rawValue { value } else { NSNull() } + let updatedAt: Any = + if let value = source.updatedAt { + ISO8601DateFormatter().string(from: value) + } else { + NSNull() + } + let catalogVersion: Any = + if let value = source.catalogVersion { value } else { NSNull() } + return [ + "provider": source.provider.rawValue, + "kind": source.kind.rawValue, + "phase": source.phase.rawValue, + "error_kind": errorKind, + "updated_at": updatedAt, + "catalog_version": catalogVersion, + ] + } + return [ "generated_at": ISO8601DateFormatter().string(from: now()), "configuration": [ "enabled_providers": configuration.enabledProviders.map(\.rawValue).sorted(), @@ -973,6 +1302,12 @@ public final class AppModel { "error_kind": errorKind, ] as [String: Any] }, + "costs": [ + "enabled": configuration.betaCostsEnabled, + "is_refreshing": costMetadata.isRefreshing, + "last_refresh_at": lastCostRefresh, + "sources": costSources, + ] as [String: Any], "device_phase": deviceStatusText, "bluetooth": [ "authorization": bluetoothDiagnostics.authorization.rawValue, @@ -996,7 +1331,10 @@ public final class AppModel { } public func exportDiagnostics(to url: URL) throws { - try DiagnosticExporter.write(diagnosticObject(), to: url) + try DiagnosticExporter.write( + diagnosticObject(), + to: url, + accountLabels: Set(configuration.accounts.map(\.label))) } private func saveConfiguration() throws { @@ -1011,7 +1349,7 @@ public final class AppModel { } private func hasWatchSyncCandidate(allowStale: Bool) -> Bool { - configuration.watchSettings.syncedProviders.contains { provider in + enabledWatchProviders.contains { provider in guard let account = configuration.defaultAccount(for: provider), account.enabled, let state = states[account.id] else { return false } @@ -1061,9 +1399,8 @@ public final class AppModel { ) async -> [WatchSyncPolicy.Decision] { let negotiated = await bridge.negotiatedProtocol var candidates: [(provider: ProviderID, snapshot: QuotaSnapshot)] = [] - for provider in ProviderID.allCases { - guard configuration.watchSettings.syncedProviders.contains(provider), - let account = configuration.defaultAccount(for: provider), + for provider in enabledWatchProviders { + guard let account = configuration.defaultAccount(for: provider), account.enabled, let state = states[account.id], state.phase == .healthy || (allowStale && state.phase == .stale), @@ -1382,6 +1719,13 @@ public final class AppModel { networkWasAvailable = available } + private func rebuildCostDashboard() { + guard let costDashboardBuilder else { return } + let previous = costDashboard + costDashboard = costDashboardBuilder(configuration) + Task { await previous.disable() } + } + public static func displayName(for provider: ProviderID) -> String { ProviderRegistry.displayName(for: provider) } diff --git a/Sources/TokenLinkApp/ConfigurationStore.swift b/Sources/TokenLinkApp/ConfigurationStore.swift index 84c00b7..25e9340 100644 --- a/Sources/TokenLinkApp/ConfigurationStore.swift +++ b/Sources/TokenLinkApp/ConfigurationStore.swift @@ -90,6 +90,10 @@ public struct AppConfiguration: Codable, Equatable, Sendable { /// from TokenLink's pre-0.2.1 Keychain service. Fresh installs have nothing /// to migrate and start with this flow completed. public var legacyKeychainMigrationCompleted: Bool + /// Beta: authoritative balances and local API-equivalent cost estimates. + public var betaCostsEnabled: Bool + /// Fixed optional supplement displayed after the primary quota label. + public var menuBarCostMetric: MenuBarCostMetric /// StopWatch v2 preferences (theme, wake, hour format, synced providers). public var watchSettings: WatchSettings @@ -112,6 +116,8 @@ public struct AppConfiguration: Codable, Equatable, Sendable { betaLocalUsageEnabled: Bool = false, claudeCredentialAccessAuthorized: Bool = false, legacyKeychainMigrationCompleted: Bool = true, + betaCostsEnabled: Bool = false, + menuBarCostMetric: MenuBarCostMetric = .none, watchSettings: WatchSettings = WatchSettings() ) { self.accounts = accounts @@ -127,6 +133,8 @@ public struct AppConfiguration: Codable, Equatable, Sendable { self.betaLocalUsageEnabled = betaLocalUsageEnabled self.claudeCredentialAccessAuthorized = claudeCredentialAccessAuthorized self.legacyKeychainMigrationCompleted = legacyKeychainMigrationCompleted + self.betaCostsEnabled = betaCostsEnabled + self.menuBarCostMetric = menuBarCostMetric self.watchSettings = watchSettings } @@ -153,7 +161,7 @@ public struct AppConfiguration: Codable, Equatable, Sendable { // Claude is opt-in because enabling it can request access to a credential // owned by another app. The other providers use TokenLink-owned keys, // documented files, or local processes. - enabledProviders: Set(ProviderID.allCases).subtracting([.claude]), + enabledProviders: Set(ProviderRegistry.quotaProviderIDs).subtracting([.claude]), refreshMinutes: 5, boundDeviceIdentifier: nil, codexPath: nil, @@ -161,7 +169,7 @@ public struct AppConfiguration: Codable, Equatable, Sendable { glmRegion: .global) public static func defaultAccounts(for providers: Set) -> [ProviderAccount] { - ProviderID.allCases.filter(providers.contains).map { provider in + ProviderRegistry.quotaProviderIDs.filter(providers.contains).map { provider in ProviderAccount( provider: provider, label: ProviderRegistry.displayName(for: provider)) @@ -192,6 +200,8 @@ public struct AppConfiguration: Codable, Equatable, Sendable { case betaLocalUsageEnabled case claudeCredentialAccessAuthorized case legacyKeychainMigrationCompleted + case betaCostsEnabled + case menuBarCostMetric case watchSettings } @@ -209,7 +219,7 @@ public struct AppConfiguration: Codable, Equatable, Sendable { accounts = Self.defaultAccounts(for: Set(legacy)) } else { accounts = Self.defaultAccounts( - for: Set(ProviderID.allCases).subtracting([.claude])) + for: Set(ProviderRegistry.quotaProviderIDs).subtracting([.claude])) } let decodedBoundDeviceIdentifier = try container.decodeIfPresent( UUID.self, forKey: .boundDeviceIdentifier) @@ -244,6 +254,10 @@ public struct AppConfiguration: Codable, Equatable, Sendable { // Do not touch the old Keychain service until the user chooses to migrate. legacyKeychainMigrationCompleted: try container.decodeIfPresent( Bool.self, forKey: .legacyKeychainMigrationCompleted) ?? false, + betaCostsEnabled: try container.decodeIfPresent( + Bool.self, forKey: .betaCostsEnabled) ?? false, + menuBarCostMetric: try container.decodeIfPresent( + MenuBarCostMetric.self, forKey: .menuBarCostMetric) ?? .none, watchSettings: try container.decodeIfPresent( WatchSettings.self, forKey: .watchSettings) ?? WatchSettings()) } @@ -267,6 +281,8 @@ public struct AppConfiguration: Codable, Equatable, Sendable { claudeCredentialAccessAuthorized, forKey: .claudeCredentialAccessAuthorized) try container.encode( legacyKeychainMigrationCompleted, forKey: .legacyKeychainMigrationCompleted) + try container.encode(betaCostsEnabled, forKey: .betaCostsEnabled) + try container.encode(menuBarCostMetric, forKey: .menuBarCostMetric) try container.encode(watchSettings, forKey: .watchSettings) } } diff --git a/Sources/TokenLinkApp/Costs/CostDashboardModel.swift b/Sources/TokenLinkApp/Costs/CostDashboardModel.swift new file mode 100644 index 0000000..d94e26b --- /dev/null +++ b/Sources/TokenLinkApp/Costs/CostDashboardModel.swift @@ -0,0 +1,320 @@ +import Foundation +import Observation +import TokenLinkCore + +public struct AuthoritativeCostSource: Equatable, Hashable, Sendable { + public let accountID: UUID + public let provider: ProviderID + + public init(accountID: UUID, provider: ProviderID) { + self.accountID = accountID + self.provider = provider + } +} + +public struct AuthoritativeCostRow: Identifiable, Equatable, Sendable { + public var id: UUID { source.accountID } + public let source: AuthoritativeCostSource + public let state: AuthoritativeCostState + + public init(source: AuthoritativeCostSource, state: AuthoritativeCostState) { + self.source = source + self.state = state + } +} + +public struct EstimatedCostRow: Identifiable, Equatable, Sendable { + public var id: ProviderID { provider } + public let provider: ProviderID + public let state: EstimatedCostState + + public init(provider: ProviderID, state: EstimatedCostState) { + self.provider = provider + self.state = state + } +} + +public enum CostDiagnosticSourceKind: String, Equatable, Sendable { + case authoritative + case estimate +} + +public struct CostDiagnosticSourceMetadata: Equatable, Sendable { + public let provider: ProviderID + public let kind: CostDiagnosticSourceKind + public let phase: ProviderPhase + public let errorKind: ProviderErrorKind? + public let updatedAt: Date? + public let catalogVersion: String? + + public init( + provider: ProviderID, + kind: CostDiagnosticSourceKind, + phase: ProviderPhase, + errorKind: ProviderErrorKind?, + updatedAt: Date?, + catalogVersion: String? = nil + ) { + self.provider = provider + self.kind = kind + self.phase = phase + self.errorKind = errorKind + self.updatedAt = updatedAt + self.catalogVersion = catalogVersion + } +} + +public struct CostDiagnosticMetadata: Equatable, Sendable { + public let isRefreshing: Bool + public let lastRefreshAt: Date? + public let sources: [CostDiagnosticSourceMetadata] + + public init( + isRefreshing: Bool, + lastRefreshAt: Date?, + sources: [CostDiagnosticSourceMetadata] + ) { + self.isRefreshing = isRefreshing + self.lastRefreshAt = lastRefreshAt + self.sources = sources + } +} + +@MainActor +@Observable +public final class CostDashboardModel { + public private(set) var isEnabled: Bool + public private(set) var isRefreshing = false + public private(set) var authoritativeRows: [AuthoritativeCostRow] = [] + public private(set) var estimateRows: [EstimatedCostRow] = [] + public private(set) var lastRefreshAt: Date? + + @ObservationIgnored private let authoritativeSources: [AuthoritativeCostSource] + @ObservationIgnored private let estimateProviders: [ProviderID] + @ObservationIgnored private let store: CostStore + @ObservationIgnored private let authoritativeLoader: + @Sendable (AuthoritativeCostSource) async -> Result< + AuthoritativeCostSnapshot, ProviderFailure + > + @ObservationIgnored private let estimateLoader: + @Sendable (ProviderID) async -> Result + @ObservationIgnored private let now: @Sendable () -> Date + @ObservationIgnored private var refreshTask: Task? + @ObservationIgnored private var refreshToken: UUID? + @ObservationIgnored private var agingTask: Task? + + public init( + enabled: Bool, + authoritativeSources: [AuthoritativeCostSource], + estimateProviders: [ProviderID], + store: CostStore = CostStore(), + authoritativeLoader: + @escaping @Sendable (AuthoritativeCostSource) async -> Result< + AuthoritativeCostSnapshot, ProviderFailure + >, + estimateLoader: + @escaping @Sendable (ProviderID) async -> Result< + EstimatedCostSnapshot, ProviderFailure + >, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.isEnabled = enabled + self.authoritativeSources = authoritativeSources + self.estimateProviders = estimateProviders + self.store = store + self.authoritativeLoader = authoritativeLoader + self.estimateLoader = estimateLoader + self.now = now + } + + public var diagnosticMetadata: CostDiagnosticMetadata { + let authoritative = authoritativeRows.map { row in + CostDiagnosticSourceMetadata( + provider: row.source.provider, + kind: .authoritative, + phase: row.state.phase, + errorKind: row.state.error?.kind, + updatedAt: row.state.snapshot?.fetchedAt, + catalogVersion: nil) + } + let estimated = estimateRows.map { row in + CostDiagnosticSourceMetadata( + provider: row.provider, + kind: .estimate, + phase: row.state.phase, + errorKind: row.state.error?.kind, + updatedAt: row.state.snapshot?.scannedAt, + catalogVersion: row.state.snapshot?.catalogVersion) + } + return CostDiagnosticMetadata( + isRefreshing: isRefreshing, + lastRefreshAt: lastRefreshAt, + sources: authoritative + estimated) + } + + public func loadIfNeeded() async { + await refreshCosts(force: false) + } + + public func refreshCosts(force: Bool) async { + guard isEnabled else { return } + if let refreshTask { + await refreshTask.value + return + } + + let token = UUID() + refreshToken = token + let task = Task { @MainActor [weak self] in + await self?.performRefresh(force: force) + } + refreshTask = task + await task.value + if refreshToken == token { + refreshTask = nil + refreshToken = nil + isRefreshing = false + } + } + + public func setEnabled(_ enabled: Bool) async { + if enabled { + isEnabled = true + } else { + await disable() + } + } + + public func disable() async { + isEnabled = false + refreshToken = nil + refreshTask?.cancel() + refreshTask = nil + agingTask?.cancel() + agingTask = nil + isRefreshing = false + authoritativeRows = [] + estimateRows = [] + lastRefreshAt = nil + await store.clear() + } + + private func performRefresh(force: Bool) async { + var authoritativeToLoad: [AuthoritativeCostSource] = [] + for source in authoritativeSources { + let state = await store.authoritativeState(for: source.accountID) + if force || state.phase != .healthy { + authoritativeToLoad.append(source) + } + } + + var estimatesToLoad: [ProviderID] = [] + for provider in estimateProviders { + let state = await store.estimatedState(for: provider) + if force || state.phase != .healthy { + estimatesToLoad.append(provider) + } + } + + guard !authoritativeToLoad.isEmpty || !estimatesToLoad.isEmpty else { + await updateRows() + return + } + + isRefreshing = true + let authoritativeLoader = self.authoritativeLoader + let estimateLoader = self.estimateLoader + await withTaskGroup(of: CostLoadResult.self) { group in + for source in authoritativeToLoad { + await store.markAuthoritativeRefreshing(source.accountID) + group.addTask { + .authoritative( + accountID: source.accountID, + result: await authoritativeLoader(source)) + } + } + for provider in estimatesToLoad { + await store.markEstimateRefreshing(provider) + group.addTask { + .estimate( + provider: provider, + result: await estimateLoader(provider)) + } + } + + for await result in group { + guard !Task.isCancelled else { continue } + switch result { + case .authoritative(let accountID, let value): + await store.acceptAuthoritative(value, accountID: accountID) + case .estimate(let provider, let value): + await store.acceptEstimate(value, provider: provider) + } + await updateRows() + } + } + + guard !Task.isCancelled else { return } + lastRefreshAt = now() + await updateRows() + } + + private func updateRows() async { + var authoritative: [AuthoritativeCostRow] = [] + for source in authoritativeSources { + authoritative.append( + AuthoritativeCostRow( + source: source, + state: await store.authoritativeState(for: source.accountID))) + } + var estimates: [EstimatedCostRow] = [] + for provider in estimateProviders { + estimates.append( + EstimatedCostRow( + provider: provider, + state: await store.estimatedState(for: provider))) + } + authoritativeRows = authoritative + estimateRows = estimates + scheduleAging() + } + + private func scheduleAging() { + agingTask?.cancel() + agingTask = nil + guard isEnabled else { return } + + let authoritativeDeadlines = authoritativeRows.compactMap { row -> Date? in + guard row.state.phase == .healthy, let fetchedAt = row.state.snapshot?.fetchedAt else { + return nil + } + return fetchedAt.addingTimeInterval(CostStore.authoritativeTTL) + } + let estimateDeadlines = estimateRows.compactMap { row -> Date? in + guard row.state.phase == .healthy, let scannedAt = row.state.snapshot?.scannedAt else { + return nil + } + return scannedAt.addingTimeInterval(CostStore.estimateTTL) + } + guard let deadline = (authoritativeDeadlines + estimateDeadlines).min() else { return } + let delay = max(0, deadline.timeIntervalSince(now())) + 0.01 + agingTask = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: .seconds(delay)) + } catch { + return + } + guard let self, self.isEnabled else { return } + await self.updateRows() + } + } +} + +private enum CostLoadResult: Sendable { + case authoritative( + accountID: UUID, + result: Result) + case estimate( + provider: ProviderID, + result: Result) +} diff --git a/Sources/TokenLinkApp/Costs/CostFormatting.swift b/Sources/TokenLinkApp/Costs/CostFormatting.swift new file mode 100644 index 0000000..1c13a41 --- /dev/null +++ b/Sources/TokenLinkApp/Costs/CostFormatting.swift @@ -0,0 +1,32 @@ +import Foundation +import TokenLinkCore + +enum CostFormatting { + static func amount(_ amount: CurrencyAmount, language: AppLanguage) -> String { + let formatter = NumberFormatter() + formatter.locale = + switch language { + case .english: Locale(identifier: "en_US") + case .simplifiedChinese: Locale(identifier: "zh_CN") + case .japanese: Locale(identifier: "ja_JP") + } + formatter.numberStyle = .currency + formatter.currencyCode = amount.currency + formatter.minimumFractionDigits = amount.currency == "JPY" ? 0 : 2 + formatter.maximumFractionDigits = amount.currency == "JPY" ? 0 : 2 + return formatter.string(from: NSDecimalNumber(decimal: amount.value)) + ?? "\(amount.currency) \(amount.value)" + } + + static func abbreviation(for provider: ProviderID) -> String { + switch provider { + case .openrouter: "OR" + case .deepseek: "DS" + case .codex: "Codex" + case .kimi: "Kimi" + case .claude: "Claude" + case .minimax: "MiniMax" + case .glm: "GLM" + } + } +} diff --git a/Sources/TokenLinkApp/Costs/LocalCostEstimator.swift b/Sources/TokenLinkApp/Costs/LocalCostEstimator.swift new file mode 100644 index 0000000..4e4ce17 --- /dev/null +++ b/Sources/TokenLinkApp/Costs/LocalCostEstimator.swift @@ -0,0 +1,188 @@ +import Foundation +import TokenLinkCore +import TokenLinkProviders + +public enum LocalCostEstimatorError: Error, Equatable, Sendable { + case invalidInterval + case unsupportedProvider(ProviderID) +} + +public struct LocalCostEstimator: Sendable { + private let observer: LocalUsageObserver + private let catalog: PriceCatalog + private let now: @Sendable () -> Date + + public init( + observer: LocalUsageObserver, + catalog: PriceCatalog, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.observer = observer + self.catalog = catalog + self.now = now + } + + public func estimate( + provider: ProviderID, + since: Date, + through: Date + ) throws -> EstimatedCostSnapshot { + guard since <= through else { throw LocalCostEstimatorError.invalidInterval } + switch provider { + case .codex: + return try estimate( + CodexCostRecordParser.self, + since: since, + through: through) + case .claude: + return try estimate( + ClaudeCostRecordParser.self, + since: since, + through: through) + case .kimi: + return try estimate( + KimiCostRecordParser.self, + since: since, + through: through) + default: + throw LocalCostEstimatorError.unsupportedProvider(provider) + } + } + + private func estimate( + _ parser: P.Type, + since: Date, + through: Date + ) throws -> EstimatedCostSnapshot { + var aggregates: [AggregateKey: CostAggregate] = [:] + var unknownModelIDs: Set = [] + var seenEventIDs: Set = [] + var rejectedOverflow = false + + let report = try observer.scanRecords( + parser, + since: since, + through: through + ) { usage in + if !usage.deduplicationKey.isEmpty, + !seenEventIDs.insert(usage.deduplicationKey).inserted + { + return + } + guard let price = catalog.entry(provider: P.provider, modelID: usage.modelID) else { + unknownModelIDs.insert(usage.modelID) + return + } + let canonicalUsage = NormalizedModelUsage( + provider: usage.provider, + modelID: price.modelID, + timestamp: usage.timestamp, + uncachedInputTokens: usage.uncachedInputTokens, + cacheReadTokens: usage.cacheReadTokens, + cacheWriteTokens: usage.cacheWriteTokens, + cacheWriteDuration: usage.cacheWriteDuration, + outputTokens: usage.outputTokens) + guard let item = CostCalculator.lineItem(usage: canonicalUsage, price: price) else { + unknownModelIDs.insert(usage.modelID) + return + } + let key = AggregateKey( + modelID: price.modelID, + currency: item.amount.currency) + var aggregate = + aggregates[key] ?? CostAggregate(provider: P.provider, modelID: price.modelID) + if aggregate.add(item) { + aggregates[key] = aggregate + } else { + rejectedOverflow = true + } + } + + let lineItems = aggregates.keys.sorted().compactMap { key in + aggregates[key]?.lineItem(currency: key.currency) + } + var warnings: [CostWarning] = [] + let skippedFiles = report.oversizedFileCount + report.unreadableFileCount + if skippedFiles > 0 || report.oversizedRecordCount > 0 { + warnings.append( + .partialLocalScan( + fileCount: skippedFiles, + recordCount: report.oversizedRecordCount)) + } + if rejectedOverflow { + warnings.append(.invalidTokenCount) + } + return EstimatedCostSnapshot( + provider: P.provider, + period: DateInterval(start: since, end: through), + lineItems: lineItems, + totals: CostCalculator.totals(for: lineItems), + unknownModelIDs: unknownModelIDs.sorted(), + warnings: warnings, + catalogVersion: catalog.version, + catalogEffectiveDate: catalog.effectiveDate, + scannedAt: now()) + } +} + +private struct AggregateKey: Hashable, Comparable { + let modelID: String + let currency: String + + static func < (lhs: Self, rhs: Self) -> Bool { + if lhs.modelID != rhs.modelID { return lhs.modelID < rhs.modelID } + return lhs.currency < rhs.currency + } +} + +private struct CostAggregate { + let provider: ProviderID + let modelID: String + var latestTimestamp = Date.distantPast + var uncachedInputTokens = 0 + var cacheReadTokens = 0 + var cacheWriteTokens = 0 + var outputTokens = 0 + var amount: Decimal = 0 + var warnings: [CostWarning] = [] + + mutating func add(_ item: ModelCostLineItem) -> Bool { + let nextUncached = uncachedInputTokens.addingReportingOverflow( + item.usage.uncachedInputTokens) + let nextCacheRead = cacheReadTokens.addingReportingOverflow(item.usage.cacheReadTokens) + let nextCacheWrite = cacheWriteTokens.addingReportingOverflow( + item.usage.cacheWriteTokens) + let nextOutput = outputTokens.addingReportingOverflow(item.usage.outputTokens) + guard + !nextUncached.overflow, + !nextCacheRead.overflow, + !nextCacheWrite.overflow, + !nextOutput.overflow + else { return false } + + latestTimestamp = max(latestTimestamp, item.usage.timestamp) + uncachedInputTokens = nextUncached.partialValue + cacheReadTokens = nextCacheRead.partialValue + cacheWriteTokens = nextCacheWrite.partialValue + outputTokens = nextOutput.partialValue + amount += item.amount.value + for warning in item.warnings where !warnings.contains(warning) { + warnings.append(warning) + } + return true + } + + func lineItem(currency: String) -> ModelCostLineItem { + ModelCostLineItem( + usage: NormalizedModelUsage( + provider: provider, + modelID: modelID, + timestamp: latestTimestamp, + uncachedInputTokens: uncachedInputTokens, + cacheReadTokens: cacheReadTokens, + cacheWriteTokens: cacheWriteTokens, + outputTokens: outputTokens), + amount: CurrencyAmount(value: amount, currency: currency), + warnings: warnings) + } +} diff --git a/Sources/TokenLinkApp/LocalUsage/JSONLStreamingReader.swift b/Sources/TokenLinkApp/LocalUsage/JSONLStreamingReader.swift new file mode 100644 index 0000000..6bfd067 --- /dev/null +++ b/Sources/TokenLinkApp/LocalUsage/JSONLStreamingReader.swift @@ -0,0 +1,145 @@ +import Darwin +import Foundation + +public enum JSONLStreamingReaderError: Error, Equatable, Sendable { + case notRegularFile + case openFailed(Int32) + case inspectFailed(Int32) +} + +public struct JSONLReadReport: Equatable, Sendable { + public let bytesRead: Int + public let deliveredRecordCount: Int + public let oversizedRecordCount: Int + public let byteLimitExceeded: Bool + + public init( + bytesRead: Int, + deliveredRecordCount: Int, + oversizedRecordCount: Int, + byteLimitExceeded: Bool = false + ) { + self.bytesRead = bytesRead + self.deliveredRecordCount = deliveredRecordCount + self.oversizedRecordCount = oversizedRecordCount + self.byteLimitExceeded = byteLimitExceeded + } +} + +public struct JSONLStreamingReader: Sendable { + public static let chunkBytes = 65_536 + public static let maximumRecordBytes = 1_048_576 + + public init() {} + + public func read( + url: URL, + maximumBytes: Int? = nil, + onRecord: (Data) -> Void + ) throws -> JSONLReadReport { + try Task.checkCancellation() + let handle = try openRegularFile(at: url) + defer { try? handle.close() } + + var currentRecord = Data() + var discardingOversizedRecord = false + var bytesRead = 0 + var deliveredRecordCount = 0 + var oversizedRecordCount = 0 + var byteLimitExceeded = false + let byteLimit = maximumBytes.map { max(0, $0) } + + while true { + try Task.checkCancellation() + let readCount: Int + if let byteLimit { + let remaining = byteLimit - bytesRead + readCount = remaining >= Self.chunkBytes ? Self.chunkBytes : max(1, remaining + 1) + } else { + readCount = Self.chunkBytes + } + guard var chunk = try handle.read(upToCount: readCount), !chunk.isEmpty else { + break + } + var stopAfterChunk = false + if let byteLimit { + let remaining = max(0, byteLimit - bytesRead) + if chunk.count > remaining { + chunk = Data(chunk.prefix(remaining)) + byteLimitExceeded = true + stopAfterChunk = true + } + } + bytesRead += chunk.count + var segmentStart = chunk.startIndex + + for index in chunk.indices where chunk[index] == 0x0A { + try Task.checkCancellation() + if discardingOversizedRecord { + discardingOversizedRecord = false + } else { + let segment = chunk[segmentStart.. Self.maximumRecordBytes { + oversizedRecordCount += 1 + currentRecord.removeAll(keepingCapacity: false) + } else { + currentRecord.append(contentsOf: segment) + if !currentRecord.isEmpty { + onRecord(currentRecord) + deliveredRecordCount += 1 + currentRecord.removeAll(keepingCapacity: false) + } + } + } + segmentStart = chunk.index(after: index) + } + + if segmentStart < chunk.endIndex, !discardingOversizedRecord { + let tail = chunk[segmentStart.. Self.maximumRecordBytes { + oversizedRecordCount += 1 + currentRecord.removeAll(keepingCapacity: false) + discardingOversizedRecord = true + } else { + currentRecord.append(contentsOf: tail) + } + } + if stopAfterChunk { break } + } + + if !byteLimitExceeded, !discardingOversizedRecord, !currentRecord.isEmpty { + try Task.checkCancellation() + onRecord(currentRecord) + deliveredRecordCount += 1 + } + return JSONLReadReport( + bytesRead: bytesRead, + deliveredRecordCount: deliveredRecordCount, + oversizedRecordCount: oversizedRecordCount, + byteLimitExceeded: byteLimitExceeded) + } + + private func openRegularFile(at url: URL) throws -> FileHandle { + let descriptor = url.withUnsafeFileSystemRepresentation { path in + guard let path else { return Int32(-1) } + return Darwin.open(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK) + } + guard descriptor >= 0 else { + let code = errno + if code == ELOOP { throw JSONLStreamingReaderError.notRegularFile } + throw JSONLStreamingReaderError.openFailed(code) + } + + var metadata = stat() + guard fstat(descriptor, &metadata) == 0 else { + let code = errno + Darwin.close(descriptor) + throw JSONLStreamingReaderError.inspectFailed(code) + } + guard metadata.st_mode & S_IFMT == S_IFREG else { + Darwin.close(descriptor) + throw JSONLStreamingReaderError.notRegularFile + } + return FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + } +} diff --git a/Sources/TokenLinkApp/LocalUsage/LocalUsageModels.swift b/Sources/TokenLinkApp/LocalUsage/LocalUsageModels.swift index 9c86a25..c499e71 100644 --- a/Sources/TokenLinkApp/LocalUsage/LocalUsageModels.swift +++ b/Sources/TokenLinkApp/LocalUsage/LocalUsageModels.swift @@ -20,13 +20,22 @@ public struct LocalUsageSummary: Equatable, Sendable { eventCount: Int = 0 ) { self.provider = provider - self.inputTokens = inputTokens - self.outputTokens = outputTokens - self.cachedInputTokens = cachedInputTokens - self.eventCount = eventCount + self.inputTokens = max(0, inputTokens) + self.outputTokens = max(0, outputTokens) + self.cachedInputTokens = max(0, cachedInputTokens) + self.eventCount = max(0, eventCount) } - public var totalTokens: Int { inputTokens + outputTokens } + public var totalTokens: Int { + saturatingTokenAdd(inputTokens, outputTokens) + } + + mutating func add(_ event: TokenUsageEvent) { + inputTokens = saturatingTokenAdd(inputTokens, event.inputTokens) + outputTokens = saturatingTokenAdd(outputTokens, event.outputTokens) + cachedInputTokens = saturatingTokenAdd(cachedInputTokens, event.cachedInputTokens) + eventCount = saturatingTokenAdd(eventCount, 1) + } } /// A single timestamped token-usage observation parsed from a transcript. @@ -43,9 +52,9 @@ public struct TokenUsageEvent: Equatable, Sendable { cachedInputTokens: Int = 0, dedupeKey: String = "" ) { self.timestamp = timestamp - self.inputTokens = inputTokens - self.outputTokens = outputTokens - self.cachedInputTokens = cachedInputTokens + self.inputTokens = max(0, inputTokens) + self.outputTokens = max(0, outputTokens) + self.cachedInputTokens = max(0, cachedInputTokens) self.dedupeKey = dedupeKey } } @@ -58,6 +67,17 @@ public protocol LocalUsageParsing: Sendable { static func parseEvents(from data: Data) -> [TokenUsageEvent] } +/// Stateful, per-file parser for model-aware API-equivalent cost estimation. +/// Implementations decode only metadata and token counters. +public protocol LocalUsageRecordParser: Sendable { + static var provider: ProviderID { get } + static var transcriptDirectories: [String] { get } + + init() + mutating func consume(_ record: Data) -> NormalizedModelUsage? + mutating func finish() +} + public enum LocalUsageAggregation { /// Sums events inside [since, now], deduplicating on non-empty keys. public static func summarize( @@ -71,11 +91,13 @@ public enum LocalUsageAggregation { if !event.dedupeKey.isEmpty && !seen.insert(event.dedupeKey).inserted { continue } - summary.inputTokens += event.inputTokens - summary.outputTokens += event.outputTokens - summary.cachedInputTokens += event.cachedInputTokens - summary.eventCount += 1 + summary.add(event) } return summary } } + +private func saturatingTokenAdd(_ lhs: Int, _ rhs: Int) -> Int { + let result = max(0, lhs).addingReportingOverflow(max(0, rhs)) + return result.overflow ? Int.max : result.partialValue +} diff --git a/Sources/TokenLinkApp/LocalUsage/LocalUsageObserver.swift b/Sources/TokenLinkApp/LocalUsage/LocalUsageObserver.swift index ad29dce..c2d727a 100644 --- a/Sources/TokenLinkApp/LocalUsage/LocalUsageObserver.swift +++ b/Sources/TokenLinkApp/LocalUsage/LocalUsageObserver.swift @@ -8,7 +8,8 @@ import TokenLinkCore /// the window, and only token counters — never message content. public struct LocalUsageObserver: @unchecked Sendable { /// Files larger than this are skipped (beta safeguard against huge logs). - public static let maxFileBytes = 50 * 1_024 * 1_024 + public static let maximumFileBytes = 52_428_800 + public static let maxFileBytes = maximumFileBytes private let homeURL: URL private let fileManager: FileManager @@ -25,28 +26,144 @@ public struct LocalUsageObserver: @unchecked Sendable { public func summarize( _ parser: P.Type, since: Date ) -> LocalUsageSummary { - var events: [TokenUsageEvent] = [] - for relative in P.transcriptDirectories { - let directory = homeURL.appending(path: relative, directoryHint: .isDirectory) + (try? scan(parser, since: since).summary) ?? LocalUsageSummary(provider: P.provider) + } + + /// Streams eligible files in stable path order and retains only aggregate + /// counters. The report deliberately contains no file paths or raw records. + public func scan( + _ parser: P.Type, + since: Date + ) throws -> LocalUsageScanReport { + let discovery = transcriptFiles(in: P.transcriptDirectories) + let files = discovery.files + + var summary = LocalUsageSummary(provider: P.provider) + var seenEventIDs: Set = [] + var processedFileCount = 0 + var staleFileCount = 0 + var oversizedFileCount = 0 + var unreadableFileCount = discovery.unreadableDirectoryCount + var oversizedRecordCount = 0 + + for file in files { + try Task.checkCancellation() guard - let enumerator = fileManager.enumerator( - at: directory, - includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey], - options: [.skipsHiddenFiles]) - else { continue } - for case let file as URL in enumerator where file.pathExtension == "jsonl" { - guard - let values = try? file.resourceValues( - forKeys: [.contentModificationDateKey, .fileSizeKey]), - let modified = values.contentModificationDate, modified >= since, - let size = values.fileSize, size <= Self.maxFileBytes, - let data = try? Data(contentsOf: file) - else { continue } - events.append(contentsOf: P.parseEvents(from: data)) + let values = try? file.resourceValues( + forKeys: [.contentModificationDateKey, .fileSizeKey]), + let modified = values.contentModificationDate, + let size = values.fileSize + else { + unreadableFileCount += 1 + continue + } + guard modified >= since else { + staleFileCount += 1 + continue + } + guard size <= Self.maximumFileBytes else { + oversizedFileCount += 1 + continue + } + + do { + let readReport = try JSONLStreamingReader().read( + url: file, + maximumBytes: Self.maximumFileBytes + ) { record in + for event in P.parseEvents(from: record) where event.timestamp >= since { + if !event.dedupeKey.isEmpty, + !seenEventIDs.insert(event.dedupeKey).inserted + { + continue + } + summary.add(event) + } + } + processedFileCount += 1 + oversizedRecordCount += readReport.oversizedRecordCount + if readReport.byteLimitExceeded { oversizedFileCount += 1 } + } catch is CancellationError { + throw CancellationError() + } catch { + unreadableFileCount += 1 } } - return LocalUsageAggregation.summarize( - provider: P.provider, events: events, since: since) + + return LocalUsageScanReport( + summary: summary, + processedFileCount: processedFileCount, + staleFileCount: staleFileCount, + oversizedFileCount: oversizedFileCount, + unreadableFileCount: unreadableFileCount, + oversizedRecordCount: oversizedRecordCount) + } + + /// Streams model-aware usage without retaining record arrays. Parser state + /// is scoped to one file; nonempty IDs are deduplicated by the estimator. + public func scanRecords( + _ parser: P.Type, + since: Date, + through: Date, + onUsage: (NormalizedModelUsage) -> Void + ) throws -> LocalUsageRecordScanReport { + let discovery = transcriptFiles(in: P.transcriptDirectories) + let files = discovery.files + var processedFileCount = 0 + var staleFileCount = 0 + var oversizedFileCount = 0 + var unreadableFileCount = discovery.unreadableDirectoryCount + var oversizedRecordCount = 0 + + for file in files { + try Task.checkCancellation() + guard + let values = try? file.resourceValues( + forKeys: [.contentModificationDateKey, .fileSizeKey]), + let modified = values.contentModificationDate, + let size = values.fileSize + else { + unreadableFileCount += 1 + continue + } + guard modified >= since else { + staleFileCount += 1 + continue + } + guard size <= Self.maximumFileBytes else { + oversizedFileCount += 1 + continue + } + + var recordParser = P() + defer { recordParser.finish() } + do { + let readReport = try JSONLStreamingReader().read( + url: file, + maximumBytes: Self.maximumFileBytes + ) { record in + guard let usage = recordParser.consume(record), + usage.timestamp >= since, + usage.timestamp <= through + else { return } + onUsage(usage) + } + processedFileCount += 1 + oversizedRecordCount += readReport.oversizedRecordCount + if readReport.byteLimitExceeded { oversizedFileCount += 1 } + } catch is CancellationError { + throw CancellationError() + } catch { + unreadableFileCount += 1 + } + } + + return LocalUsageRecordScanReport( + processedFileCount: processedFileCount, + staleFileCount: staleFileCount, + oversizedFileCount: oversizedFileCount, + unreadableFileCount: unreadableFileCount, + oversizedRecordCount: oversizedRecordCount) } /// All currently supported local sources, in provider order. @@ -57,4 +174,91 @@ public struct LocalUsageObserver: @unchecked Sendable { summarize(KimiWireParser.self, since: since), ] } + + private func transcriptFiles(in relativeDirectories: [String]) -> TranscriptDiscovery { + var files: [URL] = [] + var unreadableDirectoryCount = 0 + for relative in relativeDirectories { + let directory = homeURL.appending(path: relative, directoryHint: .isDirectory) + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: directory.path, isDirectory: &isDirectory) else { + continue + } + guard isDirectory.boolValue else { + unreadableDirectoryCount += 1 + continue + } + guard + let enumerator = fileManager.enumerator( + at: directory, + includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey], + options: [.skipsHiddenFiles], + errorHandler: { _, _ in + unreadableDirectoryCount += 1 + return false + }) + else { + unreadableDirectoryCount += 1 + continue + } + for case let file as URL in enumerator where file.pathExtension == "jsonl" { + files.append(file.standardizedFileURL) + } + } + return TranscriptDiscovery( + files: files.sorted { $0.path < $1.path }, + unreadableDirectoryCount: unreadableDirectoryCount) + } +} + +private struct TranscriptDiscovery { + let files: [URL] + let unreadableDirectoryCount: Int +} + +public struct LocalUsageScanReport: Equatable, Sendable { + public let summary: LocalUsageSummary + public let processedFileCount: Int + public let staleFileCount: Int + public let oversizedFileCount: Int + public let unreadableFileCount: Int + public let oversizedRecordCount: Int + + public init( + summary: LocalUsageSummary, + processedFileCount: Int, + staleFileCount: Int, + oversizedFileCount: Int, + unreadableFileCount: Int, + oversizedRecordCount: Int + ) { + self.summary = summary + self.processedFileCount = processedFileCount + self.staleFileCount = staleFileCount + self.oversizedFileCount = oversizedFileCount + self.unreadableFileCount = unreadableFileCount + self.oversizedRecordCount = oversizedRecordCount + } +} + +public struct LocalUsageRecordScanReport: Equatable, Sendable { + public let processedFileCount: Int + public let staleFileCount: Int + public let oversizedFileCount: Int + public let unreadableFileCount: Int + public let oversizedRecordCount: Int + + public init( + processedFileCount: Int, + staleFileCount: Int, + oversizedFileCount: Int, + unreadableFileCount: Int, + oversizedRecordCount: Int + ) { + self.processedFileCount = processedFileCount + self.staleFileCount = staleFileCount + self.oversizedFileCount = oversizedFileCount + self.unreadableFileCount = unreadableFileCount + self.oversizedRecordCount = oversizedRecordCount + } } diff --git a/Sources/TokenLinkApp/LocalUsage/LocalUsageReaders.swift b/Sources/TokenLinkApp/LocalUsage/LocalUsageReaders.swift index ca48fa5..cc01e69 100644 --- a/Sources/TokenLinkApp/LocalUsage/LocalUsageReaders.swift +++ b/Sources/TokenLinkApp/LocalUsage/LocalUsageReaders.swift @@ -153,3 +153,319 @@ public enum KimiWireParser: LocalUsageParsing { } } } + +public struct CodexCostRecordParser: LocalUsageRecordParser { + public static let provider: ProviderID = .codex + public static let transcriptDirectories = CodexRolloutParser.transcriptDirectories + + private var currentModel: String? + private var previousTotal: CodexCostUsage? + private var needsChildBaseline = false + + public init() {} + + public mutating func consume(_ record: Data) -> NormalizedModelUsage? { + guard let decoded = try? JSONDecoder().decode(CodexCostRecord.self, from: record) else { + return nil + } + switch decoded.type { + case "session_meta": + currentModel = nil + previousTotal = nil + needsChildBaseline = + decoded.payload.threadSource == "subagent" + || decoded.payload.source?.isSubagent == true + return nil + case "turn_context": + if let model = decoded.payload.model, !model.isEmpty { + currentModel = model + } + return nil + case "event_msg": + guard decoded.payload.type == "token_count", + let model = currentModel, + let timestamp = LocalUsageTimestamp.parse(decoded.timestamp) + else { return nil } + if let total = decoded.payload.info?.totalTokenUsage { + return consumeCumulative(total, model: model, timestamp: timestamp) + } + guard let usage = decoded.payload.info?.lastTokenUsage else { return nil } + return normalized(usage, model: model, timestamp: timestamp) + default: + return nil + } + } + + public mutating func finish() { + self = Self() + } + + private mutating func consumeCumulative( + _ total: CodexCostUsage, + model: String, + timestamp: Date + ) -> NormalizedModelUsage? { + defer { previousTotal = total } + if needsChildBaseline { + needsChildBaseline = false + return nil + } + guard let previousTotal else { + return normalized(total, model: model, timestamp: timestamp) + } + guard total.isAtLeast(previousTotal) else { + return nil + } + return normalized(total.subtracting(previousTotal), model: model, timestamp: timestamp) + } + + private func normalized( + _ usage: CodexCostUsage, + model: String, + timestamp: Date + ) -> NormalizedModelUsage? { + guard usage.hasTokens else { return nil } + return NormalizedModelUsage( + provider: Self.provider, + modelID: model, + timestamp: timestamp, + uncachedInputTokens: max(usage.inputTokens - usage.cachedInputTokens, 0), + cacheReadTokens: usage.cachedInputTokens, + cacheWriteTokens: usage.cacheWriteInputTokens, + outputTokens: usage.outputTokens) + } +} + +public struct ClaudeCostRecordParser: LocalUsageRecordParser { + public static let provider: ProviderID = .claude + public static let transcriptDirectories = ClaudeTranscriptParser.transcriptDirectories + + private var seenMessageIDs: Set = [] + + public init() {} + + public mutating func consume(_ record: Data) -> NormalizedModelUsage? { + guard let decoded = try? JSONDecoder().decode(ClaudeCostRecord.self, from: record), + let message = decoded.message, + let usage = message.usage, + let model = message.model, + !model.isEmpty, + let timestamp = LocalUsageTimestamp.parse(decoded.timestamp) + else { return nil } + let messageID = message.id ?? "" + if !messageID.isEmpty, !seenMessageIDs.insert(messageID).inserted { + return nil + } + return NormalizedModelUsage( + provider: Self.provider, + modelID: model, + timestamp: timestamp, + uncachedInputTokens: usage.inputTokens, + cacheReadTokens: usage.cacheReadInputTokens, + cacheWriteTokens: usage.cacheCreationInputTokens, + outputTokens: usage.outputTokens, + deduplicationKey: messageID) + } + + public mutating func finish() { + seenMessageIDs.removeAll(keepingCapacity: false) + } +} + +public struct KimiCostRecordParser: LocalUsageRecordParser { + public static let provider: ProviderID = .kimi + public static let transcriptDirectories = KimiWireParser.transcriptDirectories + + public init() {} + + public mutating func consume(_ record: Data) -> NormalizedModelUsage? { + guard let decoded = try? JSONDecoder().decode(KimiCostRecord.self, from: record), + decoded.type == "usage.record", + let usage = decoded.usage, + let model = decoded.model, + !model.isEmpty + else { return nil } + return NormalizedModelUsage( + provider: Self.provider, + modelID: model, + timestamp: Date(timeIntervalSince1970: decoded.time / 1_000), + uncachedInputTokens: usage.inputOther, + cacheReadTokens: usage.inputCacheRead, + cacheWriteTokens: usage.inputCacheCreation, + outputTokens: usage.output) + } + + public mutating func finish() {} +} + +private struct CodexCostRecord: Decodable { + let timestamp: String + let type: String + let payload: Payload + + struct Payload: Decodable { + let type: String? + let model: String? + let info: Info? + let source: Source? + let threadSource: String? + + enum CodingKeys: String, CodingKey { + case type + case model + case info + case source + case threadSource = "thread_source" + } + } + + struct Info: Decodable { + let lastTokenUsage: CodexCostUsage? + let totalTokenUsage: CodexCostUsage? + + enum CodingKeys: String, CodingKey { + case lastTokenUsage = "last_token_usage" + case totalTokenUsage = "total_token_usage" + } + } + + struct Source: Decodable { + let isSubagent: Bool + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(String.self) { + isSubagent = value == "subagent" + return + } + let keyed = try decoder.container(keyedBy: DynamicCodingKey.self) + isSubagent = keyed.contains(DynamicCodingKey("subagent")) + } + } +} + +private struct CodexCostUsage: Decodable { + let inputTokens: Int + let cachedInputTokens: Int + let cacheWriteInputTokens: Int + let outputTokens: Int + + enum CodingKeys: String, CodingKey { + case inputTokens = "input_tokens" + case cachedInputTokens = "cached_input_tokens" + case cacheWriteInputTokens = "cache_write_input_tokens" + case outputTokens = "output_tokens" + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + inputTokens = max(try container.decodeIfPresent(Int.self, forKey: .inputTokens) ?? 0, 0) + cachedInputTokens = max( + try container.decodeIfPresent(Int.self, forKey: .cachedInputTokens) ?? 0, + 0) + cacheWriteInputTokens = max( + try container.decodeIfPresent(Int.self, forKey: .cacheWriteInputTokens) ?? 0, + 0) + outputTokens = max(try container.decodeIfPresent(Int.self, forKey: .outputTokens) ?? 0, 0) + } + + private init( + inputTokens: Int, + cachedInputTokens: Int, + cacheWriteInputTokens: Int, + outputTokens: Int + ) { + self.inputTokens = inputTokens + self.cachedInputTokens = cachedInputTokens + self.cacheWriteInputTokens = cacheWriteInputTokens + self.outputTokens = outputTokens + } + + var hasTokens: Bool { + inputTokens > 0 || cachedInputTokens > 0 || cacheWriteInputTokens > 0 + || outputTokens > 0 + } + + func isAtLeast(_ other: Self) -> Bool { + inputTokens >= other.inputTokens + && cachedInputTokens >= other.cachedInputTokens + && cacheWriteInputTokens >= other.cacheWriteInputTokens + && outputTokens >= other.outputTokens + } + + func subtracting(_ other: Self) -> Self { + Self( + inputTokens: inputTokens - other.inputTokens, + cachedInputTokens: cachedInputTokens - other.cachedInputTokens, + cacheWriteInputTokens: cacheWriteInputTokens - other.cacheWriteInputTokens, + outputTokens: outputTokens - other.outputTokens) + } +} + +private struct ClaudeCostRecord: Decodable { + let timestamp: String + let message: Message? + + struct Message: Decodable { + let id: String? + let model: String? + let usage: Usage? + } + + struct Usage: Decodable { + let inputTokens: Int + let outputTokens: Int + let cacheReadInputTokens: Int + let cacheCreationInputTokens: Int + + enum CodingKeys: String, CodingKey { + case inputTokens = "input_tokens" + case outputTokens = "output_tokens" + case cacheReadInputTokens = "cache_read_input_tokens" + case cacheCreationInputTokens = "cache_creation_input_tokens" + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + inputTokens = max(try container.decodeIfPresent(Int.self, forKey: .inputTokens) ?? 0, 0) + outputTokens = max(try container.decodeIfPresent(Int.self, forKey: .outputTokens) ?? 0, 0) + cacheReadInputTokens = max( + try container.decodeIfPresent(Int.self, forKey: .cacheReadInputTokens) ?? 0, + 0) + cacheCreationInputTokens = max( + try container.decodeIfPresent(Int.self, forKey: .cacheCreationInputTokens) ?? 0, + 0) + } + } +} + +private struct KimiCostRecord: Decodable { + let type: String + let time: Double + let model: String? + let usage: Usage? + + struct Usage: Decodable { + let inputOther: Int + let output: Int + let inputCacheRead: Int + let inputCacheCreation: Int + } +} + +private struct DynamicCodingKey: CodingKey { + let stringValue: String + let intValue: Int? = nil + + init(_ stringValue: String) { + self.stringValue = stringValue + } + + init?(stringValue: String) { + self.init(stringValue) + } + + init?(intValue: Int) { + return nil + } +} diff --git a/Sources/TokenLinkApp/Strings.swift b/Sources/TokenLinkApp/Strings.swift index f34ccca..5e91c32 100644 --- a/Sources/TokenLinkApp/Strings.swift +++ b/Sources/TokenLinkApp/Strings.swift @@ -31,9 +31,11 @@ public enum L10n { // Routes and chrome case routeOverview = "route.overview" case routeProviders = "route.providers" + case routeCosts = "route.costs" case routeStopwatch = "route.stopwatch" case routeSettings = "route.settings" case actionRefresh = "action.refresh" + case actionRefreshCosts = "action.refreshCosts" case actionControlCenter = "action.controlCenter" case actionQuit = "action.quit" case actionSave = "action.save" @@ -43,6 +45,48 @@ public enum L10n { // Menu bar case menubarNoProviders = "menubar.noProviders" case menubarEnableHint = "menubar.enableHint" + case menubarQuotaAccessibilityFormat = "menubar.quotaAccessibilityFormat" + case menubarEstimateAccessibilityFormat = "menubar.estimateAccessibilityFormat" + case menubarBalanceAccessibilityFormat = "menubar.balanceAccessibilityFormat" + case menubarCostFresh = "menubar.costFresh" + case menubarCostStale = "menubar.costStale" + case menubarCostRefreshing = "menubar.costRefreshing" + case menubarEstimateCompactFormat = "menubar.estimateCompactFormat" + case menubarBalanceCompactFormat = "menubar.balanceCompactFormat" + // Costs + case costsTitle = "costs.title" + case costsBetaBadge = "costs.betaBadge" + case costsSubtitle = "costs.subtitle" + case costsBetaOffTitle = "costs.betaOffTitle" + case costsBetaOffBody = "costs.betaOffBody" + case costsEnable = "costs.enable" + case costsAuthoritative = "costs.authoritative" + case costsAuthoritativeHint = "costs.authoritativeHint" + case costsNoAuthoritative = "costs.noAuthoritative" + case costsEstimated = "costs.estimated" + case costsEstimatedHint = "costs.estimatedHint" + case costsNoEstimates = "costs.noEstimates" + case costsSourceOfficialAPI = "costs.sourceOfficialAPI" + case costsSourceLocalTranscripts = "costs.sourceLocalTranscripts" + case costsEstimatedLabel = "costs.estimatedLabel" + case costsAvailable = "costs.available" + case costsPurchased = "costs.purchased" + case costsUsed = "costs.used" + case costsProviderUnavailable = "costs.providerUnavailable" + case costsUpdatedFormat = "costs.updatedFormat" + case costsPeriodFormat = "costs.periodFormat" + case costsCatalogFormat = "costs.catalogFormat" + case costsUnknownModelsFormat = "costs.unknownModelsFormat" + case costsDailySpend = "costs.dailySpend" + case costsWeeklySpend = "costs.weeklySpend" + case costsMonthlySpend = "costs.monthlySpend" + case costsLifetimeSpend = "costs.lifetimeSpend" + case costsNoData = "costs.noData" + case costsAssumedCache = "costs.assumedCache" + case costsPartialScanFormat = "costs.partialScanFormat" + case costsPartialSource = "costs.partialSource" + case costsUnpricedModel = "costs.unpricedModel" + case costsInvalidTokenCount = "costs.invalidTokenCount" // Quota rows and cards case quotaLeft = "quota.left" case quotaRemaining = "quota.remaining" @@ -121,6 +165,13 @@ public enum L10n { case betaScanNow = "beta.scanNow" case betaScanning = "beta.scanning" case betaNoTranscripts = "beta.noTranscripts" + case betaCosts = "beta.costs" + case betaCostsHint = "beta.costsHint" + case betaCostsMetric = "beta.costsMetric" + case betaCostsMetricHint = "beta.costsMetricHint" + case costMetricNone = "costMetric.none" + case costMetricLocalFormat = "costMetric.localFormat" + case costMetricBalanceFormat = "costMetric.balanceFormat" case watchFaceTitle = "watch.faceTitle" case watchSyncProviders = "watch.syncProviders" case watchTheme = "watch.theme" @@ -193,6 +244,11 @@ public enum L10n { case providersDeleteAccount = "providers.deleteAccount" case providersAccountAdded = "providers.accountAdded" case providersAccountRemoved = "providers.accountRemoved" + case providersCostTitle = "providers.costTitle" + case providersCostSubtitle = "providers.costSubtitle" + case providersCostBadge = "providers.costBadge" + case providersOpenRouterCostNote = "providers.openRouterCostNote" + case providersDeepSeekCostNote = "providers.deepSeekCostNote" case sourceKeychain = "source.keychain" case sourceCLI = "source.cli" case sourceEnvironment = "source.environment" @@ -224,6 +280,7 @@ public enum L10n { .routeProviders: [ .english: "Providers", .simplifiedChinese: "额度源", .japanese: "プロバイダー", ], + .routeCosts: [.english: "Costs β", .simplifiedChinese: "成本 β", .japanese: "コスト β"], .routeStopwatch: [ .english: "StopWatch", .simplifiedChinese: "StopWatch", .japanese: "StopWatch", ], @@ -232,6 +289,9 @@ public enum L10n { .japanese: "設定と診断", ], .actionRefresh: [.english: "Refresh", .simplifiedChinese: "刷新", .japanese: "更新"], + .actionRefreshCosts: [ + .english: "Refresh costs", .simplifiedChinese: "刷新成本", .japanese: "コストを更新", + ], .actionControlCenter: [ .english: "Control Center…", .simplifiedChinese: "控制中心…", .japanese: "コントロールセンター…", @@ -255,6 +315,181 @@ public enum L10n { .simplifiedChinese: "请在控制中心里启用一个额度源。", .japanese: "コントロールセンターでプロバイダーを有効にしてください。", ], + .menubarQuotaAccessibilityFormat: [ + .english: "%@ quota, %lld percent remaining", + .simplifiedChinese: "%@ 额度剩余百分之 %lld", + .japanese: "%@ クォータ、残り %lld パーセント", + ], + .menubarEstimateAccessibilityFormat: [ + .english: "%@; %@ local Estimated/API-equivalent cost %@ for the last 7 days; %@", + .simplifiedChinese: "%@;%@ 本地 Estimated/API-equivalent 成本 %@,近 7 天;%@", + .japanese: "%@、%@ のローカル Estimated/API-equivalent コスト %@、直近7日間、%@", + ], + .menubarBalanceAccessibilityFormat: [ + .english: "%@; %@ authoritative balance %@ remaining; %@", + .simplifiedChinese: "%@;%@ 权威余额剩余 %@;%@", + .japanese: "%@、%@ の正式残高は残り %@、%@", + ], + .menubarCostFresh: [ + .english: "fresh", .simplifiedChinese: "数据新鲜", .japanese: "最新", + ], + .menubarCostStale: [ + .english: "stale", .simplifiedChinese: "数据已过期", .japanese: "古いデータ", + ], + .menubarCostRefreshing: [ + .english: "refreshing with last known data", + .simplifiedChinese: "正在刷新,显示上次数据", + .japanese: "更新中、前回のデータを表示", + ], + .menubarEstimateCompactFormat: [ + .english: "≈%@/7d", .simplifiedChinese: "≈%@/7天", .japanese: "≈%@/7日", + ], + .menubarBalanceCompactFormat: [ + .english: "%@ %@ left", .simplifiedChinese: "%@ 剩余 %@", .japanese: "%@ 残り %@", + ], + .costsTitle: [ + .english: "Costs", .simplifiedChinese: "成本", .japanese: "コスト", + ], + .costsBetaBadge: [ + .english: "Beta", .simplifiedChinese: "Beta", .japanese: "ベータ", + ], + .costsSubtitle: [ + .english: + "Official balances and local estimates stay separate from coding-plan quota.", + .simplifiedChinese: "官方余额与本地估算独立于编程套餐额度。", + .japanese: "正式残高とローカル推定値は、コーディングプランのクォータとは別に扱われます。", + ], + .costsBetaOffTitle: [ + .english: "Costs beta is off", .simplifiedChinese: "成本 Beta 尚未启用", + .japanese: "コストのベータ機能はオフです", + ], + .costsBetaOffBody: [ + .english: + "Enable it to fetch opt-in official balances and estimate the API-equivalent cost of local CLI usage. Quota behavior will not change.", + .simplifiedChinese: + "启用后可获取主动配置的官方余额,并估算本地 CLI 用量的 API 等价成本;额度行为不会改变。", + .japanese: + "有効にすると、明示的に設定した正式残高を取得し、ローカル CLI 使用量の API 相当コストを推定します。クォータの動作は変わりません。", + ], + .costsEnable: [ + .english: "Enable Costs beta", .simplifiedChinese: "启用成本 Beta", + .japanese: "コストのベータ機能を有効にする", + ], + .costsAuthoritative: [ + .english: "Authoritative balances", .simplifiedChinese: "权威余额", + .japanese: "正式残高", + ], + .costsAuthoritativeHint: [ + .english: "Reported by the provider's official account API.", + .simplifiedChinese: "由服务商官方账户 API 返回。", + .japanese: "プロバイダーの正式なアカウント API が返す値です。", + ], + .costsNoAuthoritative: [ + .english: "No authoritative cost provider is enabled.", + .simplifiedChinese: "尚未启用权威成本源。", + .japanese: "有効な正式コストプロバイダーがありません。", + ], + .costsEstimated: [ + .english: "Local estimates", .simplifiedChinese: "本地估算", + .japanese: "ローカル推定", + ], + .costsEstimatedHint: [ + .english: "Seven-day, read-only pricing of supported local CLI transcripts.", + .simplifiedChinese: "只读扫描支持的本地 CLI 会话,并按近 7 天计价。", + .japanese: "対応するローカル CLI 履歴を読み取り専用で走査し、直近7日間を価格換算します。", + ], + .costsNoEstimates: [ + .english: "No supported local estimate source is available.", + .simplifiedChinese: "没有可用的本地估算源。", + .japanese: "利用可能なローカル推定ソースがありません。", + ], + .costsSourceOfficialAPI: [ + .english: "Official provider API", .simplifiedChinese: "服务商官方 API", + .japanese: "プロバイダー正式 API", + ], + .costsSourceLocalTranscripts: [ + .english: "Source: local CLI transcripts (read-only)", + .simplifiedChinese: "来源:本地 CLI 会话(只读)", + .japanese: "ソース:ローカル CLI 履歴(読み取り専用)", + ], + .costsEstimatedLabel: [ + .english: "Estimated/API-equivalent", .simplifiedChinese: "Estimated/API-equivalent", + .japanese: "Estimated/API-equivalent", + ], + .costsAvailable: [ + .english: "Available", .simplifiedChinese: "可用余额", .japanese: "利用可能", + ], + .costsPurchased: [ + .english: "Purchased / credited", .simplifiedChinese: "已购买 / 已入账", + .japanese: "購入・付与済み", + ], + .costsUsed: [ + .english: "Authoritative usage", .simplifiedChinese: "权威累计用量", + .japanese: "正式な累計使用額", + ], + .costsProviderUnavailable: [ + .english: "The provider reports that this balance is currently unavailable.", + .simplifiedChinese: "服务商报告当前余额不可用。", + .japanese: "プロバイダーは、この残高が現在利用できないと報告しています。", + ], + .costsUpdatedFormat: [ + .english: "Updated %@", .simplifiedChinese: "更新于 %@", .japanese: "%@ に更新", + ], + .costsPeriodFormat: [ + .english: "Period: %@ – %@", .simplifiedChinese: "周期:%@ 至 %@", + .japanese: "期間:%@〜%@", + ], + .costsCatalogFormat: [ + .english: "Pricing catalog %@ · effective %@", + .simplifiedChinese: "价格目录 %@ · 生效于 %@", + .japanese: "価格カタログ %@・発効 %@", + ], + .costsUnknownModelsFormat: [ + .english: "%lld unpriced model(s) excluded", + .simplifiedChinese: "已排除 %lld 个未定价模型", + .japanese: "価格未設定のモデル %lld 件を除外", + ], + .costsDailySpend: [ + .english: "Daily spend", .simplifiedChinese: "当日支出", .japanese: "日次支出", + ], + .costsWeeklySpend: [ + .english: "Weekly spend", .simplifiedChinese: "每周支出", .japanese: "週次支出", + ], + .costsMonthlySpend: [ + .english: "Monthly spend", .simplifiedChinese: "每月支出", .japanese: "月次支出", + ], + .costsLifetimeSpend: [ + .english: "Lifetime spend", .simplifiedChinese: "累计支出", .japanese: "累計支出", + ], + .costsNoData: [ + .english: "No cost snapshot yet.", .simplifiedChinese: "还没有成本快照。", + .japanese: "コストスナップショットはまだありません。", + ], + .costsAssumedCache: [ + .english: "Cache writes without a duration use the five-minute rate.", + .simplifiedChinese: "未标注时长的缓存写入按 5 分钟价格估算。", + .japanese: "期間が不明なキャッシュ書き込みには5分料金を使用しています。", + ], + .costsPartialScanFormat: [ + .english: "Partial local scan: %lld file(s), %lld record(s) skipped.", + .simplifiedChinese: "本地扫描不完整:跳过 %lld 个文件、%lld 条记录。", + .japanese: "ローカル走査は一部のみ:%lld ファイル、%lld レコードをスキップ。", + ], + .costsPartialSource: [ + .english: "The provider returned only part of the authoritative cost data.", + .simplifiedChinese: "服务商仅返回了部分权威成本数据。", + .japanese: "プロバイダーから正式コストデータの一部だけが返されました。", + ], + .costsUnpricedModel: [ + .english: "An unpriced model was excluded from the estimate.", + .simplifiedChinese: "估算中排除了未定价模型。", + .japanese: "価格未設定のモデルを推定から除外しました。", + ], + .costsInvalidTokenCount: [ + .english: "A record with an invalid token count was excluded from the estimate.", + .simplifiedChinese: "估算中排除了一条令 Token 计数溢出的记录。", + .japanese: "トークン数が不正なレコードを推定から除外しました。", + ], .quotaLeft: [.english: "left", .simplifiedChinese: "剩余", .japanese: "残り"], .quotaRemaining: [ .english: "remaining", .simplifiedChinese: "剩余", .japanese: "残り", @@ -536,6 +771,38 @@ public enum L10n { .simplifiedChinese: "没有找到支持的 CLI 本地会话记录。", .japanese: "対応する CLI のローカルセッション記録が見つかりません。", ], + .betaCosts: [ + .english: "Costs", .simplifiedChinese: "成本", .japanese: "コスト", + ], + .betaCostsHint: [ + .english: + "Fetch official balances only for explicitly configured cost providers and estimate local API-equivalent cost separately. Quota remains the primary feature.", + .simplifiedChinese: + "仅为明确配置的成本源获取官方余额,并独立估算本地 API 等价成本;额度始终是首要能力。", + .japanese: + "明示的に設定したコストプロバイダーだけ正式残高を取得し、ローカルの API 相当コストは別に推定します。クォータが常に主機能です。", + ], + .betaCostsMetric: [ + .english: "Menu bar cost metric", .simplifiedChinese: "菜单栏成本指标", + .japanese: "メニューバーのコスト指標", + ], + .betaCostsMetricHint: [ + .english: + "One fixed metric appears after the primary quota; missing data falls back to quota only.", + .simplifiedChinese: "固定选择一个指标显示在主额度之后;数据缺失时仅显示额度。", + .japanese: "固定した1つの指標を主クォータの後に表示し、データがない場合はクォータだけに戻ります。", + ], + .costMetricNone: [ + .english: "Quota only", .simplifiedChinese: "仅显示额度", .japanese: "クォータのみ", + ], + .costMetricLocalFormat: [ + .english: "%@ · Estimated 7d", .simplifiedChinese: "%@ · 近 7 天估算", + .japanese: "%@・7日間推定", + ], + .costMetricBalanceFormat: [ + .english: "%@ · %@ balance", .simplifiedChinese: "%@ · %@ 余额", + .japanese: "%@・%@ 残高", + ], .watchFaceTitle: [ .english: "Watch face (protocol v2)", .simplifiedChinese: "表盘(协议 v2)", .japanese: "ウォッチフェイス(プロトコル v2)", @@ -822,6 +1089,33 @@ public enum L10n { .english: "%@ account removed.", .simplifiedChinese: "已删除 %@ 账户。", .japanese: "%@ アカウントを削除しました。", ], + .providersCostTitle: [ + .english: "Authoritative cost providers (Beta)", + .simplifiedChinese: "权威成本源(Beta)", + .japanese: "正式コストプロバイダー(ベータ)", + ], + .providersCostSubtitle: [ + .english: "Opt-in provider balances. Cost refresh remains separate from quota refresh.", + .simplifiedChinese: "主动配置的服务商余额;成本刷新与额度刷新相互独立。", + .japanese: "オプトインのプロバイダー残高です。コスト更新はクォータ更新とは別に行われます。", + ], + .providersCostBadge: [ + .english: "Authoritative balance · Beta", .simplifiedChinese: "权威余额 · Beta", + .japanese: "正式残高・ベータ", + ], + .providersOpenRouterCostNote: [ + .english: + "Use an explicit Management Key for account credits; a regular API key may provide only current-key spend.", + .simplifiedChinese: + "请显式配置 Management Key 以读取账户余额;普通 API key 可能只能返回当前 key 的支出。", + .japanese: + "アカウント残高には Management Key を明示的に設定してください。通常の API キーでは現在のキーの支出しか取得できない場合があります。", + ], + .providersDeepSeekCostNote: [ + .english: "Use an explicit DeepSeek API key for authoritative multi-currency balances.", + .simplifiedChinese: "请显式配置 DeepSeek API key,以读取权威的多币种余额。", + .japanese: "正式な複数通貨残高には DeepSeek API キーを明示的に設定してください。", + ], .sourceKeychain: [ .english: "Keychain", .simplifiedChinese: "钥匙串", .japanese: "キーチェーン", ], diff --git a/Sources/TokenLinkApp/TokenLinkApp.swift b/Sources/TokenLinkApp/TokenLinkApp.swift index 651d636..d3e27c6 100644 --- a/Sources/TokenLinkApp/TokenLinkApp.swift +++ b/Sources/TokenLinkApp/TokenLinkApp.swift @@ -39,7 +39,7 @@ struct TokenLinkApplication: App { Text(model.menuBarLabel) } .accessibilityElement(children: .combine) - .accessibilityLabel("TokenLink, \(model.menuBarLabel)") + .accessibilityLabel(model.menuBarAccessibilityLabel) // MenuBarExtra content is lazy. Starting from the always-present label // guarantees the first refresh/BLE sync without requiring a click. .task { await model.start() } diff --git a/Sources/TokenLinkApp/Views/ControlCenterView.swift b/Sources/TokenLinkApp/Views/ControlCenterView.swift index 726dc3a..c0b633e 100644 --- a/Sources/TokenLinkApp/Views/ControlCenterView.swift +++ b/Sources/TokenLinkApp/Views/ControlCenterView.swift @@ -3,6 +3,7 @@ import SwiftUI enum ControlRoute: String, CaseIterable, Identifiable { case overview = "Overview" case providers = "Providers" + case costs = "Costs" case stopwatch = "StopWatch" case settings = "Settings & Diagnostics" var id: Self { self } @@ -11,6 +12,7 @@ enum ControlRoute: String, CaseIterable, Identifiable { switch self { case .overview: "square.grid.2x2" case .providers: "bolt.horizontal.circle" + case .costs: "dollarsign.circle" case .stopwatch: "stopwatch" case .settings: "gearshape.2" } @@ -20,6 +22,7 @@ enum ControlRoute: String, CaseIterable, Identifiable { switch self { case .overview: .routeOverview case .providers: .routeProviders + case .costs: .routeCosts case .stopwatch: .routeStopwatch case .settings: .routeSettings } @@ -46,6 +49,8 @@ struct ControlCenterView: View { OverviewView(model: model) case .providers: ProvidersView(model: model) + case .costs: + CostsView(model: model) case .stopwatch: StopWatchView(model: model) case .settings: @@ -56,11 +61,19 @@ struct ControlCenterView: View { .toolbar { ToolbarItem { Button { - Task { await model.refreshManually() } + Task { + if selection == .costs { + await model.refreshCosts(force: true) + } else { + await model.refreshManually() + } + } } label: { - Label(model.text(.actionRefresh), systemImage: "arrow.clockwise") + Label( + model.text(selection == .costs ? .actionRefreshCosts : .actionRefresh), + systemImage: "arrow.clockwise") } - .disabled(model.isRefreshing) + .disabled(selection == .costs ? model.costDashboard.isRefreshing : model.isRefreshing) } } } diff --git a/Sources/TokenLinkApp/Views/CostsView.swift b/Sources/TokenLinkApp/Views/CostsView.swift new file mode 100644 index 0000000..9728c1a --- /dev/null +++ b/Sources/TokenLinkApp/Views/CostsView.swift @@ -0,0 +1,298 @@ +import SwiftUI +import TokenLinkCore + +struct CostsView: View { + @Bindable var model: AppModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 9) { + Text(model.text(.costsTitle)) + .font(.largeTitle.bold()) + Text(model.text(.costsBetaBadge)) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.orange.opacity(0.14), in: Capsule()) + .foregroundStyle(.orange) + } + Text(model.text(.costsSubtitle)) + .foregroundStyle(.secondary) + } + + if model.configuration.betaCostsEnabled { + authoritativeSection + estimateSection + } else { + betaDisabledCard + } + } + .padding(28) + .frame(maxWidth: 880, alignment: .leading) + } + .background(Color(nsColor: .controlBackgroundColor).opacity(0.55)) + .navigationTitle(model.text(.costsTitle)) + .task { await model.loadCostsIfNeeded() } + } + + private var betaDisabledCard: some View { + VStack(alignment: .leading, spacing: 10) { + Label(model.text(.costsBetaOffTitle), systemImage: "flask") + .font(.headline) + Text(model.text(.costsBetaOffBody)) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Button(model.text(.costsEnable)) { + Task { + try? await model.setBetaCostsEnabled(true) + await model.loadCostsIfNeeded() + } + } + } + .costCard() + } + + private var authoritativeSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionHeader( + title: model.text(.costsAuthoritative), + subtitle: model.text(.costsAuthoritativeHint), + symbol: "building.columns") + if model.costDashboard.authoritativeRows.isEmpty { + Text(model.text(.costsNoAuthoritative)) + .foregroundStyle(.secondary) + .costCard() + } else { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 310), spacing: 14)], spacing: 14) { + ForEach(model.costDashboard.authoritativeRows) { row in + authoritativeCard(row) + } + } + } + } + } + + private var estimateSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionHeader( + title: model.text(.costsEstimated), + subtitle: model.text(.costsEstimatedHint), + symbol: "function") + if model.costDashboard.estimateRows.isEmpty { + Text(model.text(.costsNoEstimates)) + .foregroundStyle(.secondary) + .costCard() + } else { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 310), spacing: 14)], spacing: 14) { + ForEach(model.costDashboard.estimateRows) { row in + estimateCard(row) + } + } + } + } + } + + private func sectionHeader(title: String, subtitle: String, symbol: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Label(title, systemImage: symbol) + .font(.title2.bold()) + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private func authoritativeCard(_ row: AuthoritativeCostRow) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + ProviderMark(provider: row.source.provider, size: 34) + VStack(alignment: .leading, spacing: 2) { + Text(accountLabel(row.source.accountID, provider: row.source.provider)) + .font(.headline) + Text(model.text(.costsSourceOfficialAPI)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + PhaseBadge(phase: row.state.phase) + } + + if let snapshot = row.state.snapshot { + ForEach(Array(snapshot.balances.enumerated()), id: \.offset) { _, balance in + VStack(alignment: .leading, spacing: 7) { + HStack(alignment: .firstTextBaseline) { + Text(model.text(.costsAvailable)) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Text(CostFormatting.amount(balance.available, language: model.currentLanguage)) + .font(.title3.weight(.semibold)) + .monospacedDigit() + } + if let purchased = balance.purchased { + amountDetail(model.text(.costsPurchased), amount: purchased) + } + if let used = balance.used { + amountDetail(model.text(.costsUsed), amount: used) + } + } + } + ForEach(Array(snapshot.periodSpend.enumerated()), id: \.offset) { _, spend in + LabeledContent(periodText(spend.period)) { + Text(CostFormatting.amount(spend.amount, language: model.currentLanguage)) + .monospacedDigit() + } + .font(.caption) + } + metadataLine( + String( + format: model.text(.costsUpdatedFormat), + snapshot.fetchedAt.formatted(date: .abbreviated, time: .shortened))) + if snapshot.isAvailable == false { + warningLine(model.text(.costsProviderUnavailable)) + } + warningList(snapshot.warnings) + if let error = row.state.error { + warningLine(error.message) + } + } else { + emptyState(row.state.error) + } + } + .costCard() + } + + private func estimateCard(_ row: EstimatedCostRow) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + ProviderMark(provider: row.provider, size: 34) + VStack(alignment: .leading, spacing: 2) { + Text(AppModel.displayName(for: row.provider)) + .font(.headline) + Text(model.text(.costsEstimatedLabel)) + .font(.caption.weight(.semibold)) + .foregroundStyle(.orange) + } + Spacer() + PhaseBadge(phase: row.state.phase) + } + + if let snapshot = row.state.snapshot { + ForEach(Array(snapshot.totals.enumerated()), id: \.offset) { _, total in + Text("≈\(CostFormatting.amount(total, language: model.currentLanguage))") + .font(.title3.weight(.semibold)) + .monospacedDigit() + } + metadataLine(model.text(.costsSourceLocalTranscripts)) + metadataLine( + String( + format: model.text(.costsPeriodFormat), + snapshot.period.start.formatted(date: .abbreviated, time: .omitted), + snapshot.period.end.formatted(date: .abbreviated, time: .omitted))) + metadataLine( + String( + format: model.text(.costsCatalogFormat), + snapshot.catalogVersion, + snapshot.catalogEffectiveDate.formatted(date: .abbreviated, time: .omitted))) + metadataLine( + String( + format: model.text(.costsUpdatedFormat), + snapshot.scannedAt.formatted(date: .abbreviated, time: .shortened))) + if !snapshot.unknownModelIDs.isEmpty { + warningLine( + String( + format: model.text(.costsUnknownModelsFormat), + snapshot.unknownModelIDs.count)) + } + warningList(snapshot.warnings) + if let error = row.state.error { + warningLine(error.message) + } + } else { + emptyState(row.state.error) + } + } + .costCard() + } + + private func accountLabel(_ accountID: UUID, provider: ProviderID) -> String { + model.configuration.accounts.first(where: { $0.id == accountID })?.label + ?? AppModel.displayName(for: provider) + } + + private func periodText(_ period: ProviderSpendPeriod) -> String { + switch period { + case .daily: model.text(.costsDailySpend) + case .weekly: model.text(.costsWeeklySpend) + case .monthly: model.text(.costsMonthlySpend) + case .lifetime: model.text(.costsLifetimeSpend) + } + } + + @ViewBuilder + private func emptyState(_ error: ProviderFailure?) -> some View { + Text(error?.message ?? model.text(.costsNoData)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + private func metadataLine(_ value: String) -> some View { + Text(value) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + private func amountDetail(_ label: String, amount: CurrencyAmount) -> some View { + LabeledContent(label) { + Text(CostFormatting.amount(amount, language: model.currentLanguage)).monospacedDigit() + } + .font(.caption) + } + + private func warningLine(_ value: String) -> some View { + Label(value, systemImage: "exclamationmark.triangle") + .font(.caption2) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + + @ViewBuilder + private func warningList(_ warnings: [CostWarning]) -> some View { + ForEach(Array(warnings.enumerated()), id: \.offset) { _, warning in + warningLine(warningText(warning)) + } + } + + private func warningText(_ warning: CostWarning) -> String { + switch warning { + case .assumedFiveMinuteCacheWrite: + model.text(.costsAssumedCache) + case .partialLocalScan(let fileCount, let recordCount): + String(format: model.text(.costsPartialScanFormat), fileCount, recordCount) + case .partialSource: + model.text(.costsPartialSource) + case .unpricedModel: + model.text(.costsUnpricedModel) + case .invalidTokenCount: + model.text(.costsInvalidTokenCount) + } + } +} + +extension View { + fileprivate func costCard() -> some View { + self + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.background, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(.quaternary) + } + } +} diff --git a/Sources/TokenLinkApp/Views/MenuBarView.swift b/Sources/TokenLinkApp/Views/MenuBarView.swift index cd5b14d..a7569d6 100644 --- a/Sources/TokenLinkApp/Views/MenuBarView.swift +++ b/Sources/TokenLinkApp/Views/MenuBarView.swift @@ -19,6 +19,12 @@ struct MenuBarView: View { Text("StopWatch · \(model.deviceStatusText)") .font(.caption) .foregroundStyle(.secondary) + if model.menuBarLabel != "TokenLink" { + Text(model.menuBarLabel) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .monospacedDigit() + } } Spacer() if model.isRefreshing { diff --git a/Sources/TokenLinkApp/Views/ProvidersView.swift b/Sources/TokenLinkApp/Views/ProvidersView.swift index 02956c9..61032b6 100644 --- a/Sources/TokenLinkApp/Views/ProvidersView.swift +++ b/Sources/TokenLinkApp/Views/ProvidersView.swift @@ -40,10 +40,23 @@ struct ProvidersView: View { legacyCredentialMigrationBanner } - ForEach(ProviderID.allCases, id: \.self) { provider in + ForEach(ProviderRegistry.quotaProviderIDs, id: \.self) { provider in providerSection(provider) } + VStack(alignment: .leading, spacing: 5) { + Text(model.text(.providersCostTitle)) + .font(.title2.bold()) + Text(model.text(.providersCostSubtitle)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.top, 8) + + ForEach(ProviderRegistry.authoritativeCostProviderIDs, id: \.self) { provider in + costProviderSection(provider) + } + if let message { Text(message) .font(.caption) @@ -111,6 +124,78 @@ struct ProvidersView: View { .background(.blue.opacity(0.08), in: RoundedRectangle(cornerRadius: 12)) } + @ViewBuilder + private func costProviderSection(_ provider: ProviderID) -> some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 12) { + ProviderMark(provider: provider, size: 42) + VStack(alignment: .leading, spacing: 2) { + Text(AppModel.displayName(for: provider)) + .font(.headline) + Text(model.text(.providersCostBadge)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Toggle( + model.text(.providersEnabled), + isOn: Binding( + get: { model.configuration.enabledProviders.contains(provider) }, + set: { enabled in + do { try model.setProvider(provider, enabled: enabled) } catch { + message = error.localizedDescription + } + }) + ) + .toggleStyle(.switch) + .labelsHidden() + } + + Divider() + let group = model.costAccountGroups.first { $0.provider == provider } + ForEach(group?.accounts ?? []) { account in + accountRow(account) + if account.id != group?.accounts.last?.id { + Divider() + } + } + costCredentialHelp(provider) + addAccountSection(provider) + } + .padding(20) + .background(.background, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(.quaternary) + } + } + + @ViewBuilder + private func costCredentialHelp(_ provider: ProviderID) -> some View { + switch provider { + case .openrouter: + Text(model.text(.providersOpenRouterCostNote)) + .font(.caption) + .foregroundStyle(.secondary) + Link( + model.text(.providersGetKey), + destination: URL(string: "https://openrouter.ai/settings/keys")! + ) + .font(.caption) + case .deepseek: + Text(model.text(.providersDeepSeekCostNote)) + .font(.caption) + .foregroundStyle(.secondary) + Link( + model.text(.providersGetKey), + destination: URL(string: "https://platform.deepseek.com/api_keys")! + ) + .font(.caption) + default: + EmptyView() + } + } + @ViewBuilder private func providerSection(_ provider: ProviderID) -> some View { VStack(alignment: .leading, spacing: 16) { @@ -486,7 +571,7 @@ struct ProvidersView: View { private func loadKeyHints() async { var hints: [UUID: String] = [:] - for group in model.accountGroups { + for group in model.accountGroups + model.costAccountGroups { for account in group.accounts { hints[account.id] = await model.keyHint(for: account.id) } @@ -510,6 +595,7 @@ struct ProvidersView: View { case .minimax: .subtitleMinimax case .glm: .subtitleGLM case .claude: .subtitleClaude + case .openrouter, .deepseek: .providersSubtitle } } } diff --git a/Sources/TokenLinkApp/Views/SettingsView.swift b/Sources/TokenLinkApp/Views/SettingsView.swift index 06eae33..59a2511 100644 --- a/Sources/TokenLinkApp/Views/SettingsView.swift +++ b/Sources/TokenLinkApp/Views/SettingsView.swift @@ -1,5 +1,13 @@ import AppKit import SwiftUI +import TokenLinkCore +import TokenLinkProviders + +private struct CostMetricOption: Identifiable { + let metric: MenuBarCostMetric + let label: String + var id: MenuBarCostMetric { metric } +} struct SettingsView: View { @Bindable var model: AppModel @@ -180,6 +188,55 @@ struct SettingsView: View { } .disabled(model.isScanningLocalUsage) } + + Divider() + + LabeledContent(model.text(.betaCosts)) { + Toggle( + model.text(.betaCosts), + isOn: Binding( + get: { model.configuration.betaCostsEnabled }, + set: { enabled in + Task { @MainActor in + do { + try await model.setBetaCostsEnabled(enabled) + } catch { + message = error.localizedDescription + } + } + }) + ) + .toggleStyle(.switch) + .labelsHidden() + } + Text(model.text(.betaCostsHint)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if model.configuration.betaCostsEnabled { + LabeledContent(model.text(.betaCostsMetric)) { + Picker( + model.text(.betaCostsMetric), + selection: Binding( + get: { model.configuration.menuBarCostMetric }, + set: { metric in + do { try model.setMenuBarCostMetric(metric) } catch { + message = error.localizedDescription + } + }) + ) { + ForEach(costMetricOptions) { option in + Text(option.label).tag(option.metric) + } + } + .labelsHidden() + .frame(width: 250) + } + Text(model.text(.betaCostsMetricHint)) + .font(.caption) + .foregroundStyle(.secondary) + } } .settingsCard() } @@ -192,6 +249,45 @@ struct SettingsView: View { return "\(total) tok (\(cached) cached)" } + private var costMetricOptions: [CostMetricOption] { + var options = [ + CostMetricOption(metric: .none, label: model.text(.costMetricNone)) + ] + options += ProviderRegistry.localCostEstimateProviderIDs.map { provider in + CostMetricOption( + metric: .localEstimate(provider), + label: String( + format: model.text(.costMetricLocalFormat), + AppModel.displayName(for: provider))) + } + for row in model.costDashboard.authoritativeRows { + let providerName = AppModel.displayName(for: row.source.provider) + let currencies = row.state.snapshot?.balances.map(\.available.currency) ?? [] + for currency in currencies { + options.append( + CostMetricOption( + metric: .authoritativeBalance( + accountID: row.source.accountID, currency: currency), + label: String( + format: model.text(.costMetricBalanceFormat), providerName, currency))) + } + } + let selected = model.configuration.menuBarCostMetric + if !options.contains(where: { $0.metric == selected }), + case .authoritativeBalance(let accountID, let currency) = selected + { + let provider = model.configuration.accounts.first(where: { $0.id == accountID })?.provider + options.append( + CostMetricOption( + metric: selected, + label: String( + format: model.text(.costMetricBalanceFormat), + provider.map(AppModel.displayName(for:)) ?? model.text(.costsAuthoritative), + currency))) + } + return options + } + private var privacyCard: some View { VStack(alignment: .leading, spacing: 12) { Label(model.text(.settingsPrivacyTitle), systemImage: "lock.shield") diff --git a/Sources/TokenLinkApp/Views/ViewSupport.swift b/Sources/TokenLinkApp/Views/ViewSupport.swift index ca70cb8..e957fb0 100644 --- a/Sources/TokenLinkApp/Views/ViewSupport.swift +++ b/Sources/TokenLinkApp/Views/ViewSupport.swift @@ -20,6 +20,8 @@ enum ProviderPresentation { case .minimax: "sparkles" case .glm: "cube.transparent.fill" case .claude: "sun.max.fill" + case .openrouter: "arrow.triangle.branch" + case .deepseek: "wave.3.right.circle.fill" } } @@ -30,6 +32,8 @@ enum ProviderPresentation { case .minimax: Color(red: 0.95, green: 0.49, blue: 0.32) case .glm: Color(red: 0.30, green: 0.66, blue: 0.95) case .claude: Color(red: 0.85, green: 0.55, blue: 0.35) + case .openrouter: Color(red: 0.35, green: 0.35, blue: 0.42) + case .deepseek: Color(red: 0.21, green: 0.38, blue: 0.94) } } @@ -75,6 +79,8 @@ struct ProviderMark: View { .frame(width: size, height: size) .clipShape(RoundedRectangle(cornerRadius: size * 0.28, style: .continuous)) } else { + // Cost-only providers intentionally use a code-native symbol until a + // reviewed, redistributable brand asset is added to the bundle. Image(systemName: ProviderPresentation.symbol(for: provider)) .font(.system(size: size * 0.42, weight: .semibold)) .foregroundStyle(.white) diff --git a/Sources/TokenLinkApp/Views/WatchFaceSettingsView.swift b/Sources/TokenLinkApp/Views/WatchFaceSettingsView.swift index 4a9aaf6..ef1379c 100644 --- a/Sources/TokenLinkApp/Views/WatchFaceSettingsView.swift +++ b/Sources/TokenLinkApp/Views/WatchFaceSettingsView.swift @@ -253,9 +253,7 @@ struct WatchFaceSettingsView: View { } private var enabledProviders: [ProviderID] { - ProviderID.allCases.filter { provider in - model.configuration.accounts.contains { $0.provider == provider && $0.enabled } - } + model.watchEligibleProviders } private var negotiatedProtocolText: String { diff --git a/Sources/TokenLinkCore/CostModels.swift b/Sources/TokenLinkCore/CostModels.swift new file mode 100644 index 0000000..c9f0806 --- /dev/null +++ b/Sources/TokenLinkCore/CostModels.swift @@ -0,0 +1,296 @@ +import Foundation + +public enum MenuBarCostMetric: Codable, Equatable, Hashable, Sendable { + case none + case localEstimate(ProviderID) + case authoritativeBalance(accountID: UUID, currency: String) +} + +public struct CurrencyAmount: Equatable, Sendable { + public let value: Decimal + public let currency: String + + public init(value: Decimal, currency: String) { + self.value = value + self.currency = currency.uppercased() + } +} + +public struct AccountBalance: Equatable, Sendable { + public let available: CurrencyAmount + public let purchased: CurrencyAmount? + public let used: CurrencyAmount? + + public init( + currency: String, + available: Decimal, + purchased: Decimal? = nil, + used: Decimal? = nil + ) { + self.available = CurrencyAmount(value: available, currency: currency) + self.purchased = purchased.map { CurrencyAmount(value: $0, currency: currency) } + self.used = used.map { CurrencyAmount(value: $0, currency: currency) } + } +} + +public enum ProviderSpendPeriod: String, Codable, Equatable, Sendable { + case daily + case weekly + case monthly + case lifetime +} + +public struct ProviderPeriodSpend: Equatable, Sendable { + public let period: ProviderSpendPeriod + public let amount: CurrencyAmount + + public init(period: ProviderSpendPeriod, amount: CurrencyAmount) { + self.period = period + self.amount = amount + } +} + +public enum CostWarning: Equatable, Sendable { + case assumedFiveMinuteCacheWrite + case partialLocalScan(fileCount: Int, recordCount: Int) + case partialSource(String) + case unpricedModel(String) + case invalidTokenCount +} + +public struct AuthoritativeCostSnapshot: Equatable, Sendable { + public let provider: ProviderID + public let balances: [AccountBalance] + public let periodSpend: [ProviderPeriodSpend] + public let isAvailable: Bool? + public let warnings: [CostWarning] + public let fetchedAt: Date + + public init( + provider: ProviderID, + balances: [AccountBalance], + periodSpend: [ProviderPeriodSpend] = [], + isAvailable: Bool? = nil, + warnings: [CostWarning] = [], + fetchedAt: Date + ) { + self.provider = provider + self.balances = balances + self.periodSpend = periodSpend + self.isAvailable = isAvailable + self.warnings = warnings + self.fetchedAt = fetchedAt + } +} + +public enum CacheWriteDuration: String, Codable, Equatable, Sendable { + case fiveMinutes + case oneHour +} + +public struct NormalizedModelUsage: Equatable, Sendable { + public let provider: ProviderID + public let modelID: String + public let timestamp: Date + public let uncachedInputTokens: Int + public let cacheReadTokens: Int + public let cacheWriteTokens: Int + public let cacheWriteDuration: CacheWriteDuration? + public let outputTokens: Int + public let deduplicationKey: String + + public init( + provider: ProviderID, + modelID: String, + timestamp: Date, + uncachedInputTokens: Int = 0, + cacheReadTokens: Int = 0, + cacheWriteTokens: Int = 0, + cacheWriteDuration: CacheWriteDuration? = nil, + outputTokens: Int = 0, + deduplicationKey: String = "" + ) { + self.provider = provider + self.modelID = modelID + self.timestamp = timestamp + self.uncachedInputTokens = max(0, uncachedInputTokens) + self.cacheReadTokens = max(0, cacheReadTokens) + self.cacheWriteTokens = max(0, cacheWriteTokens) + self.cacheWriteDuration = cacheWriteDuration + self.outputTokens = max(0, outputTokens) + self.deduplicationKey = deduplicationKey + } + + public var totalInputTokens: Int { + let first = uncachedInputTokens.addingReportingOverflow(cacheReadTokens) + if first.overflow { return Int.max } + let second = first.partialValue.addingReportingOverflow(cacheWriteTokens) + return second.overflow ? Int.max : second.partialValue + } +} + +public struct LongContextPricing: Equatable, Sendable { + public let thresholdInputTokens: Int + public let inputMultiplier: Decimal + public let outputMultiplier: Decimal + + public init( + thresholdInputTokens: Int, + inputMultiplier: Decimal, + outputMultiplier: Decimal + ) { + self.thresholdInputTokens = max(0, thresholdInputTokens) + self.inputMultiplier = inputMultiplier + self.outputMultiplier = outputMultiplier + } +} + +public struct ModelPrice: Equatable, Sendable { + public let provider: ProviderID + public let modelID: String + public let aliases: [String] + public let currency: String + public let uncachedInputPerMillion: Decimal? + public let cacheReadPerMillion: Decimal? + public let cacheWriteFiveMinutePerMillion: Decimal? + public let cacheWriteOneHourPerMillion: Decimal? + public let outputPerMillion: Decimal? + public let sourceURL: URL + public let longContext: LongContextPricing? + + public init( + provider: ProviderID, + modelID: String, + aliases: [String], + currency: String, + uncachedInputPerMillion: Decimal?, + cacheReadPerMillion: Decimal?, + cacheWriteFiveMinutePerMillion: Decimal? = nil, + cacheWriteOneHourPerMillion: Decimal? = nil, + outputPerMillion: Decimal?, + sourceURL: URL, + longContext: LongContextPricing? = nil + ) { + self.provider = provider + self.modelID = modelID + self.aliases = aliases + self.currency = currency.uppercased() + self.uncachedInputPerMillion = uncachedInputPerMillion + self.cacheReadPerMillion = cacheReadPerMillion + self.cacheWriteFiveMinutePerMillion = cacheWriteFiveMinutePerMillion + self.cacheWriteOneHourPerMillion = cacheWriteOneHourPerMillion + self.outputPerMillion = outputPerMillion + self.sourceURL = sourceURL + self.longContext = longContext + } +} + +public struct ModelCostLineItem: Equatable, Sendable { + public let usage: NormalizedModelUsage + public let amount: CurrencyAmount + public let warnings: [CostWarning] + + public init( + usage: NormalizedModelUsage, + amount: CurrencyAmount, + warnings: [CostWarning] = [] + ) { + self.usage = usage + self.amount = amount + self.warnings = warnings + } +} + +public struct EstimatedCostSnapshot: Equatable, Sendable { + public let provider: ProviderID + public let period: DateInterval + public let lineItems: [ModelCostLineItem] + public let totals: [CurrencyAmount] + public let unknownModelIDs: [String] + public let warnings: [CostWarning] + public let catalogVersion: String + public let catalogEffectiveDate: Date + public let scannedAt: Date + + public init( + provider: ProviderID, + period: DateInterval, + lineItems: [ModelCostLineItem], + totals: [CurrencyAmount], + unknownModelIDs: [String], + warnings: [CostWarning] = [], + catalogVersion: String, + catalogEffectiveDate: Date, + scannedAt: Date + ) { + self.provider = provider + self.period = period + self.lineItems = lineItems + self.totals = totals + self.unknownModelIDs = unknownModelIDs + self.warnings = warnings + self.catalogVersion = catalogVersion + self.catalogEffectiveDate = catalogEffectiveDate + self.scannedAt = scannedAt + } +} + +public enum CostCalculator { + private static let tokensPerMillion = Decimal(1_000_000) + + public static func lineItem( + usage: NormalizedModelUsage, + price: ModelPrice + ) -> ModelCostLineItem? { + guard usage.provider == price.provider else { return nil } + + var warnings: [CostWarning] = [] + let cacheWriteRate: Decimal? + switch usage.cacheWriteDuration { + case .oneHour: + cacheWriteRate = price.cacheWriteOneHourPerMillion + case .fiveMinutes: + cacheWriteRate = price.cacheWriteFiveMinutePerMillion + case nil: + cacheWriteRate = price.cacheWriteFiveMinutePerMillion + if usage.cacheWriteTokens > 0 { + warnings.append(.assumedFiveMinuteCacheWrite) + } + } + + guard let input = cost(tokens: usage.uncachedInputTokens, rate: price.uncachedInputPerMillion), + let cacheRead = cost(tokens: usage.cacheReadTokens, rate: price.cacheReadPerMillion), + let cacheWrite = cost(tokens: usage.cacheWriteTokens, rate: cacheWriteRate), + let output = cost(tokens: usage.outputTokens, rate: price.outputPerMillion) + else { return nil } + + let isLongContext = + price.longContext.map { + usage.totalInputTokens > $0.thresholdInputTokens + } ?? false + let inputMultiplier = isLongContext ? price.longContext?.inputMultiplier ?? 1 : 1 + let outputMultiplier = isLongContext ? price.longContext?.outputMultiplier ?? 1 : 1 + let total = (input + cacheRead + cacheWrite) * inputMultiplier + output * outputMultiplier + + return ModelCostLineItem( + usage: usage, + amount: CurrencyAmount(value: total, currency: price.currency), + warnings: warnings) + } + + public static func totals(for lineItems: [ModelCostLineItem]) -> [CurrencyAmount] { + var values: [String: Decimal] = [:] + for item in lineItems { + values[item.amount.currency, default: 0] += item.amount.value + } + return values.keys.sorted().map { + CurrencyAmount(value: values[$0] ?? 0, currency: $0) + } + } + + private static func cost(tokens: Int, rate: Decimal?) -> Decimal? { + if tokens == 0 { return 0 } + guard let rate else { return nil } + return Decimal(tokens) * rate / tokensPerMillion + } +} diff --git a/Sources/TokenLinkCore/CostStore.swift b/Sources/TokenLinkCore/CostStore.swift new file mode 100644 index 0000000..431ef68 --- /dev/null +++ b/Sources/TokenLinkCore/CostStore.swift @@ -0,0 +1,149 @@ +import Foundation + +public struct AuthoritativeCostState: Equatable, Sendable { + public var phase: ProviderPhase + public var snapshot: AuthoritativeCostSnapshot? + public var error: ProviderFailure? + + public init( + phase: ProviderPhase, + snapshot: AuthoritativeCostSnapshot? = nil, + error: ProviderFailure? = nil + ) { + self.phase = phase + self.snapshot = snapshot + self.error = error + } +} + +public struct EstimatedCostState: Equatable, Sendable { + public var phase: ProviderPhase + public var snapshot: EstimatedCostSnapshot? + public var error: ProviderFailure? + + public init( + phase: ProviderPhase, + snapshot: EstimatedCostSnapshot? = nil, + error: ProviderFailure? = nil + ) { + self.phase = phase + self.snapshot = snapshot + self.error = error + } +} + +public actor CostStore { + public static let authoritativeTTL: TimeInterval = 15 * 60 + public static let estimateTTL: TimeInterval = 30 * 60 + + private var authoritativeStates: [UUID: AuthoritativeCostState] = [:] + private var estimatedStates: [ProviderID: EstimatedCostState] = [:] + private let now: @Sendable () -> Date + + public init(now: @escaping @Sendable () -> Date = { Date() }) { + self.now = now + } + + public func acceptAuthoritative( + _ result: Result, + accountID: UUID + ) { + switch result { + case .success(let snapshot): + authoritativeStates[accountID] = .init(phase: .healthy, snapshot: snapshot) + case .failure(let failure): + let old = authoritativeStates[accountID]?.snapshot + authoritativeStates[accountID] = .init( + phase: failurePhase(for: failure, hasSnapshot: old != nil), + snapshot: old, + error: failure) + } + } + + public func acceptEstimate( + _ result: Result, + provider: ProviderID + ) { + switch result { + case .success(let snapshot): + estimatedStates[provider] = .init(phase: .healthy, snapshot: snapshot) + case .failure(let failure): + let old = estimatedStates[provider]?.snapshot + estimatedStates[provider] = .init( + phase: failurePhase(for: failure, hasSnapshot: old != nil), + snapshot: old, + error: failure) + } + } + + public func markAuthoritativeRefreshing(_ accountID: UUID) { + let old = authoritativeStates[accountID] + authoritativeStates[accountID] = .init( + phase: .refreshing, + snapshot: old?.snapshot) + } + + public func markEstimateRefreshing(_ provider: ProviderID) { + let old = estimatedStates[provider] + estimatedStates[provider] = .init( + phase: .refreshing, + snapshot: old?.snapshot) + } + + public func authoritativeState(for accountID: UUID) -> AuthoritativeCostState { + guard let state = authoritativeStates[accountID] else { + return .init(phase: .disabled) + } + return agedAuthoritative(state) + } + + public func estimatedState(for provider: ProviderID) -> EstimatedCostState { + guard let state = estimatedStates[provider] else { + return .init(phase: .disabled) + } + return agedEstimate(state) + } + + public func allAuthoritativeStates() -> [UUID: AuthoritativeCostState] { + authoritativeStates.mapValues(agedAuthoritative) + } + + public func allEstimatedStates() -> [ProviderID: EstimatedCostState] { + estimatedStates.mapValues(agedEstimate) + } + + public func clear() { + authoritativeStates.removeAll() + estimatedStates.removeAll() + } + + private func failurePhase( + for failure: ProviderFailure, + hasSnapshot: Bool + ) -> ProviderPhase { + if hasSnapshot { return .stale } + return failure.kind == .missingCredential ? .missingCredential : .error + } + + private func agedAuthoritative( + _ stored: AuthoritativeCostState + ) -> AuthoritativeCostState { + guard stored.phase == .healthy, + let fetchedAt = stored.snapshot?.fetchedAt, + now().timeIntervalSince(fetchedAt) > Self.authoritativeTTL + else { return stored } + var result = stored + result.phase = .stale + return result + } + + private func agedEstimate(_ stored: EstimatedCostState) -> EstimatedCostState { + guard stored.phase == .healthy, + let scannedAt = stored.snapshot?.scannedAt, + now().timeIntervalSince(scannedAt) > Self.estimateTTL + else { return stored } + var result = stored + result.phase = .stale + return result + } +} diff --git a/Sources/TokenLinkCore/ProviderModels.swift b/Sources/TokenLinkCore/ProviderModels.swift index e37b59f..9c289b0 100644 --- a/Sources/TokenLinkCore/ProviderModels.swift +++ b/Sources/TokenLinkCore/ProviderModels.swift @@ -4,6 +4,20 @@ public enum ProviderID: String, Codable, CaseIterable, Sendable { case minimax case glm case claude + case openrouter + case deepseek +} + +public struct ProviderCapability: OptionSet, Equatable, Hashable, Sendable { + public let rawValue: UInt8 + + public init(rawValue: UInt8) { + self.rawValue = rawValue + } + + public static let quota = Self(rawValue: 1 << 0) + public static let authoritativeCost = Self(rawValue: 1 << 1) + public static let localCostEstimate = Self(rawValue: 1 << 2) } public struct ProviderDescriptor: Equatable, Sendable { diff --git a/Sources/TokenLinkCore/ProviderState.swift b/Sources/TokenLinkCore/ProviderState.swift index c19df23..a63aefb 100644 --- a/Sources/TokenLinkCore/ProviderState.swift +++ b/Sources/TokenLinkCore/ProviderState.swift @@ -8,6 +8,7 @@ public enum ProviderErrorKind: String, Codable, Sendable { case process case timeout case configuration + case localRead } public struct ProviderFailure: Error, Equatable, Sendable { diff --git a/Sources/TokenLinkProviders/Costs/AuthoritativeCostProvider.swift b/Sources/TokenLinkProviders/Costs/AuthoritativeCostProvider.swift new file mode 100644 index 0000000..c353c53 --- /dev/null +++ b/Sources/TokenLinkProviders/Costs/AuthoritativeCostProvider.swift @@ -0,0 +1,117 @@ +import Foundation +import TokenLinkCore + +public protocol AuthoritativeCostProvider: Sendable { + var id: ProviderID { get } + + func fetch() async -> Result +} + +public struct AccountCostProvider: Sendable { + public let accountID: UUID + public let provider: any AuthoritativeCostProvider + + public init(accountID: UUID, provider: any AuthoritativeCostProvider) { + self.accountID = accountID + self.provider = provider + } +} + +struct LosslessDecimal: Decodable, Sendable { + let value: Decimal + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if (try? container.decode(Bool.self)) != nil { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "A monetary value cannot be a Boolean.") + } + + let decoded: Decimal? + if let decimal = try? container.decode(Decimal.self) { + decoded = decimal + } else if let string = try? container.decode(String.self), + string.range( + of: #"^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$"#, + options: .regularExpression) != nil + { + decoded = Decimal( + string: string, + locale: Locale(identifier: "en_US_POSIX")) + } else { + decoded = nil + } + + guard var decoded, !NSDecimalIsNotANumber(&decoded) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Expected a finite decimal number or numeric string.") + } + self.value = decoded + } +} + +enum AuthoritativeCostSupport { + static func credential( + account: String, + reader: any CredentialReader, + providerName: String + ) async -> Result { + do { + guard let key = try await reader.apiKey(forAccount: account), !key.isEmpty else { + return .failure( + .missingCredential("Configure an explicit \(providerName) API key.")) + } + return .success(key) + } catch let failure as ProviderFailure { + return .failure(failure) + } catch { + return .failure(.network("\(providerName) credential could not be read.")) + } + } + + static func request(url: URL, bearerToken: String) -> URLRequest { + var request = URLRequest(url: url) + request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") + return request + } + + static func responseFailure( + statusCode: Int, + providerName: String, + sourceName: String + ) -> ProviderFailure { + let kind: ProviderErrorKind = + statusCode == 401 || statusCode == 403 ? .authentication : .network + return ProviderFailure( + kind: kind, + message: "\(providerName) \(sourceName) returned HTTP \(statusCode).") + } + + static func transportFailure( + _ error: any Error, + providerName: String, + sourceName: String + ) -> ProviderFailure { + if error is CancellationError + || (error as? URLError)?.code == .cancelled + { + return .timeout("\(providerName) \(sourceName) request was cancelled.") + } + if (error as? URLError)?.code == .timedOut { + return .timeout("\(providerName) \(sourceName) request timed out.") + } + return .network("\(providerName) \(sourceName) request failed.") + } + + static func aggregate(_ failures: [ProviderFailure]) -> ProviderFailure { + precondition(!failures.isEmpty) + if failures.allSatisfy({ $0.kind == .authentication }) { + return failures[0] + } + return failures.first { $0.kind == .decoding } + ?? failures.first { $0.kind != .authentication } + ?? failures[0] + } +} diff --git a/Sources/TokenLinkProviders/Costs/DeepSeekCostProvider.swift b/Sources/TokenLinkProviders/Costs/DeepSeekCostProvider.swift new file mode 100644 index 0000000..8dea068 --- /dev/null +++ b/Sources/TokenLinkProviders/Costs/DeepSeekCostProvider.swift @@ -0,0 +1,92 @@ +import Foundation +import TokenLinkCore + +public struct DeepSeekCostProvider: AuthoritativeCostProvider { + public let id: ProviderID = .deepseek + + private static let balanceURL = URL(string: "https://api.deepseek.com/user/balance")! + private static let policy = EndpointPolicy(allowedHosts: ["api.deepseek.com"]) + + private let credentialAccount: String + private let http: any HTTPClient + private let credentials: any CredentialReader + private let now: @Sendable () -> Date + + public init( + credentialAccount: String = ProviderID.deepseek.rawValue, + http: any HTTPClient, + credentials: any CredentialReader, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.credentialAccount = credentialAccount + self.http = http + self.credentials = credentials + self.now = now + } + + public func fetch() async -> Result { + let credential = await AuthoritativeCostSupport.credential( + account: credentialAccount, + reader: credentials, + providerName: "DeepSeek") + let key: String + switch credential { + case .success(let value): + key = value + case .failure(let failure): + return .failure(failure) + } + + do { + let response = try await http.data( + for: AuthoritativeCostSupport.request( + url: Self.balanceURL, + bearerToken: key), + policy: Self.policy) + guard response.statusCode == 200 else { + return .failure( + AuthoritativeCostSupport.responseFailure( + statusCode: response.statusCode, + providerName: "DeepSeek", + sourceName: "balance")) + } + let decoded = try JSONDecoder().decode(DeepSeekBalanceResponse.self, from: response.data) + return .success( + AuthoritativeCostSnapshot( + provider: id, + balances: decoded.balanceInfos.map { + AccountBalance(currency: $0.currency, available: $0.totalBalance.value) + }, + isAvailable: decoded.isAvailable, + fetchedAt: now())) + } catch is DecodingError { + return .failure(.decoding("DeepSeek balance could not be read.")) + } catch { + return .failure( + AuthoritativeCostSupport.transportFailure( + error, + providerName: "DeepSeek", + sourceName: "balance")) + } + } +} + +private struct DeepSeekBalanceResponse: Decodable { + let isAvailable: Bool + let balanceInfos: [DeepSeekBalanceInfo] + + private enum CodingKeys: String, CodingKey { + case isAvailable = "is_available" + case balanceInfos = "balance_infos" + } +} + +private struct DeepSeekBalanceInfo: Decodable { + let currency: String + let totalBalance: LosslessDecimal + + private enum CodingKeys: String, CodingKey { + case currency + case totalBalance = "total_balance" + } +} diff --git a/Sources/TokenLinkProviders/Costs/OpenRouterCostProvider.swift b/Sources/TokenLinkProviders/Costs/OpenRouterCostProvider.swift new file mode 100644 index 0000000..ceddff7 --- /dev/null +++ b/Sources/TokenLinkProviders/Costs/OpenRouterCostProvider.swift @@ -0,0 +1,213 @@ +import Foundation +import TokenLinkCore + +public struct OpenRouterCostProvider: AuthoritativeCostProvider { + public let id: ProviderID = .openrouter + + private static let creditsURL = URL(string: "https://openrouter.ai/api/v1/credits")! + private static let keyURL = URL(string: "https://openrouter.ai/api/v1/key")! + private static let policy = EndpointPolicy(allowedHosts: ["openrouter.ai"]) + + private let credentialAccount: String + private let http: any HTTPClient + private let credentials: any CredentialReader + private let now: @Sendable () -> Date + + public init( + credentialAccount: String = ProviderID.openrouter.rawValue, + http: any HTTPClient, + credentials: any CredentialReader, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.credentialAccount = credentialAccount + self.http = http + self.credentials = credentials + self.now = now + } + + public func fetch() async -> Result { + let credential = await AuthoritativeCostSupport.credential( + account: credentialAccount, + reader: credentials, + providerName: "OpenRouter") + let key: String + switch credential { + case .success(let value): + key = value + case .failure(let failure): + return .failure(failure) + } + + async let creditsResult = fetchCredits(key: key) + async let keyResult = fetchKey(key: key) + let (credits, currentKey) = await (creditsResult, keyResult) + + var balances: [AccountBalance] = [] + var periodSpend: [ProviderPeriodSpend] = [] + var warnings: [CostWarning] = [] + var failures: [ProviderFailure] = [] + + switch credits { + case .success(let balance): + balances = [balance] + case .failure(let failure): + failures.append(failure) + warnings.append(.partialSource("credits")) + } + + switch currentKey { + case .success(let value): + if balances.isEmpty, let balance = value.balance { + balances = [balance] + } + periodSpend = value.periodSpend + case .failure(let failure): + failures.append(failure) + warnings.append(.partialSource("key")) + } + + guard !balances.isEmpty || !periodSpend.isEmpty else { + return .failure(AuthoritativeCostSupport.aggregate(failures)) + } + if failures.isEmpty { + warnings.removeAll() + } + return .success( + AuthoritativeCostSnapshot( + provider: id, + balances: balances, + periodSpend: periodSpend, + warnings: warnings, + fetchedAt: now())) + } + + private func fetchCredits(key: String) async -> Result { + do { + let response = try await http.data( + for: AuthoritativeCostSupport.request( + url: Self.creditsURL, + bearerToken: key), + policy: Self.policy) + guard response.statusCode == 200 else { + return .failure( + AuthoritativeCostSupport.responseFailure( + statusCode: response.statusCode, + providerName: "OpenRouter", + sourceName: "credits")) + } + let decoded = try JSONDecoder().decode(CreditsEnvelope.self, from: response.data).data + let remaining = max(decoded.totalCredits.value - decoded.totalUsage.value, 0) + return .success( + AccountBalance( + currency: "USD", + available: remaining, + purchased: decoded.totalCredits.value, + used: decoded.totalUsage.value)) + } catch is DecodingError { + return .failure(.decoding("OpenRouter credits could not be read.")) + } catch { + return .failure( + AuthoritativeCostSupport.transportFailure( + error, + providerName: "OpenRouter", + sourceName: "credits")) + } + } + + private func fetchKey(key: String) async -> Result { + do { + let response = try await http.data( + for: AuthoritativeCostSupport.request( + url: Self.keyURL, + bearerToken: key), + policy: Self.policy) + guard response.statusCode == 200 else { + return .failure( + AuthoritativeCostSupport.responseFailure( + statusCode: response.statusCode, + providerName: "OpenRouter", + sourceName: "key")) + } + let decoded = try JSONDecoder().decode(KeyEnvelope.self, from: response.data).data + let snapshot = decoded.snapshot + guard snapshot.balance != nil || !snapshot.periodSpend.isEmpty else { + return .failure(.decoding("OpenRouter key usage was empty.")) + } + return .success(snapshot) + } catch is DecodingError { + return .failure(.decoding("OpenRouter key usage could not be read.")) + } catch { + return .failure( + AuthoritativeCostSupport.transportFailure( + error, + providerName: "OpenRouter", + sourceName: "key")) + } + } +} + +private struct CreditsEnvelope: Decodable { + let data: CreditsData +} + +private struct CreditsData: Decodable { + let totalCredits: LosslessDecimal + let totalUsage: LosslessDecimal + + private enum CodingKeys: String, CodingKey { + case totalCredits = "total_credits" + case totalUsage = "total_usage" + } +} + +private struct KeyEnvelope: Decodable { + let data: KeyData +} + +private struct KeyData: Decodable { + let limit: LosslessDecimal? + let limitRemaining: LosslessDecimal? + let usage: LosslessDecimal? + let usageDaily: LosslessDecimal? + let usageWeekly: LosslessDecimal? + let usageMonthly: LosslessDecimal? + + private enum CodingKeys: String, CodingKey { + case limit + case limitRemaining = "limit_remaining" + case usage + case usageDaily = "usage_daily" + case usageWeekly = "usage_weekly" + case usageMonthly = "usage_monthly" + } + + var snapshot: KeySnapshot { + let balance = limitRemaining.map { + AccountBalance( + currency: "USD", + available: $0.value, + purchased: limit?.value, + used: usage?.value) + } + let values: [(ProviderSpendPeriod, LosslessDecimal?)] = [ + (.daily, usageDaily), + (.weekly, usageWeekly), + (.monthly, usageMonthly), + (.lifetime, usage), + ] + return KeySnapshot( + balance: balance, + periodSpend: values.compactMap { period, amount in + amount.map { + ProviderPeriodSpend( + period: period, + amount: CurrencyAmount(value: $0.value, currency: "USD")) + } + }) + } +} + +private struct KeySnapshot: Sendable { + let balance: AccountBalance? + let periodSpend: [ProviderPeriodSpend] +} diff --git a/Sources/TokenLinkProviders/Costs/PriceCatalog.swift b/Sources/TokenLinkProviders/Costs/PriceCatalog.swift new file mode 100644 index 0000000..b7da09f --- /dev/null +++ b/Sources/TokenLinkProviders/Costs/PriceCatalog.swift @@ -0,0 +1,169 @@ +import Foundation +import TokenLinkCore + +public enum PriceCatalogError: Error, Equatable, Sendable { + case missingResource + case invalidEffectiveDate + case invalidEntry(String) +} + +public struct PriceCatalog: Sendable { + public let version: String + public let effectiveDate: Date + public let entries: [ModelPrice] + + private struct LookupKey: Hashable, Sendable { + let provider: ProviderID + let modelID: String + } + + private let lookup: [LookupKey: ModelPrice] + + public init(version: String, effectiveDate: Date, entries: [ModelPrice]) { + self.version = version + self.effectiveDate = effectiveDate + self.entries = entries + var lookup: [LookupKey: ModelPrice] = [:] + for entry in entries { + lookup[LookupKey(provider: entry.provider, modelID: entry.modelID)] = entry + for alias in entry.aliases { + lookup[LookupKey(provider: entry.provider, modelID: alias)] = entry + } + } + self.lookup = lookup + } + + public func entry(provider: ProviderID, modelID: String) -> ModelPrice? { + lookup[LookupKey(provider: provider, modelID: modelID)] + } + + public static func bundled() throws -> PriceCatalog { + guard + let url = Bundle.module.url( + forResource: "api-equivalent-prices", + withExtension: "json") + else { throw PriceCatalogError.missingResource } + let document = try JSONDecoder().decode( + CatalogDocument.self, + from: Data(contentsOf: url)) + guard let effectiveDate = dayFormatter.date(from: document.effectiveDate) else { + throw PriceCatalogError.invalidEffectiveDate + } + return PriceCatalog( + version: document.version, + effectiveDate: effectiveDate, + entries: try document.entries.map(ModelPrice.init(document:))) + } + + private static let dayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() +} + +private struct CatalogDocument: Decodable { + let schemaVersion: Int + let version: String + let effectiveDate: String + let entries: [PriceDocument] + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case version + case effectiveDate = "effective_date" + case entries + } +} + +private struct PriceDocument: Decodable { + let provider: ProviderID + let modelID: String + let aliases: [String] + let currency: String + let uncachedInputPerMillion: String? + let cacheReadPerMillion: String? + let cacheWriteFiveMinutePerMillion: String? + let cacheWriteOneHourPerMillion: String? + let outputPerMillion: String? + let sourceURL: String + let longContext: LongContextDocument? + + enum CodingKeys: String, CodingKey { + case provider + case modelID = "model_id" + case aliases + case currency + case uncachedInputPerMillion = "uncached_input_per_million" + case cacheReadPerMillion = "cache_read_per_million" + case cacheWriteFiveMinutePerMillion = "cache_write_five_minute_per_million" + case cacheWriteOneHourPerMillion = "cache_write_one_hour_per_million" + case outputPerMillion = "output_per_million" + case sourceURL = "source_url" + case longContext = "long_context" + } +} + +private struct LongContextDocument: Decodable { + let thresholdInputTokens: Int + let inputMultiplier: String + let outputMultiplier: String + + enum CodingKeys: String, CodingKey { + case thresholdInputTokens = "threshold_input_tokens" + case inputMultiplier = "input_multiplier" + case outputMultiplier = "output_multiplier" + } +} + +extension ModelPrice { + fileprivate init(document: PriceDocument) throws { + guard let sourceURL = URL(string: document.sourceURL), sourceURL.scheme == "https" else { + throw PriceCatalogError.invalidEntry(document.modelID) + } + let longContext: LongContextPricing? + if let value = document.longContext { + guard let inputMultiplier = Self.decimal(value.inputMultiplier), + let outputMultiplier = Self.decimal(value.outputMultiplier) + else { throw PriceCatalogError.invalidEntry(document.modelID) } + longContext = LongContextPricing( + thresholdInputTokens: value.thresholdInputTokens, + inputMultiplier: inputMultiplier, + outputMultiplier: outputMultiplier) + } else { + longContext = nil + } + self.init( + provider: document.provider, + modelID: document.modelID, + aliases: document.aliases, + currency: document.currency, + uncachedInputPerMillion: try Self.decimal( + document.uncachedInputPerMillion, modelID: document.modelID), + cacheReadPerMillion: try Self.decimal( + document.cacheReadPerMillion, modelID: document.modelID), + cacheWriteFiveMinutePerMillion: try Self.decimal( + document.cacheWriteFiveMinutePerMillion, modelID: document.modelID), + cacheWriteOneHourPerMillion: try Self.decimal( + document.cacheWriteOneHourPerMillion, modelID: document.modelID), + outputPerMillion: try Self.decimal( + document.outputPerMillion, modelID: document.modelID), + sourceURL: sourceURL, + longContext: longContext) + } + + private static func decimal(_ value: String) -> Decimal? { + Decimal(string: value, locale: Locale(identifier: "en_US_POSIX")) + } + + private static func decimal(_ value: String?, modelID: String) throws -> Decimal? { + guard let value else { return nil } + guard let parsed = decimal(value) else { + throw PriceCatalogError.invalidEntry(modelID) + } + return parsed + } +} diff --git a/Sources/TokenLinkProviders/Resources/api-equivalent-prices.json b/Sources/TokenLinkProviders/Resources/api-equivalent-prices.json new file mode 100644 index 0000000..abd9f45 --- /dev/null +++ b/Sources/TokenLinkProviders/Resources/api-equivalent-prices.json @@ -0,0 +1,96 @@ +{ + "schema_version": 1, + "version": "2026-08-30.1", + "effective_date": "2026-08-30", + "entries": [ + { + "provider": "codex", + "model_id": "gpt-5.4", + "aliases": ["gpt-5.4-2026-03-05"], + "currency": "USD", + "uncached_input_per_million": "2.50", + "cache_read_per_million": "0.25", + "output_per_million": "15.00", + "source_url": "https://developers.openai.com/api/docs/models/gpt-5.4", + "long_context": { + "threshold_input_tokens": 272000, + "input_multiplier": "2.0", + "output_multiplier": "1.5" + } + }, + { + "provider": "codex", + "model_id": "gpt-5.5", + "aliases": ["gpt-5.5-2026-04-23"], + "currency": "USD", + "uncached_input_per_million": "5.00", + "cache_read_per_million": "0.50", + "output_per_million": "30.00", + "source_url": "https://developers.openai.com/api/docs/models/gpt-5.5", + "long_context": { + "threshold_input_tokens": 272000, + "input_multiplier": "2.0", + "output_multiplier": "1.5" + } + }, + { + "provider": "claude", + "model_id": "claude-sonnet-5", + "aliases": [], + "currency": "USD", + "uncached_input_per_million": "2.00", + "cache_read_per_million": "0.20", + "cache_write_five_minute_per_million": "2.50", + "cache_write_one_hour_per_million": "4.00", + "output_per_million": "10.00", + "source_url": "https://platform.claude.com/docs/en/models/sonnet-5/whats-new-sonnet-5" + }, + { + "provider": "claude", + "model_id": "claude-sonnet-4-6", + "aliases": [], + "currency": "USD", + "uncached_input_per_million": "3.00", + "cache_read_per_million": "0.30", + "cache_write_five_minute_per_million": "3.75", + "cache_write_one_hour_per_million": "6.00", + "output_per_million": "15.00", + "source_url": "https://platform.claude.com/docs/en/models/sonnet-4-6/overview" + }, + { + "provider": "claude", + "model_id": "claude-opus-5", + "aliases": [], + "currency": "USD", + "uncached_input_per_million": "5.00", + "cache_read_per_million": "0.50", + "cache_write_five_minute_per_million": "6.25", + "cache_write_one_hour_per_million": "10.00", + "output_per_million": "25.00", + "source_url": "https://platform.claude.com/docs/en/models/opus-5/overview" + }, + { + "provider": "claude", + "model_id": "claude-haiku-4-5-20251001", + "aliases": ["claude-haiku-4-5"], + "currency": "USD", + "uncached_input_per_million": "1.00", + "cache_read_per_million": "0.10", + "cache_write_five_minute_per_million": "1.25", + "cache_write_one_hour_per_million": "2.00", + "output_per_million": "5.00", + "source_url": "https://platform.claude.com/docs/en/models/haiku-4-5/overview" + }, + { + "provider": "kimi", + "model_id": "kimi-k3", + "aliases": ["k3", "kimi-code/k3"], + "currency": "USD", + "uncached_input_per_million": "3.00", + "cache_read_per_million": "0.30", + "cache_write_five_minute_per_million": "3.00", + "output_per_million": "15.00", + "source_url": "https://platform.kimi.ai/docs/pricing/chat-k3" + } + ] +} diff --git a/Sources/TokenLinkProviders/Shared/ProviderSpec.swift b/Sources/TokenLinkProviders/Shared/ProviderSpec.swift index 314f7de..8236513 100644 --- a/Sources/TokenLinkProviders/Shared/ProviderSpec.swift +++ b/Sources/TokenLinkProviders/Shared/ProviderSpec.swift @@ -78,6 +78,29 @@ public enum ProviderRegistry { /// Providers with a hand-written adapter (local subprocess, no API key). public static let customProviders: Set = [.codex] + public static let quotaProviderIDs: [ProviderID] = [ + .codex, .kimi, .minimax, .glm, .claude, + ] + + public static let authoritativeCostProviderIDs: [ProviderID] = [ + .openrouter, .deepseek, + ] + + public static let localCostEstimateProviderIDs: [ProviderID] = [ + .codex, .kimi, .claude, + ] + + public static func capabilities(for id: ProviderID) -> ProviderCapability { + switch id { + case .codex, .kimi, .claude: + [.quota, .localCostEstimate] + case .minimax, .glm: + [.quota] + case .openrouter, .deepseek: + [.authoritativeCost] + } + } + public static func spec(for id: ProviderID) -> ProviderSpec? { specs[id] } @@ -86,6 +109,8 @@ public enum ProviderRegistry { if let spec = specs[id] { return spec.displayName } switch id { case .codex: return "Codex" + case .openrouter: return "OpenRouter" + case .deepseek: return "DeepSeek" default: return id.rawValue.capitalized } } diff --git a/Tests/TokenLinkAppTests/AppModelTests.swift b/Tests/TokenLinkAppTests/AppModelTests.swift index 63cd298..117b6a9 100644 --- a/Tests/TokenLinkAppTests/AppModelTests.swift +++ b/Tests/TokenLinkAppTests/AppModelTests.swift @@ -11,6 +11,37 @@ private actor CountingRefresher: AppRefreshing { func refresh() async { count += 1 } } +private actor AppCostProbe { + private(set) var authoritativeCalls = 0 + private(set) var estimateCalls = 0 + + func authoritative( + _ source: AuthoritativeCostSource + ) -> Result { + authoritativeCalls += 1 + return .success( + AuthoritativeCostSnapshot( + provider: source.provider, + balances: [AccountBalance(currency: "USD", available: 50)], + fetchedAt: Date(timeIntervalSince1970: 1_000))) + } + + func estimate(_ provider: ProviderID) -> Result { + estimateCalls += 1 + let date = Date(timeIntervalSince1970: 1_000) + return .success( + EstimatedCostSnapshot( + provider: provider, + period: DateInterval(start: date.addingTimeInterval(-604_800), end: date), + lineItems: [], + totals: [], + unknownModelIDs: [], + catalogVersion: "test", + catalogEffectiveDate: date, + scannedAt: date)) + } +} + private actor StateSequenceLoader { private var states: [[UUID: ProviderState]] @@ -882,3 +913,417 @@ private actor CountingClaudeTokenReader: ClaudeTokenReading { #expect(await claudeReader.readCount == 1) #expect(model.configuration.claudeCredentialAccessAuthorized == false) } + +@MainActor @Test func costOnlyProvidersNeverEnterQuotaOrWatchPipelines() throws { + // Catches an all-provider enumeration leaking cost-only accounts into quota or BLE. + var configuration = AppConfiguration.default + let openRouter = ProviderAccount(provider: .openrouter, label: "OpenRouter") + let deepSeek = ProviderAccount(provider: .deepseek, label: "DeepSeek") + configuration.accounts += [openRouter, deepSeek] + configuration.watchSettings.syncedProviders.formUnion([.openrouter, .deepseek]) + let model = AppModel( + refresher: CountingRefresher(), + configuration: configuration) + + #expect( + AppModel.quotaAccounts(in: configuration).map(\.provider) + == [.codex, .kimi, .minimax, .glm]) + #expect(model.orderedProviderRows.map(\.id) == [.codex, .kimi, .minimax, .glm]) + #expect(model.accountGroups.allSatisfy { $0.provider != .openrouter && $0.provider != .deepseek }) + #expect(model.costAccountGroups.map(\.provider) == [.openrouter, .deepseek]) + #expect(!model.enabledWatchProviders.contains(.openrouter)) + #expect(!model.enabledWatchProviders.contains(.deepseek)) + #expect(model.enabledWatchProviders == [.codex]) + #expect(model.watchEligibleProviders == [.codex, .kimi, .minimax, .glm]) + #expect(throws: ProviderFailure.self) { + try model.setWatchSyncedProvider(.openrouter, enabled: true) + } +} + +@MainActor @Test func disabledCostsStartNoWorkThroughQuotaRefreshes() async { + // Catches app start, scheduler setup, or quota refresh accidentally triggering cost I/O. + var configuration = AppConfiguration.default + let account = ProviderAccount(provider: .openrouter, label: "OpenRouter") + configuration.accounts.append(account) + let probe = AppCostProbe() + let dashboard = CostDashboardModel( + enabled: false, + authoritativeSources: [ + AuthoritativeCostSource(accountID: account.id, provider: .openrouter) + ], + estimateProviders: [.codex], + store: CostStore(now: { Date(timeIntervalSince1970: 1_000) }), + authoritativeLoader: { await probe.authoritative($0) }, + estimateLoader: { await probe.estimate($0) }, + now: { Date(timeIntervalSince1970: 1_000) }) + let model = AppModel( + refresher: CountingRefresher(), + configuration: configuration, + costDashboard: dashboard) + + await model.start() + await model.refreshManually() + model.stop() + + #expect(await probe.authoritativeCalls == 0) + #expect(await probe.estimateCalls == 0) + #expect(model.costDashboard.authoritativeRows.isEmpty) +} + +@MainActor @Test func betaCostsEnableExplicitLoadDisableAndPreserveCredential() async throws { + // Catches eager scans on enable, lost metric defaults, or credential deletion on disable. + var configuration = AppConfiguration.default + let account = ProviderAccount(provider: .openrouter, label: "OpenRouter") + configuration.accounts.append(account) + let keychain = AppModelFakeKeychain() + let vault = KeychainVault(client: keychain, kimiTokenReader: NoCLITokenReader()) + let probe = AppCostProbe() + let dashboard = CostDashboardModel( + enabled: false, + authoritativeSources: [ + AuthoritativeCostSource(accountID: account.id, provider: .openrouter) + ], + estimateProviders: [.codex], + store: CostStore(now: { Date(timeIntervalSince1970: 1_000) }), + authoritativeLoader: { await probe.authoritative($0) }, + estimateLoader: { await probe.estimate($0) }, + now: { Date(timeIntervalSince1970: 1_000) }) + let model = AppModel( + refresher: CountingRefresher(), + configuration: configuration, + vault: vault, + costDashboard: dashboard) + try await model.setAPIKey("management-key", for: account.id) + + try await model.setBetaCostsEnabled(true) + #expect(model.configuration.betaCostsEnabled) + #expect(model.configuration.menuBarCostMetric == .localEstimate(.codex)) + #expect(await probe.authoritativeCalls == 0) + #expect(await probe.estimateCalls == 0) + + await model.loadCostsIfNeeded() + #expect(await probe.authoritativeCalls == 1) + #expect(await probe.estimateCalls == 1) + #expect(model.costDashboard.authoritativeRows.count == 1) + try model.setMenuBarCostMetric( + .authoritativeBalance(accountID: account.id, currency: "USD")) + + try await model.setBetaCostsEnabled(false) + #expect(model.configuration.menuBarCostMetric == .none) + #expect(model.costDashboard.authoritativeRows.isEmpty) + #expect(await keychain.value(for: "openrouter") == "management-key") + await model.refreshCosts(force: true) + #expect(await probe.authoritativeCalls == 1) +} + +@MainActor @Test func betaCostsRollsBackWhenConfigurationCannotBeSaved() async throws { + // Catches observable configuration and the runtime dashboard diverging after a disk failure. + let root = FileManager.default.temporaryDirectory.appending( + path: UUID().uuidString, + directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let blockedDirectory = root.appending(path: "not-a-directory") + try Data("blocked".utf8).write(to: blockedDirectory) + let store = ConfigurationStore(directory: blockedDirectory) + + let disabledDashboard = CostDashboardModel( + enabled: false, + authoritativeSources: [], + estimateProviders: [], + authoritativeLoader: { _ in .failure(.configuration("unused")) }, + estimateLoader: { _ in .failure(.configuration("unused")) }) + let disabledModel = AppModel( + refresher: CountingRefresher(), + configurationStore: store, + costDashboard: disabledDashboard) + + do { + try await disabledModel.setBetaCostsEnabled(true) + Issue.record("Expected enabling Costs to fail") + } catch {} + #expect(!disabledModel.configuration.betaCostsEnabled) + #expect(disabledModel.configuration.menuBarCostMetric == .none) + #expect(!disabledModel.costDashboard.isEnabled) + + var enabledConfiguration = AppConfiguration.default + enabledConfiguration.betaCostsEnabled = true + enabledConfiguration.menuBarCostMetric = .localEstimate(.codex) + let snapshotDate = Date(timeIntervalSince1970: 1_000) + let costSourceID = UUID() + let enabledDashboard = CostDashboardModel( + enabled: true, + authoritativeSources: [ + AuthoritativeCostSource(accountID: costSourceID, provider: .openrouter) + ], + estimateProviders: [], + store: CostStore(now: { snapshotDate }), + authoritativeLoader: { _ in + .success( + AuthoritativeCostSnapshot( + provider: .openrouter, + balances: [AccountBalance(currency: "USD", available: 1)], + fetchedAt: snapshotDate)) + }, + estimateLoader: { _ in .failure(.configuration("unused")) }, + now: { snapshotDate }) + let enabledModel = AppModel( + refresher: CountingRefresher(), + configuration: enabledConfiguration, + configurationStore: store, + costDashboard: enabledDashboard) + await enabledModel.loadCostsIfNeeded() + #expect(enabledModel.costDashboard.authoritativeRows.count == 1) + + do { + try await enabledModel.setBetaCostsEnabled(false) + Issue.record("Expected disabling Costs to fail") + } catch {} + #expect(enabledModel.configuration.betaCostsEnabled) + #expect(enabledModel.configuration.menuBarCostMetric == .localEstimate(.codex)) + #expect(enabledModel.costDashboard.isEnabled) + #expect(enabledModel.costDashboard.authoritativeRows.count == 1) +} + +@MainActor @Test func costAccountRemovalPromotesNamespacedCredential() async throws { + // Catches cost-only accounts bypassing the existing default-key promotion semantics. + var configuration = AppConfiguration.default + let defaultAccount = ProviderAccount(provider: .deepseek, label: "DeepSeek") + configuration.accounts.append(defaultAccount) + let keychain = AppModelFakeKeychain() + let vault = KeychainVault(client: keychain, kimiTokenReader: NoCLITokenReader()) + let model = AppModel( + refresher: CountingRefresher(), + configuration: configuration, + vault: vault) + try await model.setAPIKey("default-cost-key", for: defaultAccount.id) + let second = try model.addAccount(provider: .deepseek, label: "Work") + try await model.setAPIKey("work-cost-key", for: second.id) + + try await model.removeAccount(id: defaultAccount.id) + + #expect(await keychain.value(for: "deepseek") == "work-cost-key") + #expect(await keychain.value(for: "deepseek.\(second.id.uuidString)") == nil) + #expect(model.configuration.defaultAccount(for: .deepseek)?.id == second.id) +} + +@MainActor @Test func menuBarCostEstimateKeepsQuotaFirstAndMarksApproximation() async throws { + // Catches replacing the quota segment or omitting the mandatory estimate marker. + var configuration = AppConfiguration.default + configuration.appLanguage = AppLanguage.english.rawValue + configuration.betaCostsEnabled = true + configuration.menuBarCostMetric = .localEstimate(.codex) + let date = Date(timeIntervalSince1970: 1_000) + let dashboard = CostDashboardModel( + enabled: true, + authoritativeSources: [], + estimateProviders: [.codex], + store: CostStore(now: { date }), + authoritativeLoader: { _ in .failure(.network("unused")) }, + estimateLoader: { provider in + .success( + EstimatedCostSnapshot( + provider: provider, + period: DateInterval(start: date.addingTimeInterval(-604_800), end: date), + lineItems: [], + totals: [CurrencyAmount(value: Decimal(string: "8.31")!, currency: "USD")], + unknownModelIDs: [], + catalogVersion: "menu-test", + catalogEffectiveDate: date, + scannedAt: date)) + }, + now: { date }) + let codexAccount = try #require(configuration.defaultAccount(for: .codex)) + let model = AppModel( + refresher: CountingRefresher(), + now: { date }, + stateLoader: { _ in + [ + codexAccount.id: ProviderState( + phase: .healthy, + snapshot: snapshot(.codex, remaining: 42)) + ] + }, + configuration: configuration, + costDashboard: dashboard) + + await model.requestRefresh(reason: "Quota") + await model.loadCostsIfNeeded() + + #expect(model.highlight?.provider == .codex) + #expect(model.menuBarLabel == "Codex 42% · ≈$8.31/7d") + #expect( + model.menuBarAccessibilityLabel + == "Codex quota, 42 percent remaining; Codex local Estimated/API-equivalent cost $8.31 for the last 7 days; fresh" + ) +} + +@MainActor @Test func menuBarCostAuthoritativeBalanceAndMissingFallback() async throws { + // Catches adding an estimate marker to provider balances or showing a missing selection. + var configuration = AppConfiguration.default + configuration.appLanguage = AppLanguage.english.rawValue + let openRouter = ProviderAccount(provider: .openrouter, label: "Private label") + configuration.accounts.append(openRouter) + configuration.betaCostsEnabled = true + configuration.menuBarCostMetric = .authoritativeBalance( + accountID: openRouter.id, + currency: "USD") + let date = Date(timeIntervalSince1970: 1_000) + let dashboard = CostDashboardModel( + enabled: true, + authoritativeSources: [ + AuthoritativeCostSource(accountID: openRouter.id, provider: .openrouter) + ], + estimateProviders: [], + store: CostStore(now: { date }), + authoritativeLoader: { source in + .success( + AuthoritativeCostSnapshot( + provider: source.provider, + balances: [ + AccountBalance( + currency: "USD", + available: Decimal(string: "18.40")!) + ], + fetchedAt: date)) + }, + estimateLoader: { _ in .failure(.network("unused")) }, + now: { date }) + let codexAccount = try #require(configuration.defaultAccount(for: .codex)) + let model = AppModel( + refresher: CountingRefresher(), + now: { date }, + stateLoader: { _ in + [ + codexAccount.id: ProviderState( + phase: .healthy, + snapshot: snapshot(.codex, remaining: 42)) + ] + }, + configuration: configuration, + costDashboard: dashboard) + + await model.requestRefresh(reason: "Quota") + await model.loadCostsIfNeeded() + #expect(model.menuBarLabel == "Codex 42% · OR $18.40 left") + #expect( + model.menuBarAccessibilityLabel + == "Codex quota, 42 percent remaining; OpenRouter authoritative balance $18.40 remaining; fresh" + ) + + try model.setMenuBarCostMetric( + .authoritativeBalance(accountID: UUID(), currency: "USD")) + #expect(model.menuBarLabel == "Codex 42%") +} + +@MainActor @Test func menuBarCostAccessibilityDescribesStaleEstimate() async throws { + // Catches stale cost text being exposed to assistive tech as if it were fresh. + var configuration = AppConfiguration.default + configuration.appLanguage = AppLanguage.english.rawValue + configuration.betaCostsEnabled = true + configuration.menuBarCostMetric = .localEstimate(.claude) + let date = Date(timeIntervalSince1970: 1_000) + let staleDate = date.addingTimeInterval(CostStore.estimateTTL + 1) + let dashboard = CostDashboardModel( + enabled: true, + authoritativeSources: [], + estimateProviders: [.claude], + store: CostStore(now: { staleDate }), + authoritativeLoader: { _ in .failure(.network("unused")) }, + estimateLoader: { provider in + .success( + EstimatedCostSnapshot( + provider: provider, + period: DateInterval(start: date.addingTimeInterval(-604_800), end: date), + lineItems: [], + totals: [CurrencyAmount(value: Decimal(string: "4.20")!, currency: "USD")], + unknownModelIDs: [], + catalogVersion: "accessibility-test", + catalogEffectiveDate: date, + scannedAt: date)) + }, + now: { staleDate }) + let codexAccount = try #require(configuration.defaultAccount(for: .codex)) + let model = AppModel( + refresher: CountingRefresher(), + stateLoader: { _ in + [ + codexAccount.id: ProviderState( + phase: .healthy, + snapshot: snapshot(.codex, remaining: 42)) + ] + }, + configuration: configuration, + costDashboard: dashboard) + + await model.requestRefresh(reason: "Quota") + await model.loadCostsIfNeeded() + + #expect(model.costDashboard.estimateRows.first?.state.phase == .stale) + #expect(model.menuBarLabel == "Codex 42% · ≈$4.20/7d") + #expect( + model.menuBarAccessibilityLabel + == "Codex quota, 42 percent remaining; Claude local Estimated/API-equivalent cost $4.20 for the last 7 days; stale" + ) +} + +@MainActor @Test func costDiagnosticsContainOnlyRedactedMetadata() async throws { + // Catches snapshots, amounts, account identity, error text, or paths entering diagnostics. + var configuration = AppConfiguration.default + let privateAccount = ProviderAccount( + id: UUID(uuidString: "00000000-0000-0000-0000-00000000F001")!, + provider: .openrouter, + label: "private-account-label") + configuration.accounts.append(privateAccount) + configuration.betaCostsEnabled = true + let date = Date(timeIntervalSince1970: 1_000) + let dashboard = CostDashboardModel( + enabled: true, + authoritativeSources: [ + AuthoritativeCostSource(accountID: privateAccount.id, provider: .openrouter) + ], + estimateProviders: [.codex], + store: CostStore(now: { date }), + authoritativeLoader: { _ in + .failure(.authentication("Rejected /Users/private/transcript.jsonl")) + }, + estimateLoader: { provider in + .success( + EstimatedCostSnapshot( + provider: provider, + period: DateInterval(start: date.addingTimeInterval(-604_800), end: date), + lineItems: [], + totals: [ + CurrencyAmount(value: Decimal(string: "98765.43")!, currency: "USD") + ], + unknownModelIDs: ["private-model"], + catalogVersion: "catalog-safe-version", + catalogEffectiveDate: date, + scannedAt: date)) + }, + now: { date }) + let model = AppModel( + refresher: CountingRefresher(), + now: { date }, + configuration: configuration, + costDashboard: dashboard) + await model.loadCostsIfNeeded() + + let data = try JSONSerialization.data( + withJSONObject: model.diagnosticObject(), + options: [.sortedKeys]) + let output = String(decoding: data, as: UTF8.self) + + for expected in [ + "costs", "authoritative", "estimate", "authentication", + "catalog-safe-version", "last_refresh_at", "updated_at", + ] { + #expect(output.contains(expected)) + } + for sensitive in [ + "98765.43", "private-account-label", privateAccount.id.uuidString, + "private-model", "/Users/private", "transcript.jsonl", "Rejected", + ] { + #expect(!output.contains(sensitive)) + } +} diff --git a/Tests/TokenLinkAppTests/ConfigurationStoreTests.swift b/Tests/TokenLinkAppTests/ConfigurationStoreTests.swift index bf8d84f..96520e3 100644 --- a/Tests/TokenLinkAppTests/ConfigurationStoreTests.swift +++ b/Tests/TokenLinkAppTests/ConfigurationStoreTests.swift @@ -96,9 +96,23 @@ import TokenLinkProviders #expect(loaded.fairPaceEnabled == false) #expect(loaded.betaLocalUsageEnabled == false) #expect(loaded.legacyKeychainMigrationCompleted == false) + #expect(loaded.betaCostsEnabled == false) + #expect(loaded.menuBarCostMetric == .none) + #expect(!loaded.accounts.contains { [.openrouter, .deepseek].contains($0.provider) }) #expect(loaded.watchSettings == WatchSettings()) } +@Test func defaultConfigurationIncludesQuotaProvidersOnly() { + #expect( + AppConfiguration.default.accounts.allSatisfy { + ProviderRegistry.capabilities(for: $0.provider).contains(.quota) + }) + #expect(!AppConfiguration.default.accounts.contains { $0.provider == .openrouter }) + #expect(!AppConfiguration.default.accounts.contains { $0.provider == .deepseek }) + #expect(AppConfiguration.default.betaCostsEnabled == false) + #expect(AppConfiguration.default.menuBarCostMetric == .none) +} + @Test func accountsRoundTripWithStableIDsAndLabels() throws { let directory = FileManager.default.temporaryDirectory .appending(path: UUID().uuidString, directoryHint: .isDirectory) diff --git a/Tests/TokenLinkAppTests/CostDashboardModelTests.swift b/Tests/TokenLinkAppTests/CostDashboardModelTests.swift new file mode 100644 index 0000000..bee24b9 --- /dev/null +++ b/Tests/TokenLinkAppTests/CostDashboardModelTests.swift @@ -0,0 +1,236 @@ +import Foundation +import Testing +import TokenLinkCore + +@testable import TokenLinkApp + +private let costAccount = UUID( + uuidString: "00000000-0000-0000-0000-00000000D001")! + +private actor CostLoaderProbe { + private(set) var authoritativeCalls = 0 + private(set) var estimateCalls = 0 + var authoritativeResult: Result + var estimateResult: Result + let delay: Duration? + + init( + authoritativeResult: Result, + estimateResult: Result, + delay: Duration? = nil + ) { + self.authoritativeResult = authoritativeResult + self.estimateResult = estimateResult + self.delay = delay + } + + func loadAuthoritative( + _ source: AuthoritativeCostSource + ) async -> Result { + authoritativeCalls += 1 + if let delay { try? await Task.sleep(for: delay) } + return authoritativeResult + } + + func loadEstimate( + _ provider: ProviderID + ) async -> Result { + estimateCalls += 1 + if let delay { try? await Task.sleep(for: delay) } + return estimateResult + } +} + +private func dashboardAuthoritativeSnapshot(at date: Date) -> AuthoritativeCostSnapshot { + AuthoritativeCostSnapshot( + provider: .openrouter, + balances: [AccountBalance(currency: "USD", available: 75)], + fetchedAt: date) +} + +private func dashboardEstimateSnapshot(at date: Date) -> EstimatedCostSnapshot { + EstimatedCostSnapshot( + provider: .codex, + period: DateInterval(start: date.addingTimeInterval(-604_800), end: date), + lineItems: [], + totals: [CurrencyAmount(value: 2, currency: "USD")], + unknownModelIDs: [], + catalogVersion: "test", + catalogEffectiveDate: date, + scannedAt: date) +} + +@MainActor @Test func disabledCostDashboardPerformsNoWork() async { + let date = Date(timeIntervalSince1970: 1_000) + let probe = CostLoaderProbe( + authoritativeResult: .success(dashboardAuthoritativeSnapshot(at: date)), + estimateResult: .success(dashboardEstimateSnapshot(at: date))) + let model = makeDashboard(enabled: false, probe: probe, now: date) + + await model.loadIfNeeded() + await model.refreshCosts(force: true) + + #expect(await probe.authoritativeCalls == 0) + #expect(await probe.estimateCalls == 0) + #expect(model.authoritativeRows.isEmpty) + #expect(model.estimateRows.isEmpty) +} + +@MainActor @Test func costDashboardLoadsOnceBeforeTTLAndForceBypassesTTL() async { + let date = Date(timeIntervalSince1970: 1_000) + let probe = CostLoaderProbe( + authoritativeResult: .success(dashboardAuthoritativeSnapshot(at: date)), + estimateResult: .success(dashboardEstimateSnapshot(at: date))) + let model = makeDashboard(enabled: true, probe: probe, now: date) + + await model.loadIfNeeded() + await model.loadIfNeeded() + #expect(await probe.authoritativeCalls == 1) + #expect(await probe.estimateCalls == 1) + #expect(model.authoritativeRows.first?.state.phase == .healthy) + #expect(model.estimateRows.first?.state.phase == .healthy) + + await model.refreshCosts(force: true) + #expect(await probe.authoritativeCalls == 2) + #expect(await probe.estimateCalls == 2) +} + +@MainActor @Test func concurrentCostDashboardRefreshesCoalesce() async { + let date = Date(timeIntervalSince1970: 1_000) + let probe = CostLoaderProbe( + authoritativeResult: .success(dashboardAuthoritativeSnapshot(at: date)), + estimateResult: .success(dashboardEstimateSnapshot(at: date)), + delay: .milliseconds(40)) + let model = makeDashboard(enabled: true, probe: probe, now: date) + + async let first: Void = model.refreshCosts(force: true) + async let second: Void = model.refreshCosts(force: true) + _ = await (first, second) + + #expect(await probe.authoritativeCalls == 1) + #expect(await probe.estimateCalls == 1) + #expect(model.isRefreshing == false) +} + +@MainActor @Test func costDashboardPublishesEachSourceAsItFinishes() async throws { + // Catches a fast authoritative result being hidden behind a slower local scan. + let date = Date(timeIntervalSince1970: 1_000) + let model = CostDashboardModel( + enabled: true, + authoritativeSources: [ + AuthoritativeCostSource(accountID: costAccount, provider: .openrouter) + ], + estimateProviders: [.codex], + store: CostStore(now: { date }), + authoritativeLoader: { _ in + .success(dashboardAuthoritativeSnapshot(at: date)) + }, + estimateLoader: { _ in + try? await Task.sleep(for: .milliseconds(400)) + return .success(dashboardEstimateSnapshot(at: date)) + }, + now: { date }) + + let refresh = Task { await model.refreshCosts(force: true) } + for _ in 0..<50 { + if model.authoritativeRows.first?.state.phase == .healthy { break } + try await Task.sleep(for: .milliseconds(5)) + } + + #expect(model.authoritativeRows.first?.state.phase == .healthy) + #expect(model.isRefreshing) + await refresh.value + #expect(model.estimateRows.first?.state.phase == .healthy) +} + +@MainActor @Test func sourceFailuresRemainIndependentAndDisableClearsRows() async { + let date = Date(timeIntervalSince1970: 1_000) + let probe = CostLoaderProbe( + authoritativeResult: .failure(.authentication("rejected")), + estimateResult: .success(dashboardEstimateSnapshot(at: date))) + let model = makeDashboard(enabled: true, probe: probe, now: date) + + await model.loadIfNeeded() + + #expect(model.authoritativeRows.first?.state.phase == .error) + #expect(model.authoritativeRows.first?.state.error?.kind == .authentication) + #expect(model.estimateRows.first?.state.phase == .healthy) + #expect(model.diagnosticMetadata.sources.count == 2) + + await model.disable() + #expect(model.isEnabled == false) + #expect(model.isRefreshing == false) + #expect(model.authoritativeRows.isEmpty) + #expect(model.estimateRows.isEmpty) + #expect(model.diagnosticMetadata.sources.isEmpty) +} + +@MainActor @Test func disablingCostDashboardCancelsInFlightPresentationUpdates() async { + let date = Date(timeIntervalSince1970: 1_000) + let probe = CostLoaderProbe( + authoritativeResult: .success(dashboardAuthoritativeSnapshot(at: date)), + estimateResult: .success(dashboardEstimateSnapshot(at: date)), + delay: .seconds(1)) + let model = makeDashboard(enabled: true, probe: probe, now: date) + + let refresh = Task { await model.refreshCosts(force: true) } + for _ in 0..<100 { + if await probe.authoritativeCalls > 0, await probe.estimateCalls > 0 { break } + await Task.yield() + } + await model.disable() + await refresh.value + + #expect(model.isEnabled == false) + #expect(model.isRefreshing == false) + #expect(model.authoritativeRows.isEmpty) + #expect(model.estimateRows.isEmpty) +} + +@MainActor @Test func costDashboardAgesVisibleRowsAtTTLWithoutAnotherLoad() async throws { + // Catches copied presentation rows remaining healthy forever while Costs stays open. + let loadedAt = Date() + let probe = CostLoaderProbe( + authoritativeResult: .success( + dashboardAuthoritativeSnapshot( + at: loadedAt.addingTimeInterval(-CostStore.authoritativeTTL + 0.3))), + estimateResult: .success( + dashboardEstimateSnapshot( + at: loadedAt.addingTimeInterval(-CostStore.estimateTTL + 0.3)))) + let model = CostDashboardModel( + enabled: true, + authoritativeSources: [ + AuthoritativeCostSource(accountID: costAccount, provider: .openrouter) + ], + estimateProviders: [.codex], + store: CostStore(), + authoritativeLoader: { await probe.loadAuthoritative($0) }, + estimateLoader: { await probe.loadEstimate($0) }) + + await model.loadIfNeeded() + #expect(model.authoritativeRows.first?.state.phase == .healthy) + #expect(model.estimateRows.first?.state.phase == .healthy) + + try await Task.sleep(for: .milliseconds(600)) + + #expect(model.authoritativeRows.first?.state.phase == .stale) + #expect(model.estimateRows.first?.state.phase == .stale) +} + +@MainActor private func makeDashboard( + enabled: Bool, + probe: CostLoaderProbe, + now: Date +) -> CostDashboardModel { + let store = CostStore(now: { now }) + return CostDashboardModel( + enabled: enabled, + authoritativeSources: [ + AuthoritativeCostSource(accountID: costAccount, provider: .openrouter) + ], + estimateProviders: [.codex], + store: store, + authoritativeLoader: { await probe.loadAuthoritative($0) }, + estimateLoader: { await probe.loadEstimate($0) }, + now: { now }) +} diff --git a/Tests/TokenLinkAppTests/JSONLStreamingReaderTests.swift b/Tests/TokenLinkAppTests/JSONLStreamingReaderTests.swift new file mode 100644 index 0000000..cdf0f51 --- /dev/null +++ b/Tests/TokenLinkAppTests/JSONLStreamingReaderTests.swift @@ -0,0 +1,208 @@ +import Darwin +import Foundation +import Testing + +@testable import TokenLinkApp + +@Test func streamingReaderPreservesRecordsAcrossChunkBoundariesAndEOF() throws { + // Catches truncating records at a 64 KiB chunk edge or dropping the final record. + let first = Data(repeating: 0x41, count: 65_535) + let second = Data(repeating: 0x42, count: 65_537) + let final = Data("final-without-newline".utf8) + var source = Data() + source.append(first) + source.append(0x0A) + source.append(second) + source.append(0x0A) + source.append(final) + let url = try temporaryFile(contents: source) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + var records: [Data] = [] + let report = try JSONLStreamingReader().read(url: url) { records.append($0) } + + #expect(JSONLStreamingReader.chunkBytes == 65_536) + #expect(records == [first, second, final]) + #expect(report.deliveredRecordCount == 3) + #expect(report.oversizedRecordCount == 0) + #expect(report.bytesRead == source.count) +} + +@Test func streamingReaderDeliversOneMiBAndSkipsOneByteOver() throws { + // Catches an inclusive/exclusive limit bug or retaining the tail of an oversized record. + let exact = Data(repeating: 0x58, count: 1_048_576) + let oversized = Data(repeating: 0x59, count: 1_048_577) + let final = Data("ok".utf8) + var source = Data() + source.append(exact) + source.append(0x0A) + source.append(oversized) + source.append(0x0A) + source.append(final) + let url = try temporaryFile(contents: source) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + var deliveredSizes: [Int] = [] + let report = try JSONLStreamingReader().read(url: url) { + deliveredSizes.append($0.count) + } + + #expect(JSONLStreamingReader.maximumRecordBytes == 1_048_576) + #expect(deliveredSizes == [1_048_576, 2]) + #expect(report.deliveredRecordCount == 2) + #expect(report.oversizedRecordCount == 1) +} + +@Test func streamingReaderHonorsCancellationBeforeReading() async throws { + // Catches a cancelled local scan continuing to allocate and parse transcript data. + let url = try temporaryFile(contents: Data("{}\n".utf8)) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try JSONLStreamingReader().read(url: url) { _ in } + } + + do { + _ = try await task.value + Issue.record("Expected cancellation") + } catch is CancellationError { + // Expected. + } catch { + Issue.record("Expected CancellationError, got \(type(of: error))") + } +} + +@Test func streamingReaderEnforcesByteCeilingWhileFileIsOpen() throws { + // Catches a file growing beyond the observer's preflight size check. + let url = try temporaryFile(contents: Data("a\nb\nc\n".utf8)) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + var records: [String] = [] + let truncated = try JSONLStreamingReader().read(url: url, maximumBytes: 4) { + records.append(String(decoding: $0, as: UTF8.self)) + } + + #expect(truncated.bytesRead == 4) + #expect(truncated.byteLimitExceeded) + #expect(records == ["a", "b"]) + + let exactURL = try temporaryFile(contents: Data("a\nb\n".utf8)) + defer { try? FileManager.default.removeItem(at: exactURL.deletingLastPathComponent()) } + let exact = try JSONLStreamingReader().read(url: exactURL, maximumBytes: 4) { _ in } + #expect(exact.bytesRead == 4) + #expect(!exact.byteLimitExceeded) +} + +@Test func streamingReaderEnforcesByteCeilingWhenFileGrowsAfterOpening() throws { + // Catches a writer extending the transcript after the reader passed its preflight check. + let url = try temporaryFile(contents: Data("a\n".utf8)) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let writer = try FileHandle(forWritingTo: url) + defer { try? writer.close() } + try writer.seekToEnd() + + var records: [String] = [] + let report = try JSONLStreamingReader().read(url: url, maximumBytes: 4) { + let record = String(decoding: $0, as: UTF8.self) + records.append(record) + if record == "a" { + try? writer.write(contentsOf: Data("b\nc\n".utf8)) + } + } + + #expect(report.bytesRead == 4) + #expect(report.byteLimitExceeded) + #expect(records == ["a", "b"]) +} + +@Test func streamingReaderRejectsSymbolicLinksAndSpecialFiles() throws { + // Catches transcript discovery being redirected or blocked by a FIFO. + let directory = FileManager.default.temporaryDirectory.appending( + path: UUID().uuidString, + directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let target = directory.appending(path: "target.jsonl") + try Data("{}\n".utf8).write(to: target) + let link = directory.appending(path: "link.jsonl") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + expectNotRegularFile(at: link) + + let fifo = directory.appending(path: "pipe.jsonl") + let result = fifo.withUnsafeFileSystemRepresentation { path in + guard let path else { return Int32(-1) } + return Darwin.mkfifo(path, 0o600) + } + #expect(result == 0) + expectNotRegularFile(at: fifo) +} + +@Test func observerProcessesExactlyFiftyMiBAndSkipsOneByteOver() throws { + // Catches opening a file beyond the hard cap or rejecting the exact boundary. + let root = FileManager.default.temporaryDirectory.appending( + path: UUID().uuidString, + directoryHint: .isDirectory) + let sessions = root.appending( + path: ".codex/sessions/2026/08/30", + directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let record = Data( + """ + {"timestamp":"2999-01-01T00:00:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5}}}} + + """.utf8) + let exact = sessions.appending(path: "a-exact.jsonl") + try makeSparseFile( + at: exact, + prefix: record, + byteCount: 52_428_800) + let over = sessions.appending(path: "b-over.jsonl") + try makeSparseFile( + at: over, + prefix: record, + byteCount: 52_428_801) + + let report = try LocalUsageObserver(homeURL: root).scan( + CodexRolloutParser.self, + since: Date(timeIntervalSince1970: 1_000_000)) + + #expect(LocalUsageObserver.maximumFileBytes == 52_428_800) + #expect(report.summary.eventCount == 1) + #expect(report.summary.totalTokens == 15) + #expect(report.processedFileCount == 1) + #expect(report.oversizedFileCount == 1) + #expect(report.oversizedRecordCount == 1) +} + +private func temporaryFile(contents: Data) throws -> URL { + let directory = FileManager.default.temporaryDirectory.appending( + path: UUID().uuidString, + directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appending(path: "records.jsonl") + try contents.write(to: url) + return url +} + +private func makeSparseFile(at url: URL, prefix: Data, byteCount: UInt64) throws { + FileManager.default.createFile(atPath: url.path, contents: nil) + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.write(contentsOf: prefix) + try handle.truncate(atOffset: byteCount) +} + +private func expectNotRegularFile(at url: URL) { + do { + _ = try JSONLStreamingReader().read(url: url) { _ in } + Issue.record("Expected a non-regular file error") + } catch let error as JSONLStreamingReaderError { + #expect(error == .notRegularFile) + } catch { + Issue.record("Expected JSONLStreamingReaderError, got \(type(of: error))") + } +} diff --git a/Tests/TokenLinkAppTests/KeychainVaultTests.swift b/Tests/TokenLinkAppTests/KeychainVaultTests.swift index 6397ba5..3fb7a4d 100644 --- a/Tests/TokenLinkAppTests/KeychainVaultTests.swift +++ b/Tests/TokenLinkAppTests/KeychainVaultTests.swift @@ -140,6 +140,24 @@ private struct FailingKeychainClient: KeychainClient { #expect(try await vault.apiKey(for: extraAccount, isDefault: false) == "second-key") } +@Test func costAccountKeychainNamesUseProviderDefaultsAndUUIDNamespaces() async throws { + let openRouter = ProviderAccount(provider: .openrouter, label: "OpenRouter") + let deepSeek = ProviderAccount(provider: .deepseek, label: "DeepSeek Work") + + #expect( + KeychainVault.keychainAccountName( + provider: openRouter.provider, + accountID: openRouter.id, + isDefault: true) + == "openrouter") + #expect( + KeychainVault.keychainAccountName( + provider: deepSeek.provider, + accountID: deepSeek.id, + isDefault: false) + == "deepseek.\(deepSeek.id.uuidString)") +} + @Test func environmentFallbackReadsOnlySpecAllowlist() async throws { let environment: [String: String] = [ "ZHIPU_API_KEY": "glm-env-key", diff --git a/Tests/TokenLinkAppTests/LocalCostEstimatorTests.swift b/Tests/TokenLinkAppTests/LocalCostEstimatorTests.swift new file mode 100644 index 0000000..07fecd4 --- /dev/null +++ b/Tests/TokenLinkAppTests/LocalCostEstimatorTests.swift @@ -0,0 +1,194 @@ +import Foundation +import Testing +import TokenLinkCore +import TokenLinkProviders + +@testable import TokenLinkApp + +@Test func localCostEstimatorBuildsSevenDayClaudeEstimateWithoutContentRetention() throws { + // Catches duplicate charging, partial pricing, or retaining transcript content/IDs. + let root = try temporaryCostHome() + defer { try? FileManager.default.removeItem(at: root) } + let directory = root.appending( + path: ".claude/projects/project", + directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data( + """ + {"timestamp":"2026-08-29T12:00:00Z","type":"assistant","message":{"id":"m1","model":"priced-claude","content":"must-not-survive","usage":{"input_tokens":1000000,"cache_read_input_tokens":1000000,"cache_creation_input_tokens":1000000,"output_tokens":1000000}}} + {"timestamp":"2026-08-29T12:01:00Z","type":"assistant","message":{"id":"m-unknown","model":"unknown-claude","content":"also-private","usage":{"input_tokens":1000000,"output_tokens":1000000}}} + + """.utf8 + ).write(to: directory.appending(path: "a.jsonl")) + try Data( + """ + {"timestamp":"2026-08-29T12:00:00Z","type":"assistant","message":{"id":"m1","model":"priced-claude","content":"duplicate-private","usage":{"input_tokens":1000000,"cache_read_input_tokens":1000000,"cache_creation_input_tokens":1000000,"output_tokens":1000000}}} + + """.utf8 + ).write(to: directory.appending(path: "b.jsonl")) + + let through = Date(timeIntervalSince1970: 1_788_134_400) + let since = through.addingTimeInterval(-604_800) + let effective = Date(timeIntervalSince1970: 1_787_961_600) + let catalog = PriceCatalog( + version: "test-catalog", + effectiveDate: effective, + entries: [ + ModelPrice( + provider: .claude, + modelID: "priced-claude", + aliases: [], + currency: "USD", + uncachedInputPerMillion: 1, + cacheReadPerMillion: 2, + cacheWriteFiveMinutePerMillion: 3, + outputPerMillion: 4, + sourceURL: URL(string: "https://example.com/claude")!) + ]) + let scannedAt = through.addingTimeInterval(60) + let estimator = LocalCostEstimator( + observer: LocalUsageObserver(homeURL: root), + catalog: catalog, + now: { scannedAt }) + + let snapshot = try estimator.estimate( + provider: .claude, + since: since, + through: through) + + #expect(snapshot.period == DateInterval(start: since, end: through)) + #expect(snapshot.catalogVersion == "test-catalog") + #expect(snapshot.catalogEffectiveDate == effective) + #expect(snapshot.scannedAt == scannedAt) + #expect(snapshot.lineItems.count == 1) + #expect(snapshot.lineItems[0].usage.modelID == "priced-claude") + #expect(snapshot.lineItems[0].usage.deduplicationKey.isEmpty) + #expect(snapshot.lineItems[0].amount == CurrencyAmount(value: 10, currency: "USD")) + #expect(snapshot.lineItems[0].warnings == [.assumedFiveMinuteCacheWrite]) + #expect(snapshot.totals == [CurrencyAmount(value: 10, currency: "USD")]) + #expect(snapshot.unknownModelIDs == ["unknown-claude"]) + let presentation = String(describing: snapshot) + #expect(!presentation.contains("must-not-survive")) + #expect(!presentation.contains("also-private")) + #expect(!presentation.contains("duplicate-private")) + #expect(!presentation.contains("m1")) +} + +@Test func localCostEstimatorPricesBeforeAggregationAndKeepsCurrenciesSeparate() throws { + // Catches applying long-context multipliers to an aggregated seven-day token count. + let root = try temporaryCostHome() + defer { try? FileManager.default.removeItem(at: root) } + let directory = root.appending( + path: ".kimi-code/sessions/project/session/agents/agent", + directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data( + """ + {"type":"usage.record","time":1788048000000,"model":"short-context","usage":{"inputOther":600,"inputCacheRead":0,"inputCacheCreation":0,"output":0}} + {"type":"usage.record","time":1788048060000,"model":"short-context","usage":{"inputOther":600,"inputCacheRead":0,"inputCacheCreation":0,"output":0}} + {"type":"usage.record","time":1788048120000,"model":"euro-model","usage":{"inputOther":1000000,"inputCacheRead":0,"inputCacheCreation":0,"output":0}} + + """.utf8 + ).write(to: directory.appending(path: "wire.jsonl")) + + let through = Date(timeIntervalSince1970: 1_788_134_400) + let since = through.addingTimeInterval(-604_800) + let catalog = PriceCatalog( + version: "request-pricing", + effectiveDate: since, + entries: [ + ModelPrice( + provider: .kimi, + modelID: "short-context", + aliases: [], + currency: "USD", + uncachedInputPerMillion: 1, + cacheReadPerMillion: nil, + outputPerMillion: nil, + sourceURL: URL(string: "https://example.com/usd")!, + longContext: LongContextPricing( + thresholdInputTokens: 1_000, + inputMultiplier: 2, + outputMultiplier: 1)), + ModelPrice( + provider: .kimi, + modelID: "euro-model", + aliases: [], + currency: "EUR", + uncachedInputPerMillion: 2, + cacheReadPerMillion: nil, + outputPerMillion: nil, + sourceURL: URL(string: "https://example.com/eur")!), + ]) + let estimator = LocalCostEstimator( + observer: LocalUsageObserver(homeURL: root), + catalog: catalog, + now: { through }) + + let snapshot = try estimator.estimate( + provider: .kimi, + since: since, + through: through) + + #expect(snapshot.lineItems.map(\.usage.modelID) == ["euro-model", "short-context"]) + #expect( + snapshot.lineItems.first { $0.usage.modelID == "short-context" }?.amount.value + == Decimal(string: "0.0012")) + #expect( + snapshot.totals == [ + CurrencyAmount(value: 2, currency: "EUR"), + CurrencyAmount(value: Decimal(string: "0.0012")!, currency: "USD"), + ]) +} + +@Test func localCostEstimatorSkipsCountersThatOverflowAnAggregate() throws { + // Catches repeated attacker-controlled Int.max counters trapping the local scanner. + let root = try temporaryCostHome() + defer { try? FileManager.default.removeItem(at: root) } + let directory = root.appending( + path: ".kimi-code/sessions/project/session/agents/agent", + directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data( + """ + {"type":"usage.record","time":1788048000000,"model":"huge","usage":{"inputOther":9223372036854775807,"inputCacheRead":0,"inputCacheCreation":0,"output":0}} + {"type":"usage.record","time":1788048060000,"model":"huge","usage":{"inputOther":9223372036854775807,"inputCacheRead":0,"inputCacheCreation":0,"output":0}} + + """.utf8 + ).write(to: directory.appending(path: "wire.jsonl")) + + let through = Date(timeIntervalSince1970: 1_788_134_400) + let catalog = PriceCatalog( + version: "overflow-test", + effectiveDate: through, + entries: [ + ModelPrice( + provider: .kimi, + modelID: "huge", + aliases: [], + currency: "USD", + uncachedInputPerMillion: 1, + cacheReadPerMillion: nil, + outputPerMillion: nil, + sourceURL: URL(string: "https://example.com/huge")!) + ]) + let snapshot = try LocalCostEstimator( + observer: LocalUsageObserver(homeURL: root), + catalog: catalog, + now: { through } + ).estimate( + provider: .kimi, + since: through.addingTimeInterval(-604_800), + through: through) + + #expect(snapshot.lineItems.first?.usage.uncachedInputTokens == Int.max) + #expect(snapshot.warnings.contains(.invalidTokenCount)) +} + +private func temporaryCostHome() throws -> URL { + let root = FileManager.default.temporaryDirectory.appending( + path: UUID().uuidString, + directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root +} diff --git a/Tests/TokenLinkAppTests/LocalCostResourceTests.swift b/Tests/TokenLinkAppTests/LocalCostResourceTests.swift new file mode 100644 index 0000000..0bd540d --- /dev/null +++ b/Tests/TokenLinkAppTests/LocalCostResourceTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing + +@testable import TokenLinkApp + +private struct LocalCostResourceResult { + let byteCount: Int + let eventCount: Int + let inputTokens: Int + let cachedInputTokens: Int + let outputTokens: Int + + var totalTokens: Int { inputTokens + outputTokens } +} + +private enum LocalCostResourceWorkload { + private static let fileBytes = 64 * 1_024 * 1_024 + private static let recordBytes = 4_096 + private static let recordsPerBatch = JSONLStreamingReader.chunkBytes / recordBytes + + static func run() throws -> LocalCostResourceResult { + let directory = FileManager.default.temporaryDirectory.appending( + path: UUID().uuidString, + directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appending(path: "resource-workload.jsonl") + + let record = makeRecord() + var batch = Data(capacity: JSONLStreamingReader.chunkBytes) + for _ in 0.. Data { + var data = Data( + """ + {"timestamp":"2999-01-01T00:00:00Z","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"cached_input_tokens":2,"output_tokens":5}}}} + """.utf8) + precondition(data.count < recordBytes) + data.append(Data(repeating: 0x20, count: recordBytes - data.count - 1)) + data.append(0x0A) + precondition(data.count == recordBytes) + return data + } +} + +@Test func localCostResourceWorkloadStreamsExpectedTotals() throws { + guard ProcessInfo.processInfo.environment["TOKENLINK_RESOURCE_WORKLOAD"] == "1" else { + return + } + + let result = try LocalCostResourceWorkload.run() + + #expect(result.byteCount == 67_108_864) + #expect(result.eventCount == 16_384) + #expect(result.inputTokens == 163_840) + #expect(result.cachedInputTokens == 32_768) + #expect(result.outputTokens == 81_920) + #expect(result.totalTokens == 245_760) +} diff --git a/Tests/TokenLinkAppTests/LocalUsageTests.swift b/Tests/TokenLinkAppTests/LocalUsageTests.swift index 42a97a2..550dbb9 100644 --- a/Tests/TokenLinkAppTests/LocalUsageTests.swift +++ b/Tests/TokenLinkAppTests/LocalUsageTests.swift @@ -107,3 +107,150 @@ import TokenLinkCore #expect(summary.outputTokens == 5) #expect(summary.eventCount == 1) } + +@Test func observerReportsAnExistingButUnreadableTranscriptDirectory() throws { + // Catches a permission failure being presented as if no transcript directory exists. + let home = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + let sessions = home.appending(path: ".codex/sessions", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: sessions.path) + try? FileManager.default.removeItem(at: home) + } + try FileManager.default.setAttributes( + [.posixPermissions: 0o000], + ofItemAtPath: sessions.path) + + let report = try LocalUsageObserver(homeURL: home).scan( + CodexRolloutParser.self, + since: .distantPast) + + #expect(report.unreadableFileCount == 1) +} + +@Test func codexCostParserTracksModelsCumulativeDeltasAndChildBaseline() throws { + // Catches charging cached input twice, repeated totals, or replayed parent totals. + var parser = CodexCostRecordParser() + #expect( + parser.consume( + jsonRecord( + #"{"timestamp":"2026-08-30T00:00:00Z","type":"session_meta","payload":{"source":"vscode","thread_source":"user"}}"# + )) + == nil) + #expect( + parser.consume( + jsonRecord( + #"{"timestamp":"2026-08-30T00:00:01Z","type":"turn_context","payload":{"model":"gpt-5.4"}}"# + )) + == nil) + let firstValue = parser.consume( + jsonRecord( + #"{"timestamp":"2026-08-30T00:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":100,"cached_input_tokens":40,"cache_write_input_tokens":5,"output_tokens":20}}}}"# + )) + let first = try #require(firstValue) + #expect(first.modelID == "gpt-5.4") + #expect(first.uncachedInputTokens == 60) + #expect(first.cacheReadTokens == 40) + #expect(first.cacheWriteTokens == 5) + #expect(first.outputTokens == 20) + + let repeated = parser.consume( + jsonRecord( + #"{"timestamp":"2026-08-30T00:00:03Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":100,"cached_input_tokens":40,"cache_write_input_tokens":5,"output_tokens":20}}}}"# + )) + #expect(repeated == nil) + _ = parser.consume( + jsonRecord( + #"{"timestamp":"2026-08-30T00:00:04Z","type":"turn_context","payload":{"model":"gpt-5.5"}}"#)) + let changedModelValue = parser.consume( + jsonRecord( + #"{"timestamp":"2026-08-30T00:00:05Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":160,"cached_input_tokens":60,"cache_write_input_tokens":8,"output_tokens":30}}}}"# + )) + let changedModel = try #require(changedModelValue) + #expect(changedModel.modelID == "gpt-5.5") + #expect(changedModel.uncachedInputTokens == 40) + #expect(changedModel.cacheReadTokens == 20) + #expect(changedModel.cacheWriteTokens == 3) + #expect(changedModel.outputTokens == 10) + + let reset = parser.consume( + jsonRecord( + #"{"timestamp":"2026-08-30T00:00:06Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10,"cached_input_tokens":0,"cache_write_input_tokens":0,"output_tokens":2}}}}"# + )) + #expect(reset == nil) + let afterResetValue = parser.consume( + jsonRecord( + #"{"timestamp":"2026-08-30T00:00:07Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":30,"cached_input_tokens":5,"cache_write_input_tokens":0,"output_tokens":5}}}}"# + )) + let afterReset = try #require(afterResetValue) + #expect(afterReset.uncachedInputTokens == 15) + #expect(afterReset.cacheReadTokens == 5) + #expect(afterReset.outputTokens == 3) + + parser.finish() + _ = parser.consume( + jsonRecord( + #"{"timestamp":"2026-08-30T01:00:00Z","type":"session_meta","payload":{"source":{"subagent":{"thread_spawn":{"depth":1,"parent_thread_id":"parent"}}},"thread_source":"subagent"}}"# + )) + _ = parser.consume( + jsonRecord( + #"{"timestamp":"2026-08-30T01:00:01Z","type":"turn_context","payload":{"model":"gpt-5.4"}}"#)) + let replayedParent = parser.consume( + jsonRecord( + #"{"timestamp":"2026-08-30T01:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":500,"cached_input_tokens":300,"cache_write_input_tokens":0,"output_tokens":100}}}}"# + )) + #expect(replayedParent == nil) + let childDeltaValue = parser.consume( + jsonRecord( + #"{"timestamp":"2026-08-30T01:00:03Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":550,"cached_input_tokens":320,"cache_write_input_tokens":0,"output_tokens":110}}}}"# + )) + let childDelta = try #require(childDeltaValue) + #expect(childDelta.uncachedInputTokens == 30) + #expect(childDelta.cacheReadTokens == 20) + #expect(childDelta.outputTokens == 10) +} + +@Test func claudeCostParserKeepsFourBucketsAndDeduplicatesMessageIDs() throws { + // Catches merging cache reads/writes into ordinary input or charging a duplicate message. + var parser = ClaudeCostRecordParser() + let record = jsonRecord( + #"{"timestamp":"2026-08-30T02:00:00.000Z","type":"assistant","message":{"id":"message-1","model":"claude-sonnet-5","usage":{"input_tokens":100,"cache_read_input_tokens":200,"cache_creation_input_tokens":300,"output_tokens":40}}}"# + ) + + let usageValue = parser.consume(record) + let usage = try #require(usageValue) + #expect(usage.modelID == "claude-sonnet-5") + #expect(usage.uncachedInputTokens == 100) + #expect(usage.cacheReadTokens == 200) + #expect(usage.cacheWriteTokens == 300) + #expect(usage.cacheWriteDuration == nil) + #expect(usage.outputTokens == 40) + #expect(usage.deduplicationKey == "message-1") + #expect(parser.consume(record) == nil) + + parser.finish() + #expect(parser.consume(record) != nil) +} + +@Test func kimiCostParserUsesTopLevelModelAndDirectBuckets() throws { + // Catches losing the top-level model or folding Kimi cache creation into inputOther. + var parser = KimiCostRecordParser() + let usageValue = parser.consume( + jsonRecord( + #"{"type":"usage.record","time":1788048000000,"model":"kimi-code/k3","usage":{"inputOther":100,"inputCacheRead":200,"inputCacheCreation":300,"output":40},"usageScope":"turn"}"# + )) + let usage = try #require(usageValue) + + #expect(usage.modelID == "kimi-code/k3") + #expect(usage.uncachedInputTokens == 100) + #expect(usage.cacheReadTokens == 200) + #expect(usage.cacheWriteTokens == 300) + #expect(usage.outputTokens == 40) + #expect(usage.timestamp == Date(timeIntervalSince1970: 1_788_048_000)) +} + +private func jsonRecord(_ value: String) -> Data { + Data(value.utf8) +} diff --git a/Tests/TokenLinkAppTests/ProviderLogoTests.swift b/Tests/TokenLinkAppTests/ProviderLogoTests.swift index d718703..ea89327 100644 --- a/Tests/TokenLinkAppTests/ProviderLogoTests.swift +++ b/Tests/TokenLinkAppTests/ProviderLogoTests.swift @@ -1,10 +1,12 @@ import Testing import TokenLinkCore +import TokenLinkProviders @testable import TokenLinkApp -@Test func everyProviderLogoLoadsFromResources() { - for provider in ProviderID.allCases { +@Test func everyQuotaProviderLogoLoadsFromResources() { + // Cost-only providers use the explicit SF Symbol fallback in ProviderMark. + for provider in ProviderRegistry.quotaProviderIDs { #expect(ProviderLogo.image(for: provider) != nil) } } diff --git a/Tests/TokenLinkCoreTests/CostModelsTests.swift b/Tests/TokenLinkCoreTests/CostModelsTests.swift new file mode 100644 index 0000000..f0c24c2 --- /dev/null +++ b/Tests/TokenLinkCoreTests/CostModelsTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Testing + +@testable import TokenLinkCore + +private let sourceURL = URL(string: "https://example.com/pricing")! + +@Test func priceCalculationUsesFourIndependentBuckets() throws { + // Catches dropping or double-charging any cache/input/output bucket. + let usage = NormalizedModelUsage( + provider: .claude, + modelID: "claude-sonnet-4-6", + timestamp: Date(timeIntervalSince1970: 1), + uncachedInputTokens: 1_000_000, + cacheReadTokens: 1_000_000, + cacheWriteTokens: 1_000_000, + outputTokens: 1_000_000, + deduplicationKey: "m1") + let price = ModelPrice( + provider: .claude, + modelID: "claude-sonnet-4-6", + aliases: [], + currency: "USD", + uncachedInputPerMillion: 3, + cacheReadPerMillion: Decimal(string: "0.3"), + cacheWriteFiveMinutePerMillion: Decimal(string: "3.75"), + cacheWriteOneHourPerMillion: 6, + outputPerMillion: 15, + sourceURL: sourceURL) + + let item = try #require(CostCalculator.lineItem(usage: usage, price: price)) + + #expect(item.amount == CurrencyAmount(value: Decimal(string: "22.05")!, currency: "USD")) + #expect(item.warnings == [.assumedFiveMinuteCacheWrite]) +} + +@Test func priceCalculationAppliesLongContextPerRequest() throws { + // Catches applying the >272K multiplier to a seven-day aggregate or at the boundary itself. + let price = ModelPrice( + provider: .codex, + modelID: "gpt-5.5", + aliases: [], + currency: "usd", + uncachedInputPerMillion: 5, + cacheReadPerMillion: Decimal(string: "0.5"), + outputPerMillion: 30, + sourceURL: sourceURL, + longContext: LongContextPricing( + thresholdInputTokens: 272_000, + inputMultiplier: 2, + outputMultiplier: Decimal(string: "1.5")!)) + let atBoundary = NormalizedModelUsage( + provider: .codex, + modelID: "gpt-5.5", + timestamp: .distantPast, + uncachedInputTokens: 272_000, + outputTokens: 1_000_000) + let overBoundary = NormalizedModelUsage( + provider: .codex, + modelID: "gpt-5.5", + timestamp: .distantPast, + uncachedInputTokens: 272_001, + outputTokens: 1_000_000) + + #expect( + try #require(CostCalculator.lineItem(usage: atBoundary, price: price)).amount.value + == Decimal(string: "31.36")) + #expect( + try #require(CostCalculator.lineItem(usage: overBoundary, price: price)).amount.value + == Decimal(string: "47.72001")) +} + +@Test func normalizedUsageSaturatesTotalInputInsteadOfOverflowing() { + // Catches malicious transcript counters trapping before long-context pricing. + let usage = NormalizedModelUsage( + provider: .kimi, + modelID: "huge", + timestamp: .distantPast, + uncachedInputTokens: Int.max, + cacheReadTokens: Int.max, + cacheWriteTokens: Int.max) + + #expect(usage.totalInputTokens == Int.max) +} + +@Test func priceCalculationRejectsPartiallyPricedUsage() { + // Catches silently undercounting a model when one used category lacks a rate. + let usage = NormalizedModelUsage( + provider: .kimi, + modelID: "kimi-k3", + timestamp: .distantPast, + uncachedInputTokens: 100, + cacheWriteTokens: 1, + outputTokens: 10) + let price = ModelPrice( + provider: .kimi, + modelID: "kimi-k3", + aliases: [], + currency: "USD", + uncachedInputPerMillion: 3, + cacheReadPerMillion: Decimal(string: "0.3"), + outputPerMillion: 15, + sourceURL: sourceURL) + + #expect(CostCalculator.lineItem(usage: usage, price: price) == nil) +} + +@Test func totalsKeepCurrenciesSeparate() { + // Catches adding unrelated currencies into one misleading total. + let usage = NormalizedModelUsage( + provider: .codex, modelID: "gpt-5.5", timestamp: .distantPast) + let items = [ + ModelCostLineItem( + usage: usage, + amount: CurrencyAmount(value: Decimal(string: "1.25")!, currency: "USD")), + ModelCostLineItem( + usage: usage, + amount: CurrencyAmount(value: Decimal(string: "2.75")!, currency: "usd")), + ModelCostLineItem( + usage: usage, + amount: CurrencyAmount(value: 9, currency: "CNY")), + ] + + #expect( + CostCalculator.totals(for: items) + == [ + CurrencyAmount(value: 9, currency: "CNY"), + CurrencyAmount(value: 4, currency: "USD"), + ]) +} diff --git a/Tests/TokenLinkCoreTests/CostStoreTests.swift b/Tests/TokenLinkCoreTests/CostStoreTests.swift new file mode 100644 index 0000000..b29ec16 --- /dev/null +++ b/Tests/TokenLinkCoreTests/CostStoreTests.swift @@ -0,0 +1,103 @@ +import Foundation +import Testing + +@testable import TokenLinkCore + +private let openRouterAccount = UUID( + uuidString: "00000000-0000-0000-0000-00000000C001")! + +private func authoritativeSnapshot(at date: Date) -> AuthoritativeCostSnapshot { + AuthoritativeCostSnapshot( + provider: .openrouter, + balances: [AccountBalance(currency: "USD", available: 42)], + fetchedAt: date) +} + +private func estimatedSnapshot(at date: Date) -> EstimatedCostSnapshot { + EstimatedCostSnapshot( + provider: .codex, + period: DateInterval(start: date.addingTimeInterval(-604_800), end: date), + lineItems: [], + totals: [CurrencyAmount(value: 3, currency: "USD")], + unknownModelIDs: [], + catalogVersion: "test", + catalogEffectiveDate: date, + scannedAt: date) +} + +@Test func costStoreKeepsAuthoritativeLastKnownGoodOnFailure() async { + let snapshot = authoritativeSnapshot(at: Date(timeIntervalSince1970: 100)) + let store = CostStore(now: { Date(timeIntervalSince1970: 200) }) + + await store.acceptAuthoritative(.success(snapshot), accountID: openRouterAccount) + #expect(await store.authoritativeState(for: openRouterAccount).phase == .healthy) + + await store.acceptAuthoritative( + .failure(.network("offline")), + accountID: openRouterAccount) + let failed = await store.authoritativeState(for: openRouterAccount) + #expect(failed.phase == .stale) + #expect(failed.snapshot == snapshot) + #expect(failed.error?.kind == .network) +} + +@Test func costStoreMapsInitialFailuresWithoutInventingSnapshots() async { + let store = CostStore() + await store.acceptAuthoritative( + .failure(.missingCredential("no key")), + accountID: openRouterAccount) + await store.acceptEstimate( + .failure(.init(kind: .process, message: "unreadable")), + provider: .codex) + + let authoritative = await store.authoritativeState(for: openRouterAccount) + let estimated = await store.estimatedState(for: .codex) + #expect(authoritative.phase == .missingCredential) + #expect(authoritative.snapshot == nil) + #expect(estimated.phase == .error) + #expect(estimated.snapshot == nil) + #expect(estimated.error?.kind == .process) +} + +@Test func costStoreUsesIndependentFifteenAndThirtyMinuteTTLs() async { + let clock = CostStoreClock(Date(timeIntervalSince1970: 1_000)) + let store = CostStore(now: clock.callAsFunction) + await store.acceptAuthoritative( + .success(authoritativeSnapshot(at: clock.value)), + accountID: openRouterAccount) + await store.acceptEstimate( + .success(estimatedSnapshot(at: clock.value)), + provider: .codex) + + clock.value = Date(timeIntervalSince1970: 1_901) + #expect(await store.authoritativeState(for: openRouterAccount).phase == .stale) + #expect(await store.estimatedState(for: .codex).phase == .healthy) + + clock.value = Date(timeIntervalSince1970: 2_800) + #expect(await store.estimatedState(for: .codex).phase == .healthy) + clock.value = Date(timeIntervalSince1970: 2_801) + #expect(await store.estimatedState(for: .codex).phase == .stale) +} + +@Test func clearingCostStoreDoesNotRequireOrAffectProviderStore() async { + let store = CostStore() + await store.acceptAuthoritative( + .success(authoritativeSnapshot(at: Date())), + accountID: openRouterAccount) + await store.acceptEstimate(.success(estimatedSnapshot(at: Date())), provider: .codex) + + await store.clear() + + #expect(await store.authoritativeState(for: openRouterAccount).phase == .disabled) + #expect(await store.estimatedState(for: .codex).phase == .disabled) +} + +private final class CostStoreClock: @unchecked Sendable { + var value: Date + + init(_ value: Date) { + self.value = value + } + + func callAsFunction() -> Date { value } +} diff --git a/Tests/TokenLinkProviderTests/AuthoritativeCostProviderTests.swift b/Tests/TokenLinkProviderTests/AuthoritativeCostProviderTests.swift new file mode 100644 index 0000000..24bed07 --- /dev/null +++ b/Tests/TokenLinkProviderTests/AuthoritativeCostProviderTests.swift @@ -0,0 +1,311 @@ +import Foundation +import Testing +import TokenLinkCore + +@testable import TokenLinkProviders + +private struct CostCredentials: CredentialReader { + let value: String? + + func apiKey(forAccount account: String) async throws -> String? { value } + func cliAccessToken(for provider: ProviderID) async throws -> String? { nil } +} + +private actor CostHTTPClient: HTTPClient { + private let responses: [String: HTTPResponse] + private(set) var requests: [URLRequest] = [] + + init(responses: [String: HTTPResponse]) { + self.responses = responses + } + + func data(for request: URLRequest, policy: EndpointPolicy) async throws -> HTTPResponse { + guard let url = request.url else { throw ProviderHostError() } + _ = try policy.validate(url) + requests.append(request) + return responses[url.path] ?? HTTPResponse(data: Data(), statusCode: 500) + } +} + +private struct FailingCostHTTPClient: HTTPClient { + enum Failure: Sendable { + case timedOut + case cancelled + } + + let failure: Failure + + func data(for request: URLRequest, policy: EndpointPolicy) async throws -> HTTPResponse { + switch failure { + case .timedOut: + throw URLError(.timedOut) + case .cancelled: + throw CancellationError() + } + } +} + +private func response(_ fixture: String, statusCode: Int = 200) throws -> HTTPResponse { + HTTPResponse(data: try Fixture.load(fixture), statusCode: statusCode) +} + +@Test func openRouterCombinesCreditsAndCurrentKeySpend() async throws { + // Catches dropping one successful official endpoint or deriving the wrong remaining balance. + let http = CostHTTPClient( + responses: [ + "/api/v1/credits": try response("openrouter-credits.json"), + "/api/v1/key": try response("openrouter-key.json"), + ]) + let provider = OpenRouterCostProvider( + credentialAccount: "openrouter.account", + http: http, + credentials: CostCredentials(value: "management-key"), + now: { Date(timeIntervalSince1970: 1_788_048_000) }) + + let snapshot = try await provider.fetch().get() + + #expect( + snapshot.balances == [ + AccountBalance( + currency: "USD", available: Decimal(string: "74.5")!, purchased: 100, + used: Decimal(string: "25.5")!) + ]) + #expect( + snapshot.periodSpend.first { $0.period == .weekly }?.amount + == CurrencyAmount(value: Decimal(string: "7.25")!, currency: "USD")) + #expect(snapshot.warnings.isEmpty) + #expect(snapshot.fetchedAt == Date(timeIntervalSince1970: 1_788_048_000)) + let requests = await http.requests + #expect(requests.count == 2) + #expect( + requests.allSatisfy { + $0.value(forHTTPHeaderField: "Authorization") == "Bearer management-key" + }) + #expect(requests.allSatisfy { $0.url?.host == "openrouter.ai" }) +} + +@Test func openRouterKeepsCurrentKeyWhenCreditsRequireManagementPermission() async throws { + // Catches treating a normal API key's partial `/key` data as wholly unavailable. + let http = CostHTTPClient( + responses: [ + "/api/v1/credits": HTTPResponse(data: Data(), statusCode: 403), + "/api/v1/key": try response("openrouter-key.json"), + ]) + let provider = OpenRouterCostProvider( + http: http, + credentials: CostCredentials(value: "api-key")) + + let snapshot = try await provider.fetch().get() + + #expect(snapshot.balances.first?.available.value == Decimal(string: "74.5")) + #expect(snapshot.warnings == [.partialSource("credits")]) +} + +@Test func openRouterKeepsCreditsWhenCurrentKeyFails() async throws { + // Catches one endpoint failure erasing authoritative lifetime credit data. + let http = CostHTTPClient( + responses: [ + "/api/v1/credits": try response("openrouter-credits.json"), + "/api/v1/key": HTTPResponse(data: Data(), statusCode: 500), + ]) + let provider = OpenRouterCostProvider( + http: http, + credentials: CostCredentials(value: "management-key")) + + let snapshot = try await provider.fetch().get() + + #expect(snapshot.balances.first?.available.value == Decimal(string: "74.5")) + #expect(snapshot.periodSpend.isEmpty) + #expect(snapshot.warnings == [.partialSource("key")]) +} + +@Test func openRouterFailsAuthenticationWhenNeitherEndpointIsUsable() async { + // Catches masking a rejected credential as a generic network failure. + let http = CostHTTPClient( + responses: [ + "/api/v1/credits": HTTPResponse(data: Data(), statusCode: 403), + "/api/v1/key": HTTPResponse(data: Data(), statusCode: 401), + ]) + let provider = OpenRouterCostProvider( + http: http, + credentials: CostCredentials(value: "rejected")) + + guard case .failure(let failure) = await provider.fetch() else { + Issue.record("Expected authentication failure") + return + } + #expect(failure.kind == .authentication) +} + +@Test func openRouterClampsExhaustedCreditsAndRequiresAllFailuresForAuthentication() async throws { + // Catches negative remaining credit and over-reporting authentication for a mixed failure. + let exhausted = CostHTTPClient( + responses: [ + "/api/v1/credits": HTTPResponse( + data: Data(#"{"data":{"total_credits":"1","total_usage":2}}"#.utf8), + statusCode: 200), + "/api/v1/key": HTTPResponse(data: Data(), statusCode: 500), + ]) + let exhaustedProvider = OpenRouterCostProvider( + http: exhausted, + credentials: CostCredentials(value: "management-key")) + let snapshot = try await exhaustedProvider.fetch().get() + #expect(snapshot.balances.first?.available.value == 0) + + let mixedFailure = OpenRouterCostProvider( + http: CostHTTPClient( + responses: [ + "/api/v1/credits": HTTPResponse(data: Data(), statusCode: 403), + "/api/v1/key": HTTPResponse(data: Data(), statusCode: 500), + ]), + credentials: CostCredentials(value: "management-key")) + guard case .failure(let failure) = await mixedFailure.fetch() else { + Issue.record("Expected a mixed endpoint failure") + return + } + #expect(failure.kind == .network) +} + +@Test func openRouterRejectsBooleanAndNonFiniteNumericValues() async { + // Catches JSON booleans or non-finite strings being accepted as money. + let malformedCredits = Data( + #"{"data":{"total_credits":true,"total_usage":"NaN"}}"#.utf8) + let http = CostHTTPClient( + responses: [ + "/api/v1/credits": HTTPResponse(data: malformedCredits, statusCode: 200), + "/api/v1/key": HTTPResponse(data: Data(#"{"data":{}}"#.utf8), statusCode: 200), + ]) + let provider = OpenRouterCostProvider( + http: http, + credentials: CostCredentials(value: "management-key")) + + guard case .failure(let failure) = await provider.fetch() else { + Issue.record("Expected decoding failure") + return + } + #expect(failure.kind == .decoding) +} + +@Test func openRouterRejectsPartiallyParsedDecimalStrings() async { + // Catches Foundation Decimal accepting a numeric prefix or locale-dependent spelling. + let malformedValues = [ + "1abc", "1.2.3", "1,2", " 1", "1 ", "--1", "1e", "1e+", + ] + for malformedValue in malformedValues { + let malformedCredits = Data( + #"{"data":{"total_credits":"\#(malformedValue)","total_usage":"1"}}"#.utf8) + let provider = OpenRouterCostProvider( + http: CostHTTPClient( + responses: [ + "/api/v1/credits": HTTPResponse(data: malformedCredits, statusCode: 200), + "/api/v1/key": HTTPResponse(data: Data(#"{"data":{}}"#.utf8), statusCode: 200), + ]), + credentials: CostCredentials(value: "management-key")) + + guard case .failure(let failure) = await provider.fetch() else { + Issue.record("Expected decoding failure for \(malformedValue)") + continue + } + #expect(failure.kind == .decoding) + } +} + +@Test func authoritativeCostsClassifyTimeoutAndCancellationSeparatelyFromNetwork() async { + // Catches actionable transport states collapsing into a generic network failure. + let timedOutProviders: [any AuthoritativeCostProvider] = [ + OpenRouterCostProvider( + http: FailingCostHTTPClient(failure: .timedOut), + credentials: CostCredentials(value: "key")), + DeepSeekCostProvider( + http: FailingCostHTTPClient(failure: .timedOut), + credentials: CostCredentials(value: "key")), + ] + for provider in timedOutProviders { + guard case .failure(let failure) = await provider.fetch() else { + Issue.record("Expected timeout for \(provider.id.rawValue)") + continue + } + #expect(failure.kind == .timeout) + #expect(failure.message.localizedCaseInsensitiveContains("timed out")) + } + + let cancelled = DeepSeekCostProvider( + http: FailingCostHTTPClient(failure: .cancelled), + credentials: CostCredentials(value: "key")) + guard case .failure(let cancellation) = await cancelled.fetch() else { + Issue.record("Expected cancellation failure") + return + } + #expect(cancellation.kind == .timeout) + #expect(cancellation.message.localizedCaseInsensitiveContains("cancelled")) +} + +@Test func deepSeekPreservesCurrenciesAvailabilityAndZero() async throws { + // Catches currency conversion, zero-as-missing, or discarded availability state. + let http = CostHTTPClient( + responses: [ + "/user/balance": try response("deepseek-balance.json") + ]) + let provider = DeepSeekCostProvider( + credentialAccount: "deepseek.account", + http: http, + credentials: CostCredentials(value: "deepseek-key"), + now: { Date(timeIntervalSince1970: 1_788_048_000) }) + + let snapshot = try await provider.fetch().get() + + #expect(snapshot.isAvailable == false) + #expect(snapshot.balances.map(\.available.currency) == ["CNY", "USD"]) + #expect(snapshot.balances.map(\.available.value) == [Decimal(string: "110.00")!, 0]) + #expect(snapshot.periodSpend.isEmpty) + let request = try #require(await http.requests.first) + #expect(request.url == URL(string: "https://api.deepseek.com/user/balance")) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer deepseek-key") +} + +@Test func deepSeekMapsAuthenticationAndMalformedMoney() async { + // Catches presenting rejected or malformed responses as a valid zero balance. + let unauthorized = DeepSeekCostProvider( + http: CostHTTPClient( + responses: ["/user/balance": HTTPResponse(data: Data(), statusCode: 401)]), + credentials: CostCredentials(value: "rejected")) + guard case .failure(let authFailure) = await unauthorized.fetch() else { + Issue.record("Expected authentication failure") + return + } + #expect(authFailure.kind == .authentication) + + let malformed = DeepSeekCostProvider( + http: CostHTTPClient( + responses: [ + "/user/balance": HTTPResponse( + data: Data( + #"{"is_available":true,"balance_infos":[{"currency":"CNY","total_balance":"Infinity","granted_balance":"0","topped_up_balance":"0"}]}"# + .utf8), + statusCode: 200) + ]), + credentials: CostCredentials(value: "deepseek-key")) + guard case .failure(let decodingFailure) = await malformed.fetch() else { + Issue.record("Expected decoding failure") + return + } + #expect(decodingFailure.kind == .decoding) +} + +@Test func authoritativeCostProvidersReportMissingCredentialsWithoutHTTP() async { + // Catches a credential-less cost refresh making an external request. + let http = CostHTTPClient(responses: [:]) + let providers: [any AuthoritativeCostProvider] = [ + OpenRouterCostProvider(http: http, credentials: CostCredentials(value: nil)), + DeepSeekCostProvider(http: http, credentials: CostCredentials(value: nil)), + ] + + for provider in providers { + guard case .failure(let failure) = await provider.fetch() else { + Issue.record("Expected missing credential for \(provider.id.rawValue)") + continue + } + #expect(failure.kind == .missingCredential) + } + #expect(await http.requests.isEmpty) +} diff --git a/Tests/TokenLinkProviderTests/Fixtures/deepseek-balance.json b/Tests/TokenLinkProviderTests/Fixtures/deepseek-balance.json new file mode 100644 index 0000000..2404d76 --- /dev/null +++ b/Tests/TokenLinkProviderTests/Fixtures/deepseek-balance.json @@ -0,0 +1,17 @@ +{ + "is_available": false, + "balance_infos": [ + { + "currency": "CNY", + "total_balance": "110.00", + "granted_balance": "10.00", + "topped_up_balance": "100.00" + }, + { + "currency": "USD", + "total_balance": 0, + "granted_balance": "0", + "topped_up_balance": "0" + } + ] +} diff --git a/Tests/TokenLinkProviderTests/Fixtures/openrouter-credits.json b/Tests/TokenLinkProviderTests/Fixtures/openrouter-credits.json new file mode 100644 index 0000000..ecc642e --- /dev/null +++ b/Tests/TokenLinkProviderTests/Fixtures/openrouter-credits.json @@ -0,0 +1,6 @@ +{ + "data": { + "total_credits": 100, + "total_usage": "25.5" + } +} diff --git a/Tests/TokenLinkProviderTests/Fixtures/openrouter-key.json b/Tests/TokenLinkProviderTests/Fixtures/openrouter-key.json new file mode 100644 index 0000000..c212d25 --- /dev/null +++ b/Tests/TokenLinkProviderTests/Fixtures/openrouter-key.json @@ -0,0 +1,27 @@ +{ + "data": { + "byok_usage": 0, + "byok_usage_daily": 0, + "byok_usage_monthly": 0, + "byok_usage_weekly": 0, + "creator_user_id": "user_fixture", + "expires_at": "2027-12-31T23:59:59Z", + "include_byok_in_limit": false, + "is_free_tier": false, + "is_management_key": false, + "is_provisioning_key": false, + "label": "fixture-key", + "limit": "100", + "limit_remaining": 74.5, + "limit_reset": "monthly", + "rate_limit": { + "interval": "1h", + "note": "deprecated", + "requests": 1000 + }, + "usage": "25.5", + "usage_daily": 1.25, + "usage_monthly": "12.50", + "usage_weekly": "7.25" + } +} diff --git a/Tests/TokenLinkProviderTests/PriceCatalogTests.swift b/Tests/TokenLinkProviderTests/PriceCatalogTests.swift new file mode 100644 index 0000000..a93352d --- /dev/null +++ b/Tests/TokenLinkProviderTests/PriceCatalogTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing +import TokenLinkCore + +@testable import TokenLinkProviders + +@Test func bundledPriceCatalogLoadsReviewedEntries() throws { + // Catches shipping an absent, stale, or partially decoded catalog resource. + let catalog = try PriceCatalog.bundled() + + #expect(catalog.version == "2026-08-30.1") + #expect(catalog.effectiveDate == Date(timeIntervalSince1970: 1_788_048_000)) + #expect(catalog.entries.count == 7) + #expect( + catalog.entry(provider: .codex, modelID: "gpt-5.5")?.outputPerMillion + == Decimal(string: "30.00")) + #expect( + catalog.entry(provider: .claude, modelID: "claude-sonnet-5")? + .cacheWriteFiveMinutePerMillion == Decimal(string: "2.50")) + #expect( + catalog.entry(provider: .kimi, modelID: "kimi-k3")?.cacheReadPerMillion + == Decimal(string: "0.30")) +} + +@Test func priceCatalogResolvesOnlyExplicitAliases() throws { + // Catches prefix matching a new upstream model to an older model's price. + let catalog = try PriceCatalog.bundled() + + #expect( + catalog.entry(provider: .kimi, modelID: "kimi-code/k3")?.modelID + == "kimi-k3") + #expect( + catalog.entry(provider: .claude, modelID: "claude-haiku-4-5")?.modelID + == "claude-haiku-4-5-20251001") + #expect(catalog.entry(provider: .codex, modelID: "gpt-5.5-future") == nil) + #expect(catalog.entry(provider: .claude, modelID: "MiniMax-M3") == nil) +} + +@Test func priceCatalogUsesFirstPartyHTTPSReferences() throws { + // Catches estimates becoming detached from an auditable first-party source. + let catalog = try PriceCatalog.bundled() + let allowedHosts: Set = [ + "developers.openai.com", + "platform.claude.com", + "platform.kimi.ai", + ] + + for entry in catalog.entries { + #expect(entry.sourceURL.scheme == "https") + #expect(entry.sourceURL.host.map(allowedHosts.contains) == true) + } +} diff --git a/Tests/TokenLinkProviderTests/ProviderSpecTests.swift b/Tests/TokenLinkProviderTests/ProviderSpecTests.swift index 823c4b8..afb896f 100644 --- a/Tests/TokenLinkProviderTests/ProviderSpecTests.swift +++ b/Tests/TokenLinkProviderTests/ProviderSpecTests.swift @@ -35,7 +35,7 @@ private func kimiFixtureClient() -> SpecHTTPClient { } @Test func registryCoversEveryProviderWithSpecOrCustomMarker() { - for id in ProviderID.allCases { + for id in ProviderRegistry.quotaProviderIDs { if id == .codex { #expect(ProviderRegistry.spec(for: id) == nil) #expect(ProviderRegistry.customProviders.contains(id)) @@ -44,6 +44,10 @@ private func kimiFixtureClient() -> SpecHTTPClient { #expect(!ProviderRegistry.customProviders.contains(id)) } } + for id in ProviderRegistry.authoritativeCostProviderIDs { + #expect(ProviderRegistry.spec(for: id) == nil) + #expect(!ProviderRegistry.customProviders.contains(id)) + } } @Test func registryDisplayNamesCoverCustomProviders() { @@ -53,6 +57,21 @@ private func kimiFixtureClient() -> SpecHTTPClient { #expect(ProviderRegistry.displayName(for: .codex) == "Codex") } +@Test func registrySeparatesQuotaAndCostCapabilities() { + #expect( + ProviderRegistry.capabilities(for: .codex) + == [.quota, .localCostEstimate]) + #expect( + ProviderRegistry.capabilities(for: .openrouter) + == [.authoritativeCost]) + #expect( + ProviderRegistry.capabilities(for: .deepseek) + == [.authoritativeCost]) + #expect(!ProviderRegistry.quotaProviderIDs.contains(.openrouter)) + #expect(ProviderRegistry.authoritativeCostProviderIDs == [.openrouter, .deepseek]) + #expect(ProviderRegistry.localCostEstimateProviderIDs == [.codex, .kimi, .claude]) +} + @Test func specDrivenProviderSendsBearerHeader() async throws { let http = kimiFixtureClient() let provider = SpecDrivenProvider( diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 7834171..c4c83d3 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -17,23 +17,26 @@ Before publishing a release: 1. Confirm `CHANGELOG.md`, both READMEs, version/build values in `packaging/Info.plist`, and the Latest News date agree. -2. Run `bash scripts/test.sh`, strict Swift formatting, and - `bash scripts/privacy_scan.sh`. -3. Run `python scripts/test_package_firmware_release.py`, +2. Run `bash scripts/test.sh`, strict Swift formatting, + `bash scripts/privacy_scan.sh`, and `bash scripts/resource_check.sh`. +3. If the release changes cost estimates, verify the bundled price-catalog + version, effective date, first-party source URLs, model aliases, and all + visible `Estimated/API-equivalent` labels. +4. Run `python scripts/test_package_firmware_release.py`, `bash scripts/test_firmware.sh`, and `bash scripts/build_firmware_artifact.sh`. Verify `dist/firmware/SHA256SUMS`. The version tag itself is the exact firmware source archive; no external checkout or separately maintained source ZIP is required. -4. For a hardware-facing release, flash the built candidate only after the exact +5. For a hardware-facing release, flash the built candidate only after the exact C152 port is resolved and explicitly confirmed. Verify the serial boot marker, BLE v2 negotiation, a live sync, and user-visible interaction separately, then record the evidence under `docs/validation/`. -5. Push, review, tag, and publish only in the TokenLink repository. Never push a +6. Push, review, tag, and publish only in the TokenLink repository. Never push a branch or open a PR in an external firmware repository as part of a TokenLink release. -6. Verify both arm64 and x86_64 slices in the mounted Mac DMG. Do not publish the +7. Verify both arm64 and x86_64 slices in the mounted Mac DMG. Do not publish the architecture-neutral filename if either slice is missing. -7. Observe the hosted CI run on the final commit before tagging, then observe +8. Observe the hosted CI run on the final commit before tagging, then observe the tag-triggered Release workflow before publishing the final URL. ## Build a development artifact diff --git a/docs/superpowers/plans/2026-08-30-provider-costs.md b/docs/superpowers/plans/2026-08-30-provider-costs.md new file mode 100644 index 0000000..ba96ec0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-provider-costs.md @@ -0,0 +1,737 @@ +# Provider Costs Beta Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add opt-in authoritative OpenRouter/DeepSeek balances and seven-day local Codex/Claude/Kimi API-equivalent cost estimates without changing quota or StopWatch behavior. + +**Architecture:** Quota and cost share `ProviderID` but have explicit capabilities, separate snapshot types, stores, refresh paths, and UI. Authoritative providers live in `TokenLinkProviders`; Decimal money models and state live in `TokenLinkCore`; bounded JSONL scanning and presentation coordination live in `TokenLinkApp`. + +**Tech Stack:** Swift 6.2, Foundation `Decimal`, Swift Concurrency actors, SwiftUI/Observation, Swift Testing, macOS Keychain, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-08-30-provider-costs-design.md` + +## Global Constraints + +- Quota refresh, BLE payloads, notifications, and StopWatch synchronization must remain independent from all cost work. +- Cost beta defaults to disabled and starts no HTTP request, scan, timer, or task while disabled. +- Every local monetary result is labelled exactly `Estimated/API-equivalent`; menu-bar estimates always include `≈`. +- Monetary arithmetic uses `Decimal`; no stored balance, price, multiplication, or total uses `Double`. +- Authoritative TTL is 15 minutes; local estimate TTL is 30 minutes; manual cost refresh bypasses both. +- JSONL reads use 64 KiB chunks, reject records over 1 MiB, and skip files over 50 MiB. +- Local data remains in memory. Logs and diagnostics omit balances, spend, monetary totals, account labels, UUIDs, paths, raw JSON, and secrets. +- English, Simplified Chinese, and Japanese strings are required for every new UI string. +- CI gates a deterministic 64 MiB scan at 160 MiB peak RSS and 30 seconds, and a release executable at 15 MiB. + +--- + +## File Structure + +### TokenLinkCore + +- `Sources/TokenLinkCore/ProviderModels.swift`: add cost-only provider IDs and provider capability values. +- `Sources/TokenLinkCore/CostModels.swift`: Decimal amounts, authoritative snapshots, normalized usage, price entries, estimates, warnings, and menu-bar selection values. +- `Sources/TokenLinkCore/CostStore.swift`: independent authoritative/estimate state and last-known-good failure handling. +- `Tests/TokenLinkCoreTests/CostModelsTests.swift`: hand-calculated Decimal totals, aliases, unknown models, and currency grouping. +- `Tests/TokenLinkCoreTests/CostStoreTests.swift`: success, stale retention, and aging behavior. + +### TokenLinkProviders + +- `Sources/TokenLinkProviders/Shared/ProviderSpec.swift`: capability registry and names for cost-only providers. +- `Sources/TokenLinkProviders/Costs/AuthoritativeCostProvider.swift`: provider/account bindings and credential/HTTP helpers shared by financial adapters. +- `Sources/TokenLinkProviders/Costs/OpenRouterCostProvider.swift`: independent `/credits` and `/key` requests and partial-success merge. +- `Sources/TokenLinkProviders/Costs/DeepSeekCostProvider.swift`: `/user/balance` parsing with multi-currency preservation. +- `Sources/TokenLinkProviders/Costs/PriceCatalog.swift`: bundled catalog decoding and alias resolution. +- `Sources/TokenLinkProviders/Resources/api-equivalent-prices.json`: reviewed first-party API prices and source/effective-date metadata. +- `Tests/TokenLinkProviderTests/AuthoritativeCostProviderTests.swift`: endpoint, credential, numeric decoding, partial failure, and host-policy behavior. +- `Tests/TokenLinkProviderTests/PriceCatalogTests.swift`: resource schema and exact alias resolution. +- `Tests/TokenLinkProviderTests/Fixtures/openrouter-credits.json`: complete credits response fixture. +- `Tests/TokenLinkProviderTests/Fixtures/openrouter-key.json`: complete current-key response fixture. +- `Tests/TokenLinkProviderTests/Fixtures/deepseek-balance.json`: complete multi-currency balance fixture. + +### TokenLinkApp + +- `Sources/TokenLinkApp/ConfigurationStore.swift`: beta setting, fixed menu-bar metric, and opt-in cost accounts. +- `Sources/TokenLinkApp/LocalUsage/JSONLStreamingReader.swift`: bounded cancellable record reader. +- `Sources/TokenLinkApp/LocalUsage/LocalUsageModels.swift`: model-aware four-bucket normalized usage and streaming parser contract. +- `Sources/TokenLinkApp/LocalUsage/LocalUsageReaders.swift`: stateful Codex, Claude, and Kimi record parsers. +- `Sources/TokenLinkApp/LocalUsage/LocalUsageObserver.swift`: sequential bounded file enumeration and streaming aggregation. +- `Sources/TokenLinkApp/Costs/LocalCostEstimator.swift`: seven-day catalog pricing and unknown-model reporting. +- `Sources/TokenLinkApp/Costs/CostDashboardModel.swift`: TTL, coalescing, force refresh, independent source errors, and menu supplement. +- `Sources/TokenLinkApp/AppModel.swift`: compose cost dashboard, expose settings actions, and keep quota/watch filters capability-based. +- `Sources/TokenLinkApp/Views/CostsView.swift`: opt-in explanation, authoritative cards, estimate cards, provenance, freshness, and manual refresh. +- `Sources/TokenLinkApp/Views/ControlCenterView.swift`: add `Costs β` route and route-specific toolbar refresh. +- `Sources/TokenLinkApp/Views/MenuBarView.swift`: show selected cost supplement without changing quota severity. +- `Sources/TokenLinkApp/Views/ProvidersView.swift`: cost-only OpenRouter/DeepSeek account and credential controls. +- `Sources/TokenLinkApp/Views/SettingsView.swift`: beta toggle and fixed metric picker. +- `Sources/TokenLinkApp/Views/ViewSupport.swift`: monogram/symbol/color fallback for the two cost-only providers. +- `Sources/TokenLinkApp/Strings.swift`: complete three-language cost catalog. +- `Sources/TokenLinkApp/DiagnosticExporter.swift`: preserve redaction boundary for cost diagnostic metadata. +- `Tests/TokenLinkAppTests/ConfigurationStoreTests.swift`: migration/default/round-trip behavior. +- `Tests/TokenLinkAppTests/JSONLStreamingReaderTests.swift`: chunk, record, file, cancellation, and unreadable-source boundaries. +- `Tests/TokenLinkAppTests/LocalCostEstimatorTests.swift`: model-aware parser and estimate behavior. +- `Tests/TokenLinkAppTests/CostDashboardModelTests.swift`: disablement, TTL, coalescing, force refresh, and partial failure. +- `Tests/TokenLinkAppTests/AppModelTests.swift`: quota/watch filtering, menu label, and diagnostic omission. +- `Tests/TokenLinkAppTests/LocalizationTests.swift`: existing exhaustive catalog check covers new keys. + +### Build and CI + +- `Package.swift`: add the provider resource bundle. +- `scripts/resource_check.sh`: build-before-measure resource and executable-size gates. +- `.github/workflows/ci.yml`: add resource-budget job on macOS. + +--- + +### Task 1: Provider Capabilities and Backward-Compatible Configuration + +**Files:** +- Modify: `Sources/TokenLinkCore/ProviderModels.swift` +- Create: `Sources/TokenLinkCore/CostModels.swift` +- Modify: `Sources/TokenLinkProviders/Shared/ProviderSpec.swift` +- Modify: `Sources/TokenLinkApp/ConfigurationStore.swift` +- Modify: `Sources/TokenLinkApp/Views/ViewSupport.swift` +- Test: `Tests/TokenLinkProviderTests/ProviderSpecTests.swift` +- Test: `Tests/TokenLinkAppTests/ConfigurationStoreTests.swift` + +**Interfaces:** +- Produces: `ProviderCapability: OptionSet`, `ProviderRegistry.capabilities(for:)`, `ProviderRegistry.quotaProviderIDs`, `ProviderRegistry.authoritativeCostProviderIDs`, `ProviderRegistry.localCostEstimateProviderIDs`. +- Produces: `AppConfiguration.betaCostsEnabled: Bool` and `AppConfiguration.menuBarCostMetric: MenuBarCostMetric`. +- Consumes later: every quota/watch/account enumeration must filter using these capabilities. + +- [ ] **Step 1: Write failing registry tests** + +Add literal behavior checks: + +```swift +@Test func registrySeparatesQuotaAndCostCapabilities() { + #expect(ProviderRegistry.capabilities(for: .codex) == [.quota, .localCostEstimate]) + #expect(ProviderRegistry.capabilities(for: .openrouter) == [.authoritativeCost]) + #expect(ProviderRegistry.capabilities(for: .deepseek) == [.authoritativeCost]) + #expect(!ProviderRegistry.quotaProviderIDs.contains(.openrouter)) + #expect(ProviderRegistry.authoritativeCostProviderIDs == [.openrouter, .deepseek]) +} +``` + +This catches accidentally treating cost-only IDs as quota providers. + +- [ ] **Step 2: Run the focused registry test and confirm RED** + +Run: `swift test --filter registrySeparatesQuotaAndCostCapabilities` + +Expected: compilation fails because the new provider IDs/capabilities do not exist. + +- [ ] **Step 3: Add provider IDs, capabilities, names, and UI fallbacks** + +Implement: + +```swift +public struct ProviderCapability: OptionSet, Sendable, Equatable { + public let rawValue: UInt8 + public init(rawValue: UInt8) { self.rawValue = rawValue } + public static let quota = Self(rawValue: 1 << 0) + public static let authoritativeCost = Self(rawValue: 1 << 1) + public static let localCostEstimate = Self(rawValue: 1 << 2) +} +``` + +Add `.openrouter` and `.deepseek`, registry mappings, and switch cases. Keep `ProviderRegistry.spec(for:)` quota-only. + +- [ ] **Step 4: Run registry tests and confirm GREEN** + +Run: `swift test --filter 'registrySeparatesQuotaAndCostCapabilities|registryDisplayNamesCoverCustomProviders'` + +Expected: PASS. + +- [ ] **Step 5: Write failing configuration migration tests** + +Add checks that an old config decodes with `betaCostsEnabled == false`, `.none` menu selection, and no OpenRouter/DeepSeek accounts; also check `.default` contains only quota accounts. + +```swift +#expect(loaded.betaCostsEnabled == false) +#expect(loaded.menuBarCostMetric == .none) +#expect(!loaded.accounts.contains { [.openrouter, .deepseek].contains($0.provider) }) +#expect(AppConfiguration.default.accounts.allSatisfy { + ProviderRegistry.capabilities(for: $0.provider).contains(.quota) +}) +``` + +- [ ] **Step 6: Run migration test and confirm RED** + +Run: `swift test --filter legacyEnabledProvidersConfigurationMigratesToAccounts` + +Expected: compilation fails for missing cost settings. + +- [ ] **Step 7: Implement defaults, Codable migration, and enablement semantics** + +Create `CostModels.swift` now with `MenuBarCostMetric` cases `.none`, `.localEstimate(ProviderID)`, and `.authoritativeBalance(accountID: UUID, currency: String)`. Decode missing keys as disabled/none. Make default account creation use `quotaProviderIDs`; cost accounts are created only by an explicit add action. Enabling cost beta changes `.none` to `.localEstimate(.codex)` in the AppModel setter, not while merely decoding. + +- [ ] **Step 8: Run configuration and provider suites** + +Run: `swift test --filter 'ConfigurationStoreTests|ProviderSpecTests'` + +Expected: PASS. + +- [ ] **Step 9: Commit Task 1** + +```bash +git add Sources/TokenLinkCore/ProviderModels.swift Sources/TokenLinkCore/CostModels.swift Sources/TokenLinkProviders/Shared/ProviderSpec.swift Sources/TokenLinkApp/ConfigurationStore.swift Sources/TokenLinkApp/Views/ViewSupport.swift Tests/TokenLinkProviderTests/ProviderSpecTests.swift Tests/TokenLinkAppTests/ConfigurationStoreTests.swift +git commit -m "feat: separate provider quota and cost capabilities" +``` + +### Task 2: Decimal Cost Domain and Bundled Price Catalog + +**Files:** +- Modify: `Sources/TokenLinkCore/CostModels.swift` +- Create: `Sources/TokenLinkProviders/Costs/PriceCatalog.swift` +- Create: `Sources/TokenLinkProviders/Resources/api-equivalent-prices.json` +- Modify: `Package.swift` +- Create: `Tests/TokenLinkCoreTests/CostModelsTests.swift` +- Create: `Tests/TokenLinkProviderTests/PriceCatalogTests.swift` + +**Interfaces:** +- Produces: `CurrencyAmount`, `AccountBalance`, `ProviderPeriodSpend`, `AuthoritativeCostSnapshot`, `NormalizedModelUsage`, `ModelPrice`, `ModelCostLineItem`, `EstimatedCostSnapshot`, and `CostWarning`. +- Produces: `PriceCatalog.bundled()`, `entry(provider:modelID:)`, and `estimate(_:)`. +- Consumes: `ProviderID` and `Decimal` only; does not depend on app state. + +- [ ] **Step 1: Write failing Decimal arithmetic tests** + +Use hand-derived totals, not production helpers: + +```swift +@Test func priceCalculationUsesFourIndependentBuckets() throws { + let usage = NormalizedModelUsage( + provider: .claude, modelID: "claude-sonnet-4-6", + timestamp: Date(timeIntervalSince1970: 1), uncachedInputTokens: 1_000_000, + cacheReadTokens: 1_000_000, cacheWriteTokens: 1_000_000, + outputTokens: 1_000_000, deduplicationKey: "m1") + let price = ModelPrice( + provider: .claude, modelID: "claude-sonnet-4-6", aliases: [], currency: "USD", + uncachedInputPerMillion: 3, cacheReadPerMillion: 0.3, + cacheWriteFiveMinutePerMillion: 3.75, cacheWriteOneHourPerMillion: 6, + outputPerMillion: 15, sourceURL: URL(string: "https://platform.claude.com")!) + + #expect(try CostCalculator.lineItem(usage: usage, price: price).amount.value == Decimal(string: "22.05")) +} +``` + +Add independent tests for alias lookup, grouped currencies, and an unknown/missing-category model contributing zero money while appearing in `unknownModelIDs`. Add a literal test that a single 272,001-token GPT-5.5 request receives the official 2x input and 1.5x output multipliers while a 272,000-token request does not. + +- [ ] **Step 2: Run cost model tests and confirm RED** + +Run: `swift test --filter CostModelsTests` + +Expected: compilation fails because cost models and calculator are absent. + +- [ ] **Step 3: Implement minimal immutable models and calculator** + +Normalize currency codes to uppercase, clamp token counts to nonnegative values, multiply with `Decimal(tokenCount) * rate / 1_000_000`, apply optional request-level threshold multipliers before aggregation, and leave rounding to formatting. Reject a line item entirely when any nonzero bucket lacks its price. + +- [ ] **Step 4: Run cost model tests and confirm GREEN** + +Run: `swift test --filter CostModelsTests` + +Expected: PASS. + +- [ ] **Step 5: Write failing bundled catalog tests** + +Assert catalog metadata is nonempty, every source URL is HTTPS and first-party, aliases resolve to one canonical entry, and the exact supported local fixture IDs resolve. Do not assert every possible upstream model. + +- [ ] **Step 6: Run catalog tests and confirm RED** + +Run: `swift test --filter PriceCatalogTests` + +Expected: bundled resource is missing. + +- [ ] **Step 7: Add reviewed JSON catalog and loader** + +Configure `TokenLinkProviders` resources in `Package.swift`. Catalog version `2026-08-30.1` has effective date `2026-08-30` and these exact USD-per-million entries: + +| Provider/model | Aliases | Input | Cache read | 5m write | 1h write | Output | Request modifier | +| --- | --- | ---: | ---: | ---: | ---: | ---: | --- | +| Codex `gpt-5.4` | `gpt-5.4-2026-03-05` | 2.50 | 0.25 | — | — | 15.00 | over 272,000 input: 2x input/cache and 1.5x output | +| Codex `gpt-5.5` | `gpt-5.5-2026-04-23` | 5.00 | 0.50 | — | — | 30.00 | over 272,000 input: 2x input/cache and 1.5x output | +| Claude `claude-sonnet-5` | none | 2.00 | 0.20 | 2.50 | 4.00 | 10.00 | none | +| Claude `claude-sonnet-4-6` | none | 3.00 | 0.30 | 3.75 | 6.00 | 15.00 | none | +| Claude `claude-opus-5` | none | 5.00 | 0.50 | 6.25 | 10.00 | 25.00 | none | +| Claude `claude-haiku-4-5-20251001` | `claude-haiku-4-5` | 1.00 | 0.10 | 1.25 | 2.00 | 5.00 | none | +| Kimi `kimi-k3` | `k3`, `kimi-code/k3` | 3.00 | 0.30 | 3.00 | — | 15.00 | none | + +Each entry carries its exact official source URL: OpenAI model pages for GPT-5.4/5.5, Anthropic pricing/model pages for Claude, and `https://platform.kimi.ai/docs/pricing/chat-k3` for Kimi. Aliases are exact matches and never prefix-matched. A later upstream model remains visibly unpriced until a reviewed catalog change lands. + +- [ ] **Step 8: Run core/provider catalog tests** + +Run: `swift test --filter 'CostModelsTests|PriceCatalogTests'` + +Expected: PASS. + +- [ ] **Step 9: Commit Task 2** + +```bash +git add Package.swift Sources/TokenLinkCore/CostModels.swift Sources/TokenLinkProviders/Costs/PriceCatalog.swift Sources/TokenLinkProviders/Resources/api-equivalent-prices.json Tests/TokenLinkCoreTests/CostModelsTests.swift Tests/TokenLinkProviderTests/PriceCatalogTests.swift +git commit -m "feat: add decimal API-equivalent price catalog" +``` + +### Task 3: OpenRouter and DeepSeek Authoritative Cost Providers + +**Files:** +- Create: `Sources/TokenLinkProviders/Costs/AuthoritativeCostProvider.swift` +- Create: `Sources/TokenLinkProviders/Costs/OpenRouterCostProvider.swift` +- Create: `Sources/TokenLinkProviders/Costs/DeepSeekCostProvider.swift` +- Create: `Tests/TokenLinkProviderTests/AuthoritativeCostProviderTests.swift` +- Create: `Tests/TokenLinkProviderTests/Fixtures/openrouter-credits.json` +- Create: `Tests/TokenLinkProviderTests/Fixtures/openrouter-key.json` +- Create: `Tests/TokenLinkProviderTests/Fixtures/deepseek-balance.json` + +**Interfaces:** +- Produces: `AuthoritativeCostProvider.fetch() async -> Result`. +- Produces: `AccountCostProvider(accountID:provider:)` for state-keyed coordination. +- Consumes: `HTTPClient`, `CredentialReader`, `EndpointPolicy`, `CurrencyAmount`, and `AuthoritativeCostSnapshot`. + +- [ ] **Step 1: Write failing OpenRouter success and partial-success tests** + +Use a URL-keyed actor fake returning complete official response shapes. Assert bearer auth and these literal results: + +```swift +#expect(snapshot.balances == [ + AccountBalance( + currency: "USD", available: Decimal(string: "74.5")!, + purchased: Decimal(string: "100")!, used: Decimal(string: "25.5")!) +]) +#expect(snapshot.periodSpend.first { $0.period == .weekly }?.amount.value == Decimal(string: "7.25")) +``` + +Make `/credits` 403 while `/key` succeeds and assert a usable snapshot plus `.partialSource("credits")`; reverse the failure and assert credits balance remains usable. + +- [ ] **Step 2: Run OpenRouter tests and confirm RED** + +Run: `swift test --filter OpenRouter` + +Expected: compilation fails because the adapter is absent. + +- [ ] **Step 3: Implement strict numeric decoding and independent endpoint merge** + +Create a reusable `LosslessDecimal` decoder accepting JSON number or numeric string, rejecting booleans, empty strings, NaN, and infinity. Request exactly `https://openrouter.ai/api/v1/credits` and `/key` under an `EndpointPolicy(allowedHosts: ["openrouter.ai"])`. Return authentication only when no endpoint yielded usable data and authentication was the terminal cause. + +- [ ] **Step 4: Run OpenRouter tests and confirm GREEN** + +Run: `swift test --filter OpenRouter` + +Expected: PASS. + +- [ ] **Step 5: Write failing DeepSeek tests** + +Cover CNY/USD balances, literal zero, `is_available: false`, string/number fields, malformed numeric data, 401, and policy validation. Assert currencies are separate and no spend is inferred. + +- [ ] **Step 6: Run DeepSeek tests and confirm RED** + +Run: `swift test --filter DeepSeek` + +Expected: compilation fails because the adapter is absent. + +- [ ] **Step 7: Implement DeepSeek adapter** + +Request exactly `https://api.deepseek.com/user/balance`, retain `is_available`, map each `balance_infos` object independently, and report zero as valid. Use `EndpointPolicy(allowedHosts: ["api.deepseek.com"])` and the existing 20-second/redirect behavior through `HTTPClient`. + +- [ ] **Step 8: Run authoritative provider suite** + +Run: `swift test --filter AuthoritativeCostProviderTests` + +Expected: PASS. + +- [ ] **Step 9: Commit Task 3** + +```bash +git add Sources/TokenLinkProviders/Costs Tests/TokenLinkProviderTests/AuthoritativeCostProviderTests.swift Tests/TokenLinkProviderTests/Fixtures/openrouter-credits.json Tests/TokenLinkProviderTests/Fixtures/openrouter-key.json Tests/TokenLinkProviderTests/Fixtures/deepseek-balance.json +git commit -m "feat: fetch authoritative provider balances" +``` + +### Task 4: Independent Cost Store, TTL, and Refresh Coalescing + +**Files:** +- Create: `Sources/TokenLinkCore/CostStore.swift` +- Create: `Sources/TokenLinkApp/Costs/CostDashboardModel.swift` +- Create: `Tests/TokenLinkCoreTests/CostStoreTests.swift` +- Create: `Tests/TokenLinkAppTests/CostDashboardModelTests.swift` + +**Interfaces:** +- Produces: `AuthoritativeCostState`, `EstimatedCostState`, and `CostStore` actor methods keyed by account UUID/provider. +- Produces: `CostDashboardModel.refreshCosts(force:)`, `loadIfNeeded()`, `disable()`, and immutable row projections. +- Consumes: closures `authoritativeLoader`, `estimateLoader`, and `now` so tests use real coordinator behavior without external I/O. + +- [ ] **Step 1: Write failing last-known-good store tests** + +Assert success replaces data, failure with no snapshot maps missing credentials/error, failure after success retains the exact snapshot and marks stale, and 15/30-minute aging is source-specific. + +- [ ] **Step 2: Run store tests and confirm RED** + +Run: `swift test --filter CostStoreTests` + +Expected: compilation fails because store/state types are absent. + +- [ ] **Step 3: Implement the actor store** + +Use separate dictionaries `[UUID: AuthoritativeCostState]` and `[ProviderID: EstimatedCostState]`. Never serialize them. `accept` methods retain old snapshots on failure and never affect `ProviderStore`. + +- [ ] **Step 4: Run store tests and confirm GREEN** + +Run: `swift test --filter CostStoreTests` + +Expected: PASS. + +- [ ] **Step 5: Write failing dashboard lifecycle tests** + +With counting async closures, assert disabled means zero calls; first page load calls each source once; a second load before TTL does not call; `force: true` calls again; concurrent refreshes coalesce; one failing source does not prevent another success; `disable()` cancels/clears presentation state. + +- [ ] **Step 6: Run dashboard tests and confirm RED** + +Run: `swift test --filter CostDashboardModelTests` + +Expected: compilation fails because the dashboard model is absent. + +- [ ] **Step 7: Implement dashboard coordination** + +Make it `@MainActor @Observable`, retain one `Task?` for the aggregate refresh, perform source loads in a task group, consult snapshot timestamps for TTL, and call `CostStore` independently per result. Expose cost phase metadata without amounts for diagnostics. + +- [ ] **Step 8: Run dashboard tests and confirm GREEN** + +Run: `swift test --filter CostDashboardModelTests` + +Expected: PASS. + +- [ ] **Step 9: Commit Task 4** + +```bash +git add Sources/TokenLinkCore/CostStore.swift Sources/TokenLinkApp/Costs/CostDashboardModel.swift Tests/TokenLinkCoreTests/CostStoreTests.swift Tests/TokenLinkAppTests/CostDashboardModelTests.swift +git commit -m "feat: coordinate cost refresh independently" +``` + +### Task 5: Bounded Streaming JSONL Reader + +**Files:** +- Create: `Sources/TokenLinkApp/LocalUsage/JSONLStreamingReader.swift` +- Modify: `Sources/TokenLinkApp/LocalUsage/LocalUsageObserver.swift` +- Create: `Tests/TokenLinkAppTests/JSONLStreamingReaderTests.swift` + +**Interfaces:** +- Produces: `JSONLStreamingReader.read(url:onRecord:) throws -> JSONLReadReport`. +- Produces: constants `chunkBytes = 65_536`, `maximumRecordBytes = 1_048_576`, and observer `maximumFileBytes = 52_428_800`. +- Consumes: a synchronous record closure; does not retain raw lines after delivery. + +- [ ] **Step 1: Write failing boundary tests** + +Create temporary files and assert: records split at every chunk boundary arrive intact; final record without newline arrives; exactly 1 MiB is delivered; 1 MiB plus one byte is skipped and counted; cancellation throws; and exactly/over 50 MiB observer files are processed/skipped. Expected values are literal record byte counts. + +- [ ] **Step 2: Run reader tests and confirm RED** + +Run: `swift test --filter JSONLStreamingReaderTests` + +Expected: compilation fails because the reader is absent. + +- [ ] **Step 3: Implement chunked FileHandle reading** + +Read at most 65,536 bytes per call, maintain only the incomplete current record, discard an oversized record until its newline, check `Task.checkCancellation()` between chunks and records, and close the handle with `defer`. Do not use `Data(contentsOf:)` in production local scanning. + +- [ ] **Step 4: Run reader tests and confirm GREEN** + +Run: `swift test --filter JSONLStreamingReaderTests` + +Expected: PASS. + +- [ ] **Step 5: Refactor observer to sequential path-sorted streaming** + +Sort enumerated `.jsonl` URLs by standardized path, check mtime/size before opening, stream each file fully before the next, sanitize warnings to counts, and retain no event arrays or paths. + +- [ ] **Step 6: Run existing local usage tests** + +Run: `swift test --filter LocalUsageTests` + +Expected: PASS with the observer now using the streaming reader. + +- [ ] **Step 7: Commit Task 5** + +```bash +git add Sources/TokenLinkApp/LocalUsage/JSONLStreamingReader.swift Sources/TokenLinkApp/LocalUsage/LocalUsageObserver.swift Tests/TokenLinkAppTests/JSONLStreamingReaderTests.swift Tests/TokenLinkAppTests/LocalUsageTests.swift +git commit -m "perf: stream local usage records with hard bounds" +``` + +### Task 6: Model-Aware Codex, Claude, and Kimi Cost Estimation + +**Files:** +- Modify: `Sources/TokenLinkApp/LocalUsage/LocalUsageModels.swift` +- Modify: `Sources/TokenLinkApp/LocalUsage/LocalUsageReaders.swift` +- Modify: `Sources/TokenLinkApp/LocalUsage/LocalUsageObserver.swift` +- Create: `Sources/TokenLinkApp/Costs/LocalCostEstimator.swift` +- Modify: `Tests/TokenLinkAppTests/LocalUsageTests.swift` +- Create: `Tests/TokenLinkAppTests/LocalCostEstimatorTests.swift` + +**Interfaces:** +- Produces: stateful `LocalUsageRecordParser.consume(_:) -> NormalizedModelUsage?` and `finish()` behavior per file. +- Produces: `LocalCostEstimator.estimate(provider:since:through:) -> EstimatedCostSnapshot`. +- Consumes: `JSONLStreamingReader` and `PriceCatalog`. + +- [ ] **Step 1: Write failing parser behavior tests** + +Use complete stripped JSONL fixtures and assert literal buckets/model IDs for: + +- Codex turn-context model changes, cached input subtraction, repeated cumulative totals, and a child rollout marker that must not charge replayed parent totals. +- Claude input/cache-read/cache-creation/output mapping and duplicate message IDs. +- Kimi top-level model plus `inputOther`, `inputCacheRead`, `inputCacheCreation`, and output mapping. + +Before each test body, name the mutation it catches in a code comment, such as `// Catches charging cached input twice.` + +- [ ] **Step 2: Run parser tests and confirm RED** + +Run: `swift test --filter 'CodexCost|ClaudeCost|KimiCost'` + +Expected: assertions fail because current events do not preserve model or four buckets. + +- [ ] **Step 3: Implement stateful record parsers** + +Decode only timestamp, type, model, token counters, message/event IDs, and ancestry markers. Codex computes deltas from cumulative counters and resets state safely when counters decrease; cached input is subtracted from total input. Claude treats cache buckets separately and deduplicates nonempty message IDs. Kimi maps all four source fields directly. + +- [ ] **Step 4: Run parser tests and confirm GREEN** + +Run: `swift test --filter 'CodexCost|ClaudeCost|KimiCost'` + +Expected: PASS. + +- [ ] **Step 5: Write failing estimator tests** + +Inject a literal test catalog and temporary records. Assert a seven-day interval, per-model line items, Decimal per-currency totals, unknown-model exclusion, five-minute cache-write fallback warning, catalog metadata, and no prompt/response retention. + +- [ ] **Step 6: Run estimator tests and confirm RED** + +Run: `swift test --filter LocalCostEstimatorTests` + +Expected: compilation fails because the estimator is absent. + +- [ ] **Step 7: Implement streaming aggregation and pricing** + +Price each normalized event as it arrives so request-level long-context modifiers remain correct, then aggregate priced line items by provider/model/currency. Keep only a nonempty dedupe-ID set and accumulator dictionaries. Create `EstimatedCostSnapshot` with exact window/catalog timestamps. + +- [ ] **Step 8: Run local suites and confirm GREEN** + +Run: `swift test --filter 'LocalUsageTests|LocalCostEstimatorTests|JSONLStreamingReaderTests'` + +Expected: PASS. + +- [ ] **Step 9: Commit Task 6** + +```bash +git add Sources/TokenLinkApp/LocalUsage Sources/TokenLinkApp/Costs/LocalCostEstimator.swift Tests/TokenLinkAppTests/LocalUsageTests.swift Tests/TokenLinkAppTests/LocalCostEstimatorTests.swift +git commit -m "feat: estimate local API-equivalent model costs" +``` + +### Task 7: App Composition, Cost Accounts, and Quota Isolation + +**Files:** +- Modify: `Sources/TokenLinkApp/AppModel.swift` +- Modify: `Sources/TokenLinkApp/KeychainVault.swift` +- Modify: `Sources/TokenLinkApp/Views/ProvidersView.swift` +- Modify: `Sources/TokenLinkApp/Views/WatchFaceSettingsView.swift` +- Modify: `Tests/TokenLinkAppTests/AppModelTests.swift` +- Modify: `Tests/TokenLinkAppTests/KeychainVaultTests.swift` + +**Interfaces:** +- Produces: `AppModel.costDashboard`, `setBetaCostsEnabled(_:)`, `refreshCosts(force:)`, and fixed metric setter. +- Produces: live construction of `OpenRouterCostProvider`, `DeepSeekCostProvider`, and three `LocalCostEstimator` sources. +- Consumes: cost account credentials using existing provider-plus-UUID Keychain names. + +- [ ] **Step 1: Write failing quota-isolation tests** + +Create configuration containing enabled OpenRouter/DeepSeek accounts. Assert `orderedProviderRows`, `accountGroups` used by quota overview, watch-enabled providers, watch payload candidates, and quota refresher inputs exclude them. Assert setting beta false results in zero cost loader calls even through `start()` and manual quota refresh. + +- [ ] **Step 2: Run isolation tests and confirm RED** + +Run: `swift test --filter 'costOnlyProvidersNeverEnterQuota|disabledCostsStartNoWork'` + +Expected: failures because enumeration still relies on all provider IDs. + +- [ ] **Step 3: Implement capability filtering and cost composition** + +Filter all quota/watch code using `.quota`; keep cost accounts in configuration and credential state; build authoritative cost bindings only for enabled cost accounts; build local estimators by local-cost capability. Do not invoke cost refresh from `start()`, scheduler, wake, network restoration, quota manual refresh, or watch commands. + +- [ ] **Step 4: Run isolation tests and confirm GREEN** + +Run: `swift test --filter 'costOnlyProvidersNeverEnterQuota|disabledCostsStartNoWork'` + +Expected: PASS. + +- [ ] **Step 5: Write failing enablement/account/Keychain tests** + +Assert enabling beta selects `.localEstimate(.codex)` when selection is `.none`; disabling clears in-memory cost presentation and leaves credentials intact; OpenRouter/DeepSeek account keys use default and UUID-namespaced Keychain accounts; provider removal deletes/promotes keys with existing semantics. + +- [ ] **Step 6: Run focused tests and confirm RED** + +Run: `swift test --filter 'betaCosts|costAccount|Keychain'` + +Expected: missing setters/live construction or wrong filtering. + +- [ ] **Step 7: Implement AppModel actions and provider controls** + +Add beta setters, explicit `refreshCosts(force:)`, route-load method, metric setter, and cost account creation. In Providers, show quota providers as before and a separate beta-cost section for OpenRouter/DeepSeek with explicit Management/API Key help text. + +- [ ] **Step 8: Run app/keychain tests** + +Run: `swift test --filter 'AppModelTests|KeychainVaultTests|ConfigurationStoreTests'` + +Expected: PASS. + +- [ ] **Step 9: Commit Task 7** + +```bash +git add Sources/TokenLinkApp/AppModel.swift Sources/TokenLinkApp/KeychainVault.swift Sources/TokenLinkApp/Views/ProvidersView.swift Sources/TokenLinkApp/Views/WatchFaceSettingsView.swift Tests/TokenLinkAppTests/AppModelTests.swift Tests/TokenLinkAppTests/KeychainVaultTests.swift +git commit -m "feat: compose cost sources without quota coupling" +``` + +### Task 8: Costs Beta UI, Menu-Bar Supplement, Localization, and Diagnostics + +**Files:** +- Create: `Sources/TokenLinkApp/Views/CostsView.swift` +- Modify: `Sources/TokenLinkApp/Views/ControlCenterView.swift` +- Modify: `Sources/TokenLinkApp/Views/MenuBarView.swift` +- Modify: `Sources/TokenLinkApp/Views/SettingsView.swift` +- Modify: `Sources/TokenLinkApp/Strings.swift` +- Modify: `Sources/TokenLinkApp/AppModel.swift` +- Modify: `Sources/TokenLinkApp/DiagnosticExporter.swift` +- Modify: `Tests/TokenLinkAppTests/AppModelTests.swift` +- Modify: `Tests/TokenLinkAppTests/LocalizationTests.swift` + +**Interfaces:** +- Produces: Costs route/load behavior, dedicated refresh button, source cards, menu supplement formatter, and fixed metric picker. +- Consumes: cost dashboard row projections; UI never recomputes money. + +- [ ] **Step 1: Write failing menu-label and diagnostics tests** + +Assert literal labels: + +```swift +#expect(model.menuBarLabel == "Codex 42% · ≈$8.31/7d") +#expect(authoritativeModel.menuBarLabel == "Codex 42% · OR $18.40 left") +#expect(missingSelectionModel.menuBarLabel == "Codex 42%") +``` + +Assert the quota provider still determines the first segment and severity input. Serialize diagnostics and assert it contains beta state, source kind, phase, catalog version, error category, and refresh time while excluding literal fixture amounts, account labels/UUIDs, model totals, and paths. + +- [ ] **Step 2: Run focused label/diagnostic tests and confirm RED** + +Run: `swift test --filter 'menuBarCost|costDiagnostics'` + +Expected: label lacks supplement and diagnostics lack sanitized metadata. + +- [ ] **Step 3: Implement formatting and redacted diagnostics** + +Use `NumberFormatter`/currency code for display only. Estimated supplement always begins with `≈`; authoritative supplement never does. A missing selected row returns nil supplement. Diagnostics receive only `CostDiagnosticMetadata`, never snapshots. + +- [ ] **Step 4: Run label/diagnostic tests and confirm GREEN** + +Run: `swift test --filter 'menuBarCost|costDiagnostics'` + +Expected: PASS. + +- [ ] **Step 5: Add Costs route, cards, settings, and three-language keys** + +Insert `.costs` between Providers and StopWatch. `CostsView.task` calls load only when enabled; its toolbar calls `refreshCosts(force: true)`. Show separate authoritative/estimated sections and the exact `Estimated/API-equivalent` label, period, effective date, freshness, provenance, warning, stale, and failure details. Settings controls beta and a fixed metric; no rotating selection. + +- [ ] **Step 6: Run build and localization tests** + +Run: `swift build && swift test --filter LocalizationTests` + +Expected: PASS with exhaustive three-language coverage. + +- [ ] **Step 7: Inspect SwiftUI previews/build warnings and refactor** + +Run: `swift build 2>&1 | tee /tmp/tokenlink-cost-build.log` + +Expected: exit 0 and no new warning lines referring to cost files. + +- [ ] **Step 8: Commit Task 8** + +```bash +git add Sources/TokenLinkApp/Views/CostsView.swift Sources/TokenLinkApp/Views/ControlCenterView.swift Sources/TokenLinkApp/Views/MenuBarView.swift Sources/TokenLinkApp/Views/SettingsView.swift Sources/TokenLinkApp/Strings.swift Sources/TokenLinkApp/AppModel.swift Sources/TokenLinkApp/DiagnosticExporter.swift Tests/TokenLinkAppTests/AppModelTests.swift Tests/TokenLinkAppTests/LocalizationTests.swift +git commit -m "feat: present cost beta with explicit provenance" +``` + +### Task 9: Resource Budgets, Privacy Gate, and Full Verification + +**Files:** +- Create: `scripts/resource_check.sh` +- Modify: `.github/workflows/ci.yml` +- Modify: `scripts/privacy_scan.sh` +- Create: `Tests/TokenLinkAppTests/LocalCostResourceTests.swift` +- Modify: `README.md` + +**Interfaces:** +- Produces: reproducible local/CI resource gate and public beta documentation. +- Consumes: already-built test bundle and release executable; measurement excludes compiler processes. + +- [ ] **Step 1: Write failing resource workload test** + +Generate a deterministic 64 MiB JSONL file from repeated stripped usage records, stream it through the real reader/aggregator, and assert literal event/token totals so an empty scan cannot satisfy the gate. Skip only when `TOKENLINK_RESOURCE_WORKLOAD` is not `1`. + +- [ ] **Step 2: Run workload test and confirm RED** + +Run: `TOKENLINK_RESOURCE_WORKLOAD=1 swift test --filter LocalCostResourceTests` + +Expected: fails until the workload helper and expected aggregation are complete. + +- [ ] **Step 3: Complete workload and confirm functional GREEN** + +Run: `TOKENLINK_RESOURCE_WORKLOAD=1 swift test --filter LocalCostResourceTests` + +Expected: PASS with expected aggregate counts. + +- [ ] **Step 4: Implement executable resource gate** + +`scripts/resource_check.sh` must: + +1. run `swift build -c release` and `swift test --build-tests` before measurement; +2. run the filtered workload under `/usr/bin/time -l` with `--skip-build`; +3. parse maximum RSS bytes and fail above `167772160`; +4. measure elapsed seconds and fail above `30`; +5. measure `.build/release/tokenlink` bytes and fail above `15728640`; +6. print only sizes/times/status, never paths or monetary data. + +- [ ] **Step 5: Run resource gate and confirm thresholds** + +Run: `bash scripts/resource_check.sh` + +Expected: PASS under all three limits. + +- [ ] **Step 6: Extend privacy scan and CI workflow** + +Make privacy scan reject production logging/interpolation of `balance`, `amount.value`, authorization headers, raw bodies, and transcript paths while allowing domain declarations. Add a macOS CI job that runs `bash scripts/resource_check.sh` after the regular test job. + +- [ ] **Step 7: Document the beta accurately** + +README must distinguish authoritative balance from local `Estimated/API-equivalent`, state that subscription value is not estimated, explain OpenRouter Management Key partial behavior, DeepSeek multi-currency behavior, local directories, no telemetry/persistence, and the resource limits. + +- [ ] **Step 8: Run fresh full verification** + +Run exactly: + +```bash +swift build +bash scripts/test.sh +swift format lint --strict Package.swift +swift format lint --recursive --strict Sources Tests +bash scripts/privacy_scan.sh +bash scripts/resource_check.sh +git diff --check +``` + +Expected: every command exits 0; test count is greater than the 162-test baseline; privacy/resource gates print PASS. + +- [ ] **Step 9: Review the mutation checklist** + +Confirm tests fail for a wrong cost rate, wrong currency grouping, omitted estimate marker, treating a cost-only provider as quota, following a redirect, accepting malformed decimals, loading while disabled, ignoring TTL, retaining a whole file, accepting a 1 MiB-plus-one record, and exporting a fixture amount. + +- [ ] **Step 10: Commit Task 9** + +```bash +git add scripts/resource_check.sh scripts/privacy_scan.sh .github/workflows/ci.yml Tests/TokenLinkAppTests/LocalCostResourceTests.swift README.md +git commit -m "ci: enforce cost privacy and resource budgets" +``` + +- [ ] **Step 11: Request code review before integration** + +Use `superpowers:requesting-code-review` against the complete branch diff from `6342486` through HEAD. Resolve findings with one RED/GREEN cycle per bug, then rerun the complete verification block before claiming completion. diff --git a/docs/superpowers/specs/2026-08-30-provider-costs-design.md b/docs/superpowers/specs/2026-08-30-provider-costs-design.md new file mode 100644 index 0000000..8f45923 --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-provider-costs-design.md @@ -0,0 +1,438 @@ +# Provider Costs Beta Design + +Status: approved for implementation + +Date: 2026-08-30 + +Base branch: `codex/watch-face-v2` at `6342486` + +Implementation branch: `feat/provider-costs` + +## 1. Purpose and priority + +TokenLink's primary job remains quota visibility and StopWatch synchronization. +This project adds a separate beta cost domain for two different kinds of data: + +1. authoritative financial data returned by a provider API; and +2. API-equivalent cost estimates derived from local CLI usage records. + +Quota refresh, menu-bar quota severity, notifications, BLE payloads, and watch +protocols must continue to work when the cost beta is disabled, unavailable, +refreshing, or failing. Cost work must never block quota refresh or device sync. + +## 2. Scope + +### 2.1 Initial authoritative sources + +- OpenRouter account credits and key-period spend: + - `GET https://openrouter.ai/api/v1/credits` + - `GET https://openrouter.ai/api/v1/key` +- DeepSeek account balances: + - `GET https://api.deepseek.com/user/balance` + +OpenRouter reports lifetime credits and usage through `/credits`; TokenLink +derives remaining balance as `max(total_credits - total_usage, 0)`. `/key` +supplies any available daily, weekly, monthly, or key-limit spend. The two +requests are independent: usable data from one remains visible if the other is +unavailable or forbidden. + +DeepSeek balances remain in the currencies returned by the service. TokenLink +does not infer spend from changes in balance because top-ups, grants, refunds, +and expiry make that inference unreliable. + +### 2.2 Initial local estimates + +- Codex rollout JSONL below `.codex/sessions` +- Claude transcript JSONL below `.claude/projects` +- Kimi wire JSONL below `.kimi-code/sessions` + +The estimate window is the most recent seven days. Every estimate is labelled +`Estimated/API-equivalent` in the UI. It is not a claim about the value or +billing of a Coding Plan subscription. + +### 2.3 Deferred sources + +The following are outside this implementation because they require organization +or administrator credentials and different authorization semantics: + +- OpenAI organization usage and cost APIs +- Anthropic organization usage and cost APIs +- Cursor Team Admin API +- GitHub Copilot organization or enterprise usage APIs + +MiniMax and GLM local estimates are also deferred because the approved local +sources do not provide sufficiently reliable model-level usage records. + +## 3. Non-goals + +- No currency conversion or exchange-rate download. +- No inferred monetary value for quota percentages. +- No financial data in BLE payloads or watch commands. +- No cost-driven quota alert level, menu-bar color, or notification policy. +- No analytics, telemetry, or remote TokenLink service. +- No browser-cookie access, refresh-token handling, or credential-file writes. +- No persistence of balances or estimated monetary totals in this beta. +- No remote price-catalog update in the background. + +## 4. Architecture + +### 4.1 Parallel domains + +Quota and cost share a stable provider identity but use separate domain models, +provider protocols, state stores, refresh entry points, and UI presentation. + +`ProviderID` gains `openrouter` and `deepseek`. `ProviderRegistry` gains explicit +capabilities for each provider: + +- `quota` +- `authoritativeCost` +- `localCostEstimate` + +The existing quota registry and `QuotaProvider` protocol remain unchanged. +OpenRouter and DeepSeek do not receive a fake `ProviderSpec` or a synthetic +`QuotaSnapshot`; they implement the new `CostProvider` protocol. Codex, Claude, +and Kimi keep their quota adapters, while their local transcript readers feed +the estimate pipeline. + +Provider enumeration must use capabilities rather than assuming every +`ProviderID` has quota data. In particular: + +- default accounts remain the existing quota providers; +- OpenRouter and DeepSeek accounts are opt-in; +- quota refresh filters to `quota` capability; +- watch settings filter to `quota` capability; +- cost-only providers never appear as disabled quota rows. + +### 4.2 Cost model + +All monetary arithmetic uses Foundation `Decimal`; `Double` is forbidden for +stored prices, balances, cost multiplication, and totals. + +The core cost domain consists of the following concepts: + +- `CurrencyAmount`: a decimal amount and uppercase ISO 4217 currency code. +- `AccountBalance`: available amount plus optional purchased and used amounts. +- `AuthoritativeCostSnapshot`: provider/account identity, balances, optional + provider-reported period spend, and fetch time. +- `NormalizedModelUsage`: provider, model ID, timestamp, uncached input, cache + read, cache write, output tokens, and a deduplication key. +- `ModelCostLineItem`: normalized usage, resolved catalog entry, calculated + amount, and estimate warnings. +- `EstimatedCostSnapshot`: provider, seven-day period, per-model line items, + per-currency totals, unknown models, catalog version, catalog effective date, + and scan time. + +Authoritative and estimated snapshots are different concrete types. A single +optional-heavy snapshot type must not make it possible to present an estimate +as authoritative data. + +### 4.3 State and coordination + +`CostStore` owns last-known-good cost states independently from `ProviderStore`. +An authoritative state is keyed by provider account UUID; a local estimate is +keyed by provider ID because local logs cannot be reliably attributed to a +TokenLink-configured account. + +`CostDashboardModel` is the main-actor presentation coordinator. It exposes +authoritative rows, estimate rows, refresh state, errors, the selected menu-bar +supplement, and a separate `refreshCosts(force:)` operation. `AppModel` composes +it but does not absorb its provider or scanner implementation. + +## 5. Configuration and credentials + +`AppConfiguration` gains: + +- `betaCostsEnabled`, default `false`; +- `menuBarCostMetric`, defaulting to the Codex seven-day estimate when cost beta + is first enabled; +- opt-in OpenRouter and DeepSeek provider accounts in the existing account list. + +Decoding an older configuration supplies these defaults without rewriting the +configuration until the user changes a setting. + +OpenRouter and DeepSeek keys use the existing Keychain service +`io.github.phantom5125.tokenlink.provider` and provider-plus-account UUID naming. +OpenRouter accepts an explicit user-supplied Management Key or API key. The UI +explains that `/credits` requires Management Key permission; a key that can +access only `/key` yields partial data rather than making the provider wholly +unavailable. DeepSeek accepts an explicit API key. Neither provider reads +browser state or an unrelated application's credential store. + +When cost beta is disabled: + +- no cost HTTP request is started; +- no local cost scan is started; +- no cost task or timer remains active; +- the menu bar is quota-only. + +## 6. Authoritative provider behavior + +Both adapters reuse `HTTPClient`, `EndpointPolicy`, redirect rejection, the +20-second request bound, Keychain credential resolution, and official-host +allowlisting. + +### 6.1 OpenRouter + +Allowed host: `openrouter.ai`. + +`/credits` and `/key` run independently. Each successful response is parsed +through typed `Decodable` structures with numeric fields accepting JSON numbers +or numeric strings, rejecting booleans and non-finite values. + +- `total_credits` is the purchased/lifetime ceiling. +- `total_usage` is authoritative lifetime usage. +- remaining is `max(total_credits - total_usage, 0)`. +- `/key` period usage and limit fields are presented only when supplied. + +A partial response produces usable data plus a warning. Authentication failure +is reported only when no usable endpoint data remains and all attempted +endpoints rejected the credential. Raw response bodies and headers are never +logged. + +### 6.2 DeepSeek + +Allowed host: `api.deepseek.com`. + +Each returned balance becomes a separate `AccountBalance` using the service's +currency. `is_available` is retained as provider status. A zero balance is valid +authoritative data, not missing data. + +## 7. Local streaming and estimation + +### 7.1 Reader limits + +Production local scans must not call `Data(contentsOf:)` for transcript files. +`JSONLStreamingReader` reads 64 KiB chunks, keeps records intact across chunk +boundaries, and checks cancellation between chunks and records. + +- maximum source file size: 50 MiB; +- maximum JSONL record size: 1 MiB; +- files are processed sequentially in path order; +- records exceeding the limit are skipped and counted in a sanitized warning; +- files older than the seven-day window are skipped by modification time; +- unreadable files are skipped without exposing their paths in UI or logs. + +The scanner aggregates as records arrive. It does not retain raw lines or a +whole-file event array. A deduplication set may retain non-empty event IDs for +the scan window; no prompt or response text enters that set. + +### 7.2 Normalized token categories + +Provider parsers translate their source semantics into four independent +billable buckets: + +- uncached input; +- cache read; +- cache write; +- output. + +For Codex, cached input is a subset of input, so uncached input is +`max(input - cached, 0)`. Parser state tracks the current model and cumulative +totals per file, ignores repeated cumulative snapshots, and does not charge +replayed parent history in child rollouts. + +For Claude, ordinary input, cache-read input, cache-creation input, and output +are separate source fields. Message ID is the deduplication key. If cache-write +duration is unavailable, the catalog's five-minute cache-write rate is used and +the line item carries a warning. + +For Kimi, `inputOther`, `inputCacheRead`, `inputCacheCreation`, and `output` map +directly, and the top-level model field supplies the model ID. + +### 7.3 Price calculation + +The bundled catalog records, per model and currency, prices per million tokens +for uncached input, cache read, five-minute cache write, optional one-hour cache +write, and output. It may also record an official per-request long-context +threshold and its input/output multipliers. Pricing is applied before events +are aggregated so a threshold that applies to one request is not accidentally +applied to a seven-day model total. + +For every category: + +`category cost = Decimal(token count) * price per million / 1_000_000` + +Calculations retain Decimal precision. Rounding happens only during localized +currency formatting. Totals group by currency; currencies are never implicitly +combined. + +If a model is unknown, or a used category lacks a price, that model contributes +no money to totals and appears under `Unknown/unpriced models`. This prevents a +partial price from silently undercounting cost. + +## 8. Price catalog + +The catalog is a read-only application resource with: + +- a schema version; +- a catalog version; +- an effective date; +- exact model IDs and explicit aliases; +- category prices and currency; +- a first-party pricing source URL for every entry; +- optional first-party long-context thresholds and multipliers. + +Initial sources are the official OpenAI, Anthropic, and Moonshot pricing pages. +The implementation includes only model IDs verified against supported local +record schemas and official pricing. Adding or changing a price requires a +fixture/test update and review of its source/effective date. + +The app does not fetch a live price list. This makes estimates reproducible and +prevents a background pricing service from expanding TokenLink's network or +privacy surface. + +## 9. Refresh and failure behavior + +The Costs page is always discoverable and displays an enablement explanation +when the beta is off. Enabling the beta does not immediately scan; the first +Costs-page visit triggers an initial load. + +- authoritative snapshot TTL: 15 minutes; +- local estimate TTL: 30 minutes; +- manual `Refresh costs` bypasses both TTLs; +- one in-flight operation is allowed per source; +- duplicate refresh requests coalesce; +- one failed source does not delay or erase another source; +- failure retains last-known-good data and marks it stale; +- no prior success produces a typed missing-credential, authentication, + network, decoding, timeout, or local-read state. + +Cost refresh is separate from the existing quota toolbar refresh. Quota refresh +must not start a scan. Cost refresh must not start quota or watch synchronization. + +Snapshots and monetary values remain in memory for this beta. A later persistent +cache design must separately address financial-data privacy, account identity, +schema migration, and stale-on-launch behavior. + +## 10. User interface + +### 10.1 Costs route + +Control Center gains a `Costs β` route between Providers and StopWatch. It has: + +- `Authoritative balances` cards for configured OpenRouter and DeepSeek accounts; +- `Estimated API-equivalent cost` cards for Codex, Claude, and Kimi; +- source/provenance text, period, price-catalog effective date, last update, and + stale/error status on every card; +- a dedicated `Refresh costs` action. + +OpenRouter and DeepSeek credential/account controls live in Providers with a +cost-beta badge. They do not appear in quota overview rows or watch-provider +selection. A generic text/monogram mark is acceptable for the beta; third-party +logo assets are not required. + +All new user-facing strings cover English, Simplified Chinese, and Japanese. + +### 10.2 Menu-bar text + +Quota remains first. Cost changes text only; quota alone controls menu-bar icon, +color, severity, and ordering. + +When beta is enabled, Settings offers a fixed cost metric selection: + +- one provider's seven-day estimate; +- an OpenRouter account balance; +- one DeepSeek account/currency balance; +- none. + +The selection never rotates automatically. Examples: + +- `Codex 42% · ≈$8.31/7d` +- `Codex 42% · OR $18.40 left` +- `Codex 42% · DS ¥72 left` + +`≈` is mandatory for estimates. Authoritative balances use `left` and no +estimate marker. A missing or failed selection falls back to the existing +quota-only label. Stale cost text remains visible with a stale accessibility +description. Accessibility labels spell out provider, source, amount, period, +and freshness rather than relying on abbreviations. + +## 11. Privacy and diagnostics + +Local readers extract only timestamps, model IDs, token counters, and +deduplication IDs. They do not retain prompts, responses, tool content, paths, +account identifiers, or raw JSONL. + +Diagnostic export includes only beta enabled state, cost phase, source kind, +refresh time, catalog version, and error category. It excludes balances, +spend amounts, model totals, filenames, account labels, and Keychain hints. + +Application logs exclude keys, authorization headers, raw bodies, paths, and +monetary amounts. There is no analytics or telemetry. + +## 12. Resource budgets and CI + +The pre-feature baseline on the development Mac is approximately 58.3 MiB peak +RSS for the full test process and 3.9 MiB for the release executable. + +CI adds a macOS resource job that builds first, then measures the already-built +test/resource workload so compiler memory is not confused with application +memory. A deterministic 64 MiB synthetic JSONL source exercises the streaming +scanner. + +Initial gates: + +- streaming workload peak RSS: at most 160 MiB; +- 64 MiB scan wall time: at most 30 seconds; +- release executable size: at most 15 MiB. + +The resource workload verifies aggregate counts instead of merely measuring a +process that did no useful work. Thresholds may be tightened only after several +stable CI runs; a threshold increase requires a documented reason in the same +change. + +Structural tests also assert reader chunk size, maximum record size, file-size +boundary, cancellation, and no whole-file production read. + +## 13. Test and acceptance matrix + +Implementation follows test-driven development. Required coverage includes: + +- Decimal arithmetic for every token category, currency grouping, aliases, + and display rounding; +- unknown model and missing category prices excluded from totals; +- OpenRouter credits/key success, partial success, zero balance, auth failure, + malformed numeric values, host validation, and redirect rejection; +- DeepSeek multiple currencies, zero balance, unavailable status, auth failure, + malformed response, host validation, and redirect rejection; +- JSONL records split across chunks, a record exactly at and over 1 MiB, a file + exactly at and over 50 MiB, cancellation, unreadable files, and Claude dedupe; +- Codex model state, repeated cumulative totals, cached-input subtraction, and + child replay suppression; +- Claude and Kimi category mapping; +- beta-disabled guarantees of zero network and zero local scans; +- TTL, force refresh, coalescing, partial failure, and last-known-good state; +- configuration migration, Keychain account naming, and cost-only capability + filtering from quota/watch flows; +- Costs route states and localized strings; +- menu-bar estimate marker, authoritative label, fixed selection, stale + accessibility text, and quota-only fallback; +- privacy scan and diagnostic omission of monetary/account details; +- the CI resource workload and binary-size gates. + +Before completion, the branch must pass: + +```bash +swift build +bash scripts/test.sh +swift format lint --strict Package.swift +swift format lint --recursive --strict Sources Tests +bash scripts/privacy_scan.sh +bash scripts/resource_check.sh +git diff --check +``` + +## 14. Authoritative references + +- OpenRouter credits API: + +- OpenRouter usage accounting: + +- DeepSeek balance API: + +- OpenAI API pricing: + +- Anthropic pricing and prompt caching: + +- Moonshot platform documentation: + diff --git a/docs/validation/2026-08-30-provider-costs-beta.md b/docs/validation/2026-08-30-provider-costs-beta.md new file mode 100644 index 0000000..5b669b6 --- /dev/null +++ b/docs/validation/2026-08-30-provider-costs-beta.md @@ -0,0 +1,30 @@ +# Provider Costs Beta Merge Readiness — 2026-08-30 + +This report covers the opt-in provider-costs beta after rebasing its twelve +feature commits onto `origin/main` at `51b0aec`. Quota remains the primary +domain; authoritative balances and local API-equivalent estimates are reviewed +as separate beta capabilities. + +## Results + +| Layer | Evidence | Status | Limitations | +| --- | --- | --- | --- | +| Rebase integrity | `git range-diff` preserved all twelve Costs commits. The only intentional changes adopt the `main` Codex executable resolver and compose the resource job with the current packaging workflow. | PASS | Rewritten commit IDs require updating the remote feature branch with force-with-lease. | +| Build | `swift build` completed on the rebased tree. | PASS | Local build target is Apple Silicon macOS. | +| Automated tests | `bash scripts/test.sh` completed 227 tests covering quota isolation, authoritative adapters, local estimation, TTL, streaming bounds, cancellation, overflow, privacy, and v1/v2 behavior. | PASS | Provider HTTP tests use synthetic fixtures and injected transports; they do not spend from or expose a live account. | +| Main compatibility | The v0.2 provider-logo test exposed missing cost-only brand assets. Quota providers still require bundled logos; OpenRouter and DeepSeek now explicitly use the existing code-native symbol fallback until redistributable brand assets are reviewed. | PASS | The fallback is intentional, not an official provider logo. | +| Swift format | Strict lint passed for `Package.swift`, `Sources`, and `Tests`. | PASS | None. | +| Privacy scan | `bash scripts/privacy_scan.sh` passed. | PASS | Static scanning complements tests; it cannot recognize every future secret format. | +| Resource gate | The production 64 MiB workload completed in 9.48 seconds with 84,393,984 bytes maximum RSS; the release executable was 5,717,768 bytes. | PASS | Limits are 30 seconds, 160 MiB RSS, and 15 MiB executable size on this machine. | +| Documentation | Both READMEs already describe the beta. `CHANGELOG.md`, `SECURITY.md`, `CONTRIBUTING.md`, and `docs/RELEASING.md` now cover provenance, threat boundaries, cost-adapter proof, price-catalog review, and resource checks. | PASS | Price references still require review whenever the bundled catalog changes. | + +## Merge decision + +The rebased source is ready for normal review and merge as a beta. It preserves +quota behavior, keeps all cost features disabled until explicit opt-in, and +passes the local build, test, formatting, privacy, resource, and diff gates. + +Because rebasing rewrites the pull-request branch, hosted CI must run again on +the updated remote head. Merge only after those new checks pass. Live provider +account observations may improve beta confidence, but the feature neither +requires nor claims a production billing reconciliation. diff --git a/scripts/privacy_scan.sh b/scripts/privacy_scan.sh index 6517c5a..e5f3086 100755 --- a/scripts/privacy_scan.sh +++ b/scripts/privacy_scan.sh @@ -44,6 +44,17 @@ if matches="$(git grep -nIE '(sk-[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Z failed=1 fi +# Domain declarations and UI formatting are allowed. Sensitive values must +# never enter a production logging sink, even when interpolated at runtime. +logging_sink='(?:\b(?:print|debugPrint|NSLog|os_log|record)\s*\(|\b(?:logger|tokenLinkEventLogger)\.[A-Za-z]+\s*\()' +logging_sensitive='(?:\bbalances?\b|amount\.value|authorization|allHTTPHeaderFields|raw[_ ]?body|response[_ ]?body|transcript[\s\S]{0,120}?path|\.path\b)' +if matches="$(rg -n -U --pcre2 -i \ + "${logging_sink}[\\s\\S]{0,800}?${logging_sensitive}" Sources || true)"; [[ -n "$matches" ]]; then + echo "Privacy scan: sensitive cost, HTTP, or transcript data reached a logging sink:" >&2 + echo "$matches" >&2 + failed=1 +fi + if [[ $failed -ne 0 ]]; then exit 1 fi diff --git a/scripts/resource_check.sh b/scripts/resource_check.sh new file mode 100755 index 0000000..1562a89 --- /dev/null +++ b/scripts/resource_check.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_dir="$(cd "$(dirname "$0")/.." && pwd)" +cd "$repo_dir" + +maximum_rss_bytes=167772160 +maximum_elapsed_seconds=30 +maximum_executable_bytes=15728640 + +resource_tmp="$(mktemp -d)" +measurement_file="$resource_tmp/measurement.txt" +workload_output="$resource_tmp/workload.txt" + +cleanup() { + rm -f "$measurement_file" "$workload_output" + rmdir "$resource_tmp" 2>/dev/null || true +} +trap cleanup EXIT + +if ! swift build -c release >/dev/null 2>&1; then + echo "Resource check: release build failed." >&2 + exit 1 +fi +test_build=(swift build --build-tests) +developer_dir="$(xcode-select -p)" +if [[ "$developer_dir" == */CommandLineTools ]]; then + frameworks="$developer_dir/Library/Developer/Frameworks" + interop="$developer_dir/Library/Developer/usr/lib" + macros="$developer_dir/usr/lib/swift/host/plugins/testing/libTestingMacros.dylib" + if [[ -d "$frameworks/Testing.framework" && -f "$macros" ]]; then + test_build+=( + -Xswiftc -F -Xswiftc "$frameworks" + -Xswiftc -load-plugin-library -Xswiftc "$macros" + -Xlinker -F -Xlinker "$frameworks" + -Xlinker -rpath -Xlinker "$frameworks" + -Xlinker -rpath -Xlinker "$interop" + ) + fi +fi +if ! "${test_build[@]}" >/dev/null 2>&1; then + echo "Resource check: test build failed." >&2 + exit 1 +fi + +set +e +/usr/bin/time -l env TOKENLINK_RESOURCE_WORKLOAD=1 \ + bash scripts/test.sh --skip-build --filter LocalCostResourceTests \ + >"$workload_output" 2>"$measurement_file" +workload_status=$? +set -e + +if [[ $workload_status -ne 0 ]]; then + echo "Resource check: workload failed." >&2 + exit 1 +fi + +maximum_rss="$(awk '/maximum resident set size/ { print $1; exit }' "$measurement_file")" +elapsed_seconds="$(awk '/ real / { print $1; exit }' "$measurement_file")" +executable_bytes="$(stat -f '%z' .build/release/tokenlink)" + +if [[ -z "$maximum_rss" || -z "$elapsed_seconds" || -z "$executable_bytes" ]]; then + echo "Resource check: measurement unavailable." >&2 + exit 1 +fi + +printf 'Resource workload: %s seconds\n' "$elapsed_seconds" +printf 'Maximum RSS: %s bytes\n' "$maximum_rss" +printf 'Release executable: %s bytes\n' "$executable_bytes" + +failed=0 +if (( maximum_rss > maximum_rss_bytes )); then + echo "Resource check: RSS limit exceeded." >&2 + failed=1 +fi +if ! awk -v actual="$elapsed_seconds" -v limit="$maximum_elapsed_seconds" \ + 'BEGIN { exit !(actual <= limit) }'; then + echo "Resource check: elapsed-time limit exceeded." >&2 + failed=1 +fi +if (( executable_bytes > maximum_executable_bytes )); then + echo "Resource check: executable-size limit exceeded." >&2 + failed=1 +fi + +if [[ $failed -ne 0 ]]; then + exit 1 +fi + +echo "Resource check passed."