From 780a493c285fb592c3fb32587889e753a22b1b3b Mon Sep 17 00:00:00 2001 From: YE Date: Wed, 30 Sep 2026 23:32:19 +0900 Subject: [PATCH 1/3] =?UTF-8?q?Price=20Codex=20at=20OpenAI's=20list=20rate?= =?UTF-8?q?s=20on=20the=20day=20they=20applied,=20and=20mark=20a=20borrowe?= =?UTF-8?q?d=20rate=20with=20"=E2=89=88"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mac's Codex price table stopped at gpt-5.5, which it charged at gpt-5.4's $2.50 / $15 pending an official price. Every newer model fell back to that row, so gpt-6-astra ($10 / $50) and gpt-5.6-sol ($4 / $20) were charged $2.50 / $15, and nothing on screen said the rate was borrowed. - CodexPricingTable (new, CLIPulseCore, shared): the Codex rows from CodexBar's CostUsagePricing.swift at 25bba9b7 under its MIT notice, re-checked against developers.openai.com on 2026-09-30. gpt-5.5 and gpt-5.5-pro at OpenAI's rates; gpt-6-astra, gpt-5.6 Sol/Terra/Luna and the two Cyber rows added; gpt-6-sol, gpt-6-luna and gpt-6.1-sol added from OpenAI's pages (not yet upstream). The three gpt-5.5 rows OpenAI never listed (-codex, -mini, -nano) are removed. - Dated rates: Sol at $5 / $30 before 2026-08-21, Terra at $2.50 / $15 and Luna at $1 / $6 before 2026-07-30. The scanner prices a day of Codex usage at the rate in force at that day's local noon. - Pricing aliases (gpt-5.6, gpt-reserve, the Daybreak names) choose a row without renaming the model, so no cached key changes. - The 272K long-context tier is in the table and in requestCostUSD for per-request pricing; day sums stay at standard rates. - Approximate class: the scanner records when a Codex or Claude model was charged at a neighbour's rate (DailyEntry.priceIsApproximate). CostCoverage counts those tokens as approximate, the cost badge gains an "Approximate" state, and the Mac cost card, Providers bar and provider cards show those figures as "≈$…", with a line naming the models. Six languages. Co-Authored-By: Claude Opus 5.5 --- CLI Pulse Bar/CLI Pulse Bar/OverviewTab.swift | 51 ++- .../CLI Pulse Bar/ProvidersTab.swift | 22 +- .../Sources/CLIPulseCore/AppState.swift | 24 ++ .../CLIPulseCore/CodexPricingTable.swift | 372 ++++++++++++++++++ .../Sources/CLIPulseCore/Components.swift | 10 + .../Sources/CLIPulseCore/CostCoverage.swift | 79 +++- .../Sources/CLIPulseCore/CostUsageCache.swift | 20 +- .../CLIPulseCore/CostUsageScanner.swift | 181 ++++++--- .../CLIPulseCore/DataRefreshManager.swift | 3 +- .../Sources/CLIPulseCore/L10n.swift | 20 +- .../Sources/CLIPulseCore/ProviderConfig.swift | 14 +- .../Resources/en.lproj/Localizable.strings | 5 + .../Resources/es.lproj/Localizable.strings | 5 + .../Resources/ja.lproj/Localizable.strings | 5 + .../Resources/ko.lproj/Localizable.strings | 5 + .../zh-Hans.lproj/Localizable.strings | 5 + .../zh-Hant.lproj/Localizable.strings | 5 + .../ApproximatePriceCoverageTests.swift | 167 ++++++++ .../Claude5PricingTests.swift | 28 +- .../CodexPricingGpt55Tests.swift | 64 +-- .../CodexPricingTableTests.swift | 356 +++++++++++++++++ .../CLIPulseCoreTests/CostCoverageTests.swift | 10 +- 22 files changed, 1304 insertions(+), 147 deletions(-) create mode 100644 CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CodexPricingTable.swift create mode 100644 CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/ApproximatePriceCoverageTests.swift create mode 100644 CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/CodexPricingTableTests.swift diff --git a/CLI Pulse Bar/CLI Pulse Bar/OverviewTab.swift b/CLI Pulse Bar/CLI Pulse Bar/OverviewTab.swift index ea20a87d3..27a71e831 100644 --- a/CLI Pulse Bar/CLI Pulse Bar/OverviewTab.swift +++ b/CLI Pulse Bar/CLI Pulse Bar/OverviewTab.swift @@ -369,7 +369,8 @@ struct OverviewTab: View { // number came from; the old badge used it to make a claim about // how accurate the number is. A local scan that priced 20% of // its tokens is still a local scan, and this said "Exact" - // directly above the line admitting it was 20%. + // directly above the line admitting it was 20%. 1.56 adds a + // fourth, "Approximate", for a rate borrowed from another model. let fidelity = providerState.costSummary.fidelity let fidelityColor: Color = fidelity == .exact ? .green : .orange Text(L10n.cost.fidelityLabel(fidelity)) @@ -387,7 +388,12 @@ struct OverviewTab: View { .font(.system(size: 9)) .foregroundStyle(.tertiary) HStack(alignment: .firstTextBaseline, spacing: 4) { - Text(CostFormatter.format(providerState.costSummary.todayTotal)) + // v1.56: "≈" when part of today's figure was charged at + // a rate borrowed from a neighbouring model. + Text(CostFormatter.format( + providerState.costSummary.todayTotal, + approximate: providerState.costSummary.todayCoverage.hasApproximatePrices + )) .font(.system(size: 16, weight: .bold, design: .rounded)) .foregroundStyle(.green) if providerState.costSummary.todayTokens > 0 { @@ -408,7 +414,10 @@ struct OverviewTab: View { .font(.system(size: 9)) .foregroundStyle(.tertiary) HStack(alignment: .firstTextBaseline, spacing: 4) { - Text(CostFormatter.format(providerState.costSummary.thirtyDayTotal)) + Text(CostFormatter.format( + providerState.costSummary.thirtyDayTotal, + approximate: providerState.costSummary.coverage.hasApproximatePrices + )) .font(.system(size: 16, weight: .bold, design: .rounded)) .foregroundStyle(.green) if providerState.costSummary.thirtyDayTokens > 0 { @@ -445,7 +454,13 @@ struct OverviewTab: View { .clipShape(Capsule()) } Spacer() - Text(CostFormatter.format(item.cost)) + // Only the 30-day breakdown (a local scan) can know; + // `approximateProviders` is empty for a server figure. + Text(CostFormatter.format( + item.cost, + approximate: providerState.costSummary.isPrecise + && providerState.costSummary.coverage.approximateProviders.contains(item.provider) + )) .font(.system(size: 10, weight: .medium).monospacedDigit()) .foregroundStyle(.green) } @@ -548,7 +563,10 @@ struct OverviewTab: View { .font(.system(size: 9)) .foregroundStyle(.secondary) Spacer() - Text(CostFormatter.format(providerState.costSummary.thirtyDayTotal)) + Text(CostFormatter.format( + providerState.costSummary.thirtyDayTotal, + approximate: providerState.costSummary.coverage.hasApproximatePrices + )) .font(.system(size: 10, weight: .medium).monospacedDigit()) .foregroundStyle(.green) } @@ -584,6 +602,24 @@ struct OverviewTab: View { )) } + // v1.56 — the same honesty for a borrowed rate. A model + // with no price of its own is charged at the closest listed + // model's; that keeps it out of "$0", and until now nothing + // said the rate was not its own. The figures above carry + // "≈"; this line says why, and the help names the models. + if providerState.costSummary.coverage.hasApproximatePrices { + let coverage = providerState.costSummary.coverage + HStack(spacing: 4) { + Text(L10n.cost.approximateSummary(coverage.approximateModels.count)) + .font(.system(size: 9)) + .foregroundStyle(.orange) + Spacer() + } + .help(L10n.cost.approximateHelp( + coverage.approximateModels.prefix(6).joined(separator: ", ") + )) + } + HStack { Text(L10n.dashboard.subscriptions) .font(.system(size: 9)) @@ -622,7 +658,10 @@ struct OverviewTab: View { Text(L10n.cost.tokensValue(TokenFormatter.format(item.totalTokens))) .font(.system(size: 8)) .foregroundStyle(.tertiary) - Text(CostFormatter.format(item.cost)) + Text(CostFormatter.format( + item.cost, + approximate: providerState.costSummary.coverage.approximateModels.contains(item.model) + )) .font(.system(size: 9, weight: .medium).monospacedDigit()) .foregroundStyle(.green) .frame(width: 55, alignment: .trailing) diff --git a/CLI Pulse Bar/CLI Pulse Bar/ProvidersTab.swift b/CLI Pulse Bar/CLI Pulse Bar/ProvidersTab.swift index a4eb67db1..b1cdb19a5 100644 --- a/CLI Pulse Bar/CLI Pulse Bar/ProvidersTab.swift +++ b/CLI Pulse Bar/CLI Pulse Bar/ProvidersTab.swift @@ -174,7 +174,10 @@ struct ProvidersTab: View { Text(L10n.dashboard.today) .font(.system(size: 8)) .foregroundStyle(.tertiary) - Text(CostFormatter.format(providerState.costSummary.todayTotal)) + Text(CostFormatter.format( + providerState.costSummary.todayTotal, + approximate: providerState.costSummary.todayCoverage.hasApproximatePrices + )) .font(.system(size: 13, weight: .bold, design: .rounded)) .foregroundStyle(.green) } @@ -183,7 +186,10 @@ struct ProvidersTab: View { Text(L10n.dashboard.thirtyDayEst) .font(.system(size: 8)) .foregroundStyle(.tertiary) - Text(CostFormatter.format(providerState.costSummary.thirtyDayTotal)) + Text(CostFormatter.format( + providerState.costSummary.thirtyDayTotal, + approximate: providerState.costSummary.coverage.hasApproximatePrices + )) .font(.system(size: 13, weight: .bold, design: .rounded)) .foregroundStyle(.green) } @@ -496,7 +502,7 @@ struct EnhancedProviderCard: View { private var isQuotaProvider: Bool { provider.metadata?.supports_quota ?? false } @ViewBuilder - private func usageColumn(header: String, metric: CardMetric, cost: Double) -> some View { + private func usageColumn(header: String, metric: CardMetric, cost: Double, approximateCost: Bool) -> some View { VStack(alignment: .leading, spacing: 2) { Text(header) .font(.system(size: 9)) @@ -511,7 +517,9 @@ struct EnhancedProviderCard: View { .help(metric.breakdownTooltip) } if showCost { - Text(CostFormatter.format(cost)) + // v1.56: "≈" when part of the cost was charged at a rate + // borrowed from a neighbouring model. + Text(CostFormatter.format(cost, approximate: approximateCost)) .font(.system(size: 10, weight: .medium)) .foregroundStyle(.green) } @@ -776,8 +784,10 @@ struct EnhancedProviderCard: View { let today = metric(for: Date()) let week = metric(for: nil, weekRolling: true) HStack(spacing: 12) { - usageColumn(header: L10n.dashboard.today, metric: today, cost: provider.estimated_cost_today) - usageColumn(header: L10n.providers.thisWeek, metric: week, cost: provider.estimated_cost_week) + usageColumn(header: L10n.dashboard.today, metric: today, cost: provider.estimated_cost_today, + approximateCost: state.scanPriceIsApproximate(for: provider.provider, onDate: Date())) + usageColumn(header: L10n.providers.thisWeek, metric: week, cost: provider.estimated_cost_week, + approximateCost: state.scanPriceIsApproximateThisWeek(for: provider.provider)) Spacer() } diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/AppState.swift b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/AppState.swift index 21d4fa748..f8090f4ca 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/AppState.swift +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/AppState.swift @@ -397,6 +397,30 @@ public final class AppState: ObservableObject { return total > 0 ? total : nil } + /// v1.56: whether any of `provider`'s scanned cost on `onDate` (every + /// scanned day when nil) was charged at a rate borrowed from a neighbouring + /// model. The provider card shows that cost as "≈$…". + /// + /// The card's figure can be the cloud's rather than this scan's (it shows + /// the larger), but the cloud's includes what this Mac uploaded, priced + /// the same way, so the mark holds either way. + public func scanPriceIsApproximate(for provider: String, onDate: Date? = nil) -> Bool { + guard let scan = costUsageScanResult else { return false } + let key = onDate.map { DayKey.string(from: $0) } // the scanner's Gregorian keys + return scan.entries.contains { + $0.provider == provider && $0.priceIsApproximate && (key == nil || $0.date == key) + } + } + + /// `scanPriceIsApproximate` over the rolling 7-day window. + public func scanPriceIsApproximateThisWeek(for provider: String) -> Bool { + guard let scan = costUsageScanResult else { return false } + let cutoffKey = DateRange.rollingWeekStartYMD(from: Date()) + return scan.entries.contains { + $0.provider == provider && $0.priceIsApproximate && $0.date >= cutoffKey + } + } + public func scanMessagesThisWeek(for provider: String) -> Int? { guard let scan = costUsageScanResult, !scan.entries.isEmpty else { return nil } let cutoffKey = DateRange.rollingWeekStartYMD(from: Date()) diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CodexPricingTable.swift b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CodexPricingTable.swift new file mode 100644 index 000000000..e7afe4274 --- /dev/null +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CodexPricingTable.swift @@ -0,0 +1,372 @@ +// Derived from steipete/CodexBar +// Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift (the Codex +// rate rows, the GPT-5.6 Sol/Terra/Luna rates in force before their repricing, +// the pricing aliases in `normalizeCodexModel`, and `codexCostUSD(pricing:…)`, +// taken at upstream commit 25bba9b7, 2026-09-28) +// (https://github.com/steipete/CodexBar). +// +// Not verbatim: +// * Rows are `Rates` values in one table instead of upstream's +// `CodexPricing` initialisers and its `gpt56Pricing` helper. The numbers +// are upstream's, re-checked on 2026-09-30 against OpenAI's pricing page +// and model pages for every model the page lists +// (https://developers.openai.com/api/docs/pricing). +// * Rows that are ours, not upstream's, say so where they sit: `gpt-6-sol`, +// `gpt-6-luna` and `gpt-6.1-sol` (released after 25bba9b7), and the +// long-context tier on the two `-pro` rows, which OpenAI's page lists and +// upstream's table leaves out. +// * Upstream applies an alias inside `normalizeCodexModel`, so the alias +// also becomes the model's stored and displayed name. Here an alias only +// chooses the price row: `CostUsageScanner.Pricing.normalizeCodexModel` +// is unchanged, so every cached day keeps the key it was stored under and +// a model is shown under the name Codex wrote. +// * Dated rates are a list per model (`superseded`), not one cutoff each. +// * `aggregateCostUSD` is ours. The scanner still sums Codex usage per day +// and model before pricing it, and a per-request tier cannot be applied to +// a sum; see that function. +// * Not ported: models.dev lookups, the custom-pricing overlay, API Fast +// (priority) multipliers, and the pricing fingerprint. +// +// ─── MIT License (full notice required by upstream) ─────────────── +// +// MIT License +// +// Copyright (c) 2026 Peter Steinberger +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, copy, +// modify, merge, publish, distribute, sublicense, and/or sell copies +// of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + +import Foundation + +/// OpenAI API list prices for the models Codex writes into its session logs, +/// in US dollars per token, and the prices some of them had before. +/// +/// The cost this feeds is an API-equivalent estimate, not a bill: a ChatGPT +/// plan does not charge per token. The table exists so that the estimate uses +/// the rate OpenAI actually lists for the model, on the day it was used. +/// +/// Until 1.56 the Mac table stopped at `gpt-5.5`, which it priced at +/// `gpt-5.4`'s $2.50 / $15 with a note to replace the row "when official". +/// Every newer model fell back to that row, so `gpt-6-astra` ($10 / $50) and +/// `gpt-5.6-sol` ($4 / $20) were charged $2.50 / $15, and nothing on screen +/// said the rate was borrowed. +public enum CodexPricingTable { + + /// One model's rates, USD per token. + public struct Rates: Sendable, Equatable { + public let input: Double + public let output: Double + /// Cached input. nil: OpenAI lists none (the `-pro` rows), so cached + /// input is charged at `input`. + public let cachedInput: Double? + /// Cache writes. nil: charged at `input`. Codex's logs do not report + /// cache writes separately today, so this rate is carried for fidelity + /// with the published row rather than used. + public let cacheWrite: Double? + /// A request with MORE than this many input tokens is charged the + /// `…AboveThreshold` rates for the whole request. nil: no tier. + public let longContextThreshold: Int? + public let inputAboveThreshold: Double? + public let outputAboveThreshold: Double? + public let cachedInputAboveThreshold: Double? + public let cacheWriteAboveThreshold: Double? + + public init( + input: Double, + output: Double, + cachedInput: Double?, + cacheWrite: Double? = nil, + longContextThreshold: Int? = nil, + inputAboveThreshold: Double? = nil, + outputAboveThreshold: Double? = nil, + cachedInputAboveThreshold: Double? = nil, + cacheWriteAboveThreshold: Double? = nil + ) { + self.input = input + self.output = output + self.cachedInput = cachedInput + self.cacheWrite = cacheWrite + self.longContextThreshold = longContextThreshold + self.inputAboveThreshold = inputAboveThreshold + self.outputAboveThreshold = outputAboveThreshold + self.cachedInputAboveThreshold = cachedInputAboveThreshold + self.cacheWriteAboveThreshold = cacheWriteAboveThreshold + } + } + + /// OpenAI's long-context boundary: "Short context: ≤272K input tokens. + /// Long context: >272K input tokens" (pricing page), and the whole request + /// moves to the long-context rates (every model page). + public static let longContextThreshold = 272_000 + + /// The GPT-5.6 and GPT-6 rows share one shape: cached input is 10% of + /// input, a cache write 1.25x, and above 272K the request pays 2x input + /// and cache rates and 1.5x output (OpenAI's model pages). Written out as + /// numbers, not multiplied, so each row can be read against the page. + private static func tiered( + standard: (input: Double, cached: Double, write: Double, output: Double), + longContext: (input: Double, cached: Double, write: Double, output: Double) + ) -> Rates { + Rates( + input: standard.input, + output: standard.output, + cachedInput: standard.cached, + cacheWrite: standard.write, + longContextThreshold: longContextThreshold, + inputAboveThreshold: longContext.input, + outputAboveThreshold: longContext.output, + cachedInputAboveThreshold: longContext.cached, + cacheWriteAboveThreshold: longContext.write + ) + } + + // MARK: - Rates in force today + + /// Today's rates, keyed by the name Codex writes (after + /// `normalizeCodexModel` drops an `openai/` prefix or a date suffix). + /// + /// Source: https://developers.openai.com/api/docs/pricing (standard tier), + /// checked 2026-09-30, plus the model page named on a row where it adds + /// something. The page no longer lists the older `-codex` variants + /// (`gpt-5-codex`, `gpt-5.1-codex`, `-codex-max`, `-codex-mini`, + /// `gpt-5.2-codex`); their rows are upstream's and equal their base model. + public static let current: [String: Rates] = [ + "gpt-5": Rates(input: 1.25e-6, output: 1e-5, cachedInput: 1.25e-7), + "gpt-5-codex": Rates(input: 1.25e-6, output: 1e-5, cachedInput: 1.25e-7), + "gpt-5-mini": Rates(input: 2.5e-7, output: 2e-6, cachedInput: 2.5e-8), + "gpt-5-nano": Rates(input: 5e-8, output: 4e-7, cachedInput: 5e-9), + "gpt-5-pro": Rates(input: 1.5e-5, output: 1.2e-4, cachedInput: nil), + "gpt-5.1": Rates(input: 1.25e-6, output: 1e-5, cachedInput: 1.25e-7), + "gpt-5.1-codex": Rates(input: 1.25e-6, output: 1e-5, cachedInput: 1.25e-7), + "gpt-5.1-codex-max": Rates(input: 1.25e-6, output: 1e-5, cachedInput: 1.25e-7), + "gpt-5.1-codex-mini": Rates(input: 2.5e-7, output: 2e-6, cachedInput: 2.5e-8), + "gpt-5.2": Rates(input: 1.75e-6, output: 1.4e-5, cachedInput: 1.75e-7), + "gpt-5.2-codex": Rates(input: 1.75e-6, output: 1.4e-5, cachedInput: 1.75e-7), + "gpt-5.2-pro": Rates(input: 2.1e-5, output: 1.68e-4, cachedInput: nil), + "gpt-5.3-codex": Rates(input: 1.75e-6, output: 1.4e-5, cachedInput: 1.75e-7), + // A research preview with no API price. Upstream shows it as + // "Research Preview"; here it is simply free, as it was before. + "gpt-5.3-codex-spark": Rates(input: 0, output: 0, cachedInput: 0), + "gpt-5.4": Rates( + input: 2.5e-6, output: 1.5e-5, cachedInput: 2.5e-7, + longContextThreshold: longContextThreshold, + inputAboveThreshold: 5e-6, outputAboveThreshold: 2.25e-5, cachedInputAboveThreshold: 5e-7 + ), + "gpt-5.4-mini": Rates(input: 7.5e-7, output: 4.5e-6, cachedInput: 7.5e-8), + "gpt-5.4-nano": Rates(input: 2e-7, output: 1.25e-6, cachedInput: 2e-8), + // Long-context tier ours: OpenAI lists $60 / $270 above 272K for both + // `-pro` rows; upstream's table has no tier on them. + "gpt-5.4-pro": Rates( + input: 3e-5, output: 1.8e-4, cachedInput: nil, + longContextThreshold: longContextThreshold, + inputAboveThreshold: 6e-5, outputAboveThreshold: 2.7e-4 + ), + // $5 / $30, $0.50 cached; $10 / $45 / $1 above 272K. The old row was + // gpt-5.4's rates, written before OpenAI published these. + "gpt-5.5": Rates( + input: 5e-6, output: 3e-5, cachedInput: 5e-7, + longContextThreshold: longContextThreshold, + inputAboveThreshold: 1e-5, outputAboveThreshold: 4.5e-5, cachedInputAboveThreshold: 1e-6 + ), + "gpt-5.5-pro": Rates( + input: 3e-5, output: 1.8e-4, cachedInput: nil, + longContextThreshold: longContextThreshold, + inputAboveThreshold: 6e-5, outputAboveThreshold: 2.7e-4 + ), + // https://developers.openai.com/api/docs/models/gpt-6-astra + "gpt-6-astra": tiered( + standard: (input: 1e-5, cached: 1e-6, write: 1.25e-5, output: 5e-5), + longContext: (input: 2e-5, cached: 2e-6, write: 2.5e-5, output: 7.5e-5) + ), + // GPT-5.6. Sol's rate is the one in force since 2026-08-21 (see + // `superseded`); OpenAI's page calls it promotional "at least through + // November 21, 2026". Terra and Luna since 2026-07-30. + "gpt-5.6-sol": tiered( + standard: (input: 4e-6, cached: 4e-7, write: 5e-6, output: 2e-5), + longContext: (input: 8e-6, cached: 8e-7, write: 1e-5, output: 3e-5) + ), + "gpt-5.6-terra": tiered( + standard: (input: 2e-6, cached: 2e-7, write: 2.5e-6, output: 1.2e-5), + longContext: (input: 4e-6, cached: 4e-7, write: 5e-6, output: 1.8e-5) + ), + "gpt-5.6-luna": tiered( + standard: (input: 2e-7, cached: 2e-8, write: 2.5e-7, output: 1.2e-6), + longContext: (input: 4e-7, cached: 4e-8, write: 5e-7, output: 1.8e-6) + ), + // Daybreak Cyber. OpenAI lists no long-context tier for either, and no + // cache-write rate for gpt-5.5-cyber. + "gpt-5.6-cyber": Rates(input: 1.25e-5, output: 7.5e-5, cachedInput: 1.25e-6, cacheWrite: 1.5625e-5), + "gpt-5.5-cyber": Rates(input: 1.25e-5, output: 7.5e-5, cachedInput: 1.25e-6), + // Ours, not upstream's: released after 25bba9b7 (OpenAI changelog, + // 2026-09-22 and 2026-09-29). Without rows they would borrow + // gpt-6-astra's rate, five times theirs. + // https://developers.openai.com/api/docs/models/gpt-6-sol + "gpt-6-sol": tiered( + standard: (input: 2e-6, cached: 2e-7, write: 2.5e-6, output: 1e-5), + longContext: (input: 4e-6, cached: 4e-7, write: 5e-6, output: 1.5e-5) + ), + // https://developers.openai.com/api/docs/models/gpt-6-luna + "gpt-6-luna": tiered( + standard: (input: 1e-7, cached: 1e-8, write: 1.25e-7, output: 5e-7), + longContext: (input: 2e-7, cached: 2e-8, write: 2.5e-7, output: 7.5e-7) + ), + // https://developers.openai.com/api/docs/models/gpt-6.1-sol — cached + // input is 5% of input on this one, not 10%. + "gpt-6.1-sol": tiered( + standard: (input: 2e-6, cached: 1e-7, write: 2.5e-6, output: 1e-5), + longContext: (input: 4e-6, cached: 2e-7, write: 5e-6, output: 1.5e-5) + ), + ] + + // MARK: - Rates a model had before + + /// GPT-5.6 Terra and Luna were repriced from 2026-07-30: "Starting July 30, + /// GPT-5.6 Luna costs 80% less, while GPT-5.6 Terra costs 20% less" (OpenAI + /// changelog). Upstream dates it 00:00 UTC (Unix 1785369600). + public static let terraLunaRepricing = Date(timeIntervalSince1970: 1_785_369_600) + + /// GPT-5.6 Sol went from $5 / $30 to $4 / $20 on 2026-08-21 (OpenAI + /// changelog, "August 21, 2026"). Upstream dates it 00:00 UTC + /// (Unix 1787270400). + public static let solRepricing = Date(timeIntervalSince1970: 1_787_270_400) + + /// A rate that applied until `until` (exclusive). + public struct DatedRates: Sendable { + public let until: Date + public let rates: Rates + } + + /// Earlier rates per model, oldest first. A usage moment before `until` + /// is charged `rates`; from `until` on, the next entry or `current`. + public static let superseded: [String: [DatedRates]] = [ + "gpt-5.6-sol": [DatedRates(until: solRepricing, rates: tiered( + standard: (input: 5e-6, cached: 5e-7, write: 6.25e-6, output: 3e-5), + longContext: (input: 1e-5, cached: 1e-6, write: 1.25e-5, output: 4.5e-5) + ))], + "gpt-5.6-terra": [DatedRates(until: terraLunaRepricing, rates: tiered( + standard: (input: 2.5e-6, cached: 2.5e-7, write: 3.125e-6, output: 1.5e-5), + longContext: (input: 5e-6, cached: 5e-7, write: 6.25e-6, output: 2.25e-5) + ))], + // Five times today's rate: the July cut was 80%. + "gpt-5.6-luna": [DatedRates(until: terraLunaRepricing, rates: tiered( + standard: (input: 1e-6, cached: 1e-7, write: 1.25e-6, output: 6e-6), + longContext: (input: 2e-6, cached: 2e-7, write: 2.5e-6, output: 9e-6) + ))], + ] + + // MARK: - Names that are another model's price + + /// Names Codex can write that OpenAI bills as another listed model. + /// Upstream's `normalizeCodexModel`: "OpenAI routes the unsuffixed gpt-5.6 + /// alias to Sol", "Codex uses gpt-reserve for the Luna Reserve quota + /// bucket", and "OpenAI's Daybreak aliases currently point to Sol (blue) + /// and Cyber (red)". An alias is an exact price, not an approximation. + public static let aliases: [String: String] = [ + "gpt-5.6": "gpt-5.6-sol", + "gpt-reserve": "gpt-5.6-luna", + "gpt-daybreak-blue-latest": "gpt-5.6-sol", + "gpt-daybreak-red-latest": "gpt-5.6-cyber", + ] + + // MARK: - Lookup + + /// The rates `key` was charged at `date`; today's when `date` is nil. + /// `key` is a row name (`current`), not an alias or a raw model name. + public static func rates(forKey key: String, at date: Date?) -> Rates? { + if let date, let history = superseded[key] { + for entry in history where date < entry.until { + return entry.rates + } + } + return current[key] + } + + // MARK: - Cost + + /// Cost of ONE request. Upstream's `codexCostUSD(pricing:…)`. + /// + /// OpenAI's `input_tokens` is the whole prompt; cached reads are a subset + /// of it, and cache writes a subset of the rest. Both are clamped so no + /// token is invented or charged twice. A request over the threshold pays + /// the long-context rates on every token, not only on the excess. + public static func requestCostUSD( + rates: Rates, + inputTokens: Int, + cachedInputTokens: Int, + cacheWriteInputTokens: Int = 0, + outputTokens: Int + ) -> Double { + let longContext = rates.longContextThreshold.map { max(0, inputTokens) > $0 } ?? false + return cost( + rates: rates, longContext: longContext, + inputTokens: inputTokens, cachedInputTokens: cachedInputTokens, + cacheWriteInputTokens: cacheWriteInputTokens, outputTokens: outputTokens + ) + } + + /// Cost of a SUM of requests, at standard rates. + /// + /// The scanner adds Codex usage up per day and model before pricing it, so + /// the size of each request is gone by the time a price is chosen. Testing + /// the threshold against a day's total would put every busy day on + /// long-context rates, so a sum never takes the tier. Requests over 272K + /// are therefore under-counted until usage is priced request by request. + public static func aggregateCostUSD( + rates: Rates, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int + ) -> Double { + cost( + rates: rates, longContext: false, + inputTokens: inputTokens, cachedInputTokens: cachedInputTokens, + cacheWriteInputTokens: 0, outputTokens: outputTokens + ) + } + + private static func cost( + rates: Rates, + longContext: Bool, + inputTokens: Int, + cachedInputTokens: Int, + cacheWriteInputTokens: Int, + outputTokens: Int + ) -> Double { + let totalInput = max(0, inputTokens) + let cached = min(max(0, cachedInputTokens), totalInput) + let remainingAfterCache = totalInput - cached + let cacheWrite = min(max(0, cacheWriteInputTokens), remainingAfterCache) + let nonCached = remainingAfterCache - cacheWrite + + let inputRate = longContext ? rates.inputAboveThreshold ?? rates.input : rates.input + let cachedRate = longContext + ? rates.cachedInputAboveThreshold ?? rates.cachedInput ?? inputRate + : rates.cachedInput ?? rates.input + let cacheWriteRate = longContext + ? rates.cacheWriteAboveThreshold ?? rates.cacheWrite ?? inputRate + : rates.cacheWrite ?? inputRate + let outputRate = longContext ? rates.outputAboveThreshold ?? rates.output : rates.output + + return Double(nonCached) * inputRate + + Double(cached) * cachedRate + + Double(cacheWrite) * cacheWriteRate + + Double(max(0, outputTokens)) * outputRate + } +} diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Components.swift b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Components.swift index 0992b7632..581e0f8ce 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Components.swift +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Components.swift @@ -666,6 +666,16 @@ public enum CostFormatter { CurrencyConverter.shared.format(cost) } + /// v1.56: `format`, marked "≈" when part of the figure was charged at a + /// rate borrowed from a neighbouring model (`CostCoverage`). The mark is a + /// symbol, the same in every language, like "$". + public static func format(_ cost: Double, approximate: Bool) -> String { + approximate ? approximateMark + format(cost) : format(cost) + } + + /// The prefix for a figure that includes an approximate price. + public static let approximateMark = "≈" + /// A compact token count. Kept under this name for its many call sites; /// the one implementation is `TokenFormatter.format`. public static func formatUsage(_ usage: Int) -> String { diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CostCoverage.swift b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CostCoverage.swift index 3e1101372..a0758cb2b 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CostCoverage.swift +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CostCoverage.swift @@ -43,39 +43,57 @@ public struct CostCoverage: Sendable, Equatable { } public let basis: Basis - /// Tokens belonging to entries the app had a rate for. + /// Tokens belonging to entries charged at their model's own listed rate. public let pricedTokens: Int + /// v1.56: tokens belonging to entries charged at a rate borrowed from a + /// neighbouring model, because their model has no price of its own + /// (`DailyEntry.priceIsApproximate`). They carry a rate, so they count + /// toward `pricedFraction`, but not toward `pricedTokens`. + public let approximateTokens: Int /// Tokens belonging to entries with no rate. These contributed $0. public let unpricedTokens: Int /// Model identifiers with no rate, most tokens first. Never rendered in a /// primary label — it is for the tooltip and the bug report. public let unpricedModels: [String] + /// Model identifiers charged at a borrowed rate, most tokens first. Listed + /// in the "≈" line's tooltip. + public let approximateModels: [String] + /// Providers with at least one entry charged at a borrowed rate, so a + /// per-provider figure can carry its own "≈". + public let approximateProviders: Set public init( basis: Basis, pricedTokens: Int = 0, unpricedTokens: Int = 0, - unpricedModels: [String] = [] + unpricedModels: [String] = [], + approximateTokens: Int = 0, + approximateModels: [String] = [], + approximateProviders: Set = [] ) { self.basis = basis self.pricedTokens = pricedTokens + self.approximateTokens = approximateTokens self.unpricedTokens = unpricedTokens self.unpricedModels = unpricedModels + self.approximateModels = approximateModels + self.approximateProviders = approximateProviders } /// The default for any figure whose composition we cannot see. public static let unknown = CostCoverage(basis: .serverEstimate) - public var totalTokens: Int { pricedTokens + unpricedTokens } + public var totalTokens: Int { pricedTokens + approximateTokens + unpricedTokens } - /// Fraction of counted tokens that carried a rate. + /// Fraction of counted tokens that carried a rate, the model's own or a + /// borrowed one. /// /// `nil` when coverage is not knowable (`.serverEstimate`) or when there is /// nothing to divide (no tokens at all). Both cases must render as silence, /// not as 0% — "0% priced" on an empty account is a false alarm. public var pricedFraction: Double? { guard basis == .localScan, totalTokens > 0 else { return nil } - return Double(pricedTokens) / Double(totalTokens) + return Double(pricedTokens + approximateTokens) / Double(totalTokens) } /// True only when we looked AND everything carried a rate. A @@ -98,6 +116,15 @@ public struct CostCoverage: Sendable, Equatable { return Int((fraction * 100).rounded(.down)) } + /// v1.56: whether part of the figure was charged at a borrowed rate, so + /// the figure is shown as "≈$…" and the card says which models. + /// + /// Only a local scan can know. A server estimate says nothing, for the + /// same reason it never claims full coverage. + public var hasApproximatePrices: Bool { + basis == .localScan && approximateTokens > 0 + } + // MARK: - Fidelity /// How much a cost figure deserves to be trusted, combining where it came @@ -110,11 +137,17 @@ public struct CostCoverage: Sendable, Equatable { /// came apart the moment a model had no rate: a scan that priced 20% of its /// tokens is still a local scan, and the card called it Exact. /// - /// Three states, because there are three situations. + /// Four states, because there are four situations. `approximate` came in + /// 1.56: a scan whose every token had a rate, some of them borrowed from a + /// neighbouring model, said "Exact". public enum Fidelity: Sendable, Equatable, Hashable { - /// Counted locally, and every token had a rate. + /// Counted locally, and every token had its model's own rate. case exact + /// Counted locally, every token had a rate, and some of those rates + /// were borrowed from a neighbouring model. + case approximate /// Counted locally, but some tokens had no rate and contributed $0. + /// Outranks `approximate`: a missing cost is the bigger error. case partial /// Came pre-summed from the backend; composition unknown. case estimated @@ -132,11 +165,17 @@ public struct CostCoverage: Sendable, Equatable { /// Token basis is `input + cached + output`, matching /// `CostUsageScanner.reportUnpricedModels` so the log line and the UI can /// never disagree about the same scan. + /// + /// v1.56: an entry priced at a borrowed rate (`priceIsApproximate`) is + /// approximate, not priced — the discriminator the scanner set when it + /// chose the rate, so this never re-derives it from a model name. public static func from( entries: [CostUsageScanResult.DailyEntry] ) -> CostCoverage { var priced = 0 var unpricedByModel: [String: Int] = [:] + var approximateByModel: [String: Int] = [:] + var approximateProviders: Set = [] for entry in entries { // Skip the synthetic `__claude_msg__` bucket. It carries raw @@ -158,6 +197,9 @@ public struct CostCoverage: Sendable, Equatable { let tokens = entry.inputTokens + entry.cachedTokens + entry.outputTokens if entry.costUSD == nil { unpricedByModel[entry.model, default: 0] += tokens + } else if entry.priceIsApproximate { + approximateByModel[entry.model, default: 0] += tokens + approximateProviders.insert(entry.provider) } else { priced += tokens } @@ -167,14 +209,21 @@ public struct CostCoverage: Sendable, Equatable { basis: .localScan, pricedTokens: priced, unpricedTokens: unpricedByModel.values.reduce(0, +), - unpricedModels: unpricedByModel - .sorted { lhs, rhs in - // Token count descending, then name, so the order is stable - // for two models with identical totals (which happens in - // tests and on quiet days). - lhs.value == rhs.value ? lhs.key < rhs.key : lhs.value > rhs.value - } - .map(\.key) + unpricedModels: mostTokensFirst(unpricedByModel), + approximateTokens: approximateByModel.values.reduce(0, +), + approximateModels: mostTokensFirst(approximateByModel), + approximateProviders: approximateProviders ) } + + private static func mostTokensFirst(_ tokensByModel: [String: Int]) -> [String] { + tokensByModel + .sorted { lhs, rhs in + // Token count descending, then name, so the order is stable + // for two models with identical totals (which happens in + // tests and on quiet days). + lhs.value == rhs.value ? lhs.key < rhs.key : lhs.value > rhs.value + } + .map(\.key) + } } diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CostUsageCache.swift b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CostUsageCache.swift index 47f996f90..85637f028 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CostUsageCache.swift +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CostUsageCache.swift @@ -3,12 +3,22 @@ import Foundation // MARK: - Cache Types -/// Bump this every time `CostUsageScanner.Pricing.claudeModels` or -/// `codexModels` changes (entries added / removed / repriced). +/// Bump this every time `CostUsageScanner.Pricing.claudeModels` changes +/// (entries added / removed / repriced). /// -/// Why: per-event cost is computed inside `parseClaudeFile` / -/// `parseCodexFile` and stored as `costNanos` in the per-day-model -/// bucket. The `entriesFromClaudeCache` reconstruction has a +/// A change to `CodexPricingTable` needs no bump, as of 1.56: Codex day rows +/// hold tokens only (`[input, cached, output]`), and `entriesFromCodexCache` +/// prices them on every read, re-resolving the stored model name. A new row +/// can change what `normalizeCodexModel` returns for a dated spelling +/// (`gpt-5.6-sol-2026-08-01` → `gpt-5.6-sol`); files parsed before keep the +/// old key, which splits that model's By-Model row until they are re-read but +/// prices both halves the same. `CodexPricingTableTests. +/// test_codexCacheRowsCarryNoCost` fails the day a Codex row starts storing a +/// cost, which is when a Codex price change starts needing a bump. +/// +/// Why: per-event Claude cost is computed inside `parseClaudeFile` and +/// stored as `costNanos` in the per-day-model bucket. The +/// `entriesFromClaudeCache` reconstruction has a /// fallback that re-runs `Pricing.claudeCostUSD` when the bucket's /// summed `costNanos` is exactly zero — but that fallback gives the /// WRONG answer once even one new event lands in a previously-zero diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CostUsageScanner.swift b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CostUsageScanner.swift index 8084e6a10..7dc76eef1 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CostUsageScanner.swift +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CostUsageScanner.swift @@ -15,6 +15,12 @@ public struct CostUsageScanResult: Sendable { public let cachedTokens: Int public let outputTokens: Int public let costUSD: Double? + /// v1.56: `costUSD` was computed at a rate borrowed from a neighbouring + /// model, because this model has no price of its own + /// (`CostUsageScanner.Pricing.PriceResolution`). Always false when + /// `costUSD` is nil. `CostCoverage` counts these tokens as approximate, + /// and the cost card marks the figures they reach with "≈". + public let priceIsApproximate: Bool /// v1.9.4: deduped assistant-message count for this (day, provider, model). /// Currently only populated for Claude (via JSONL message.id + requestId /// dedup in `parseClaudeFile`). Codex leaves this at 0 — Codex doesn't @@ -27,7 +33,8 @@ public struct CostUsageScanResult: Sendable { public init(date: String, provider: String, model: String, inputTokens: Int, cachedTokens: Int, outputTokens: Int, - costUSD: Double?, messageCount: Int = 0) { + costUSD: Double?, priceIsApproximate: Bool = false, + messageCount: Int = 0) { self.date = date self.provider = provider self.model = model @@ -35,6 +42,7 @@ public struct CostUsageScanResult: Sendable { self.cachedTokens = cachedTokens self.outputTokens = outputTokens self.costUSD = costUSD + self.priceIsApproximate = costUSD != nil && priceIsApproximate self.messageCount = messageCount } } @@ -332,8 +340,16 @@ public enum CostUsageScanner { let cached = packed[safeIdx: 1] ?? 0 let output = packed[safeIdx: 2] ?? 0 guard input > 0 || cached > 0 || output > 0 else { continue } - let cost = Pricing.codexCostUSD(model: model, inputTokens: input, cachedInputTokens: cached, outputTokens: output) - result.append(.init(date: day, provider: "Codex", model: model, inputTokens: input, cachedTokens: cached, outputTokens: output, costUSD: cost)) + // Codex rows hold tokens only; the price is chosen here, on + // every read, at the rate in force on that day. So a change to + // `CodexPricingTable` reaches every cached day without a rescan + // (see `costUsageCachePricingVersion`). + let cost = Pricing.codexCostUSD( + model: model, inputTokens: input, cachedInputTokens: cached, outputTokens: output, + pricingDate: Pricing.codexPricingDate(forDayKey: day) + ) + result.append(.init(date: day, provider: "Codex", model: model, inputTokens: input, cachedTokens: cached, outputTokens: output, costUSD: cost, + priceIsApproximate: Pricing.codexPriceResolution(model)?.isApproximate ?? false)) } } return result @@ -365,7 +381,9 @@ public enum CostUsageScanner { : Pricing.claudeCostUSD(model: model, inputTokens: input, cacheReadInputTokens: cacheRead, cacheCreationInputTokens: cacheCreate, outputTokens: output) result.append(.init(date: day, provider: "Claude", model: model, inputTokens: input, cachedTokens: cacheRead + cacheCreate, - outputTokens: output, costUSD: cost, messageCount: msgs)) + outputTokens: output, costUSD: cost, + priceIsApproximate: Pricing.claudePriceResolution(model)?.isApproximate ?? false, + messageCount: msgs)) } } return result @@ -555,12 +573,6 @@ public enum CostUsageScanner { // MARK: - Pricing enum Pricing { - struct CodexModel { - let inputCostPerToken: Double - let outputCostPerToken: Double - let cacheReadCostPerToken: Double? - } - struct ClaudeModel { let inputCostPerToken: Double let outputCostPerToken: Double @@ -573,37 +585,28 @@ public enum CostUsageScanner { let cacheReadAbove: Double? } - private static let codexModels: [String: CodexModel] = [ - "gpt-5": .init(inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, cacheReadCostPerToken: 1.25e-7), - "gpt-5-codex": .init(inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, cacheReadCostPerToken: 1.25e-7), - "gpt-5-mini": .init(inputCostPerToken: 2.5e-7, outputCostPerToken: 2e-6, cacheReadCostPerToken: 2.5e-8), - "gpt-5-nano": .init(inputCostPerToken: 5e-8, outputCostPerToken: 4e-7, cacheReadCostPerToken: 5e-9), - "gpt-5-pro": .init(inputCostPerToken: 1.5e-5, outputCostPerToken: 1.2e-4, cacheReadCostPerToken: nil), - "gpt-5.1": .init(inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, cacheReadCostPerToken: 1.25e-7), - "gpt-5.1-codex": .init(inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, cacheReadCostPerToken: 1.25e-7), - "gpt-5.1-codex-max": .init(inputCostPerToken: 1.25e-6, outputCostPerToken: 1e-5, cacheReadCostPerToken: 1.25e-7), - "gpt-5.1-codex-mini": .init(inputCostPerToken: 2.5e-7, outputCostPerToken: 2e-6, cacheReadCostPerToken: 2.5e-8), - "gpt-5.2": .init(inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, cacheReadCostPerToken: 1.75e-7), - "gpt-5.2-codex": .init(inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, cacheReadCostPerToken: 1.75e-7), - "gpt-5.2-pro": .init(inputCostPerToken: 2.1e-5, outputCostPerToken: 1.68e-4, cacheReadCostPerToken: nil), - "gpt-5.3-codex": .init(inputCostPerToken: 1.75e-6, outputCostPerToken: 1.4e-5, cacheReadCostPerToken: 1.75e-7), - "gpt-5.3-codex-spark": .init(inputCostPerToken: 0, outputCostPerToken: 0, cacheReadCostPerToken: 0), - "gpt-5.4": .init(inputCostPerToken: 2.5e-6, outputCostPerToken: 1.5e-5, cacheReadCostPerToken: 2.5e-7), - "gpt-5.4-mini": .init(inputCostPerToken: 7.5e-7, outputCostPerToken: 4.5e-6, cacheReadCostPerToken: 7.5e-8), - "gpt-5.4-nano": .init(inputCostPerToken: 2e-7, outputCostPerToken: 1.25e-6, cacheReadCostPerToken: 2e-8), - "gpt-5.4-pro": .init(inputCostPerToken: 3e-5, outputCostPerToken: 1.8e-4, cacheReadCostPerToken: nil), - // gpt-5.5 family — mirrors the Rust desktop `pricing.rs` table - // (H-10 cross-runtime parity). OpenAI hasn't published official - // billing yet; rates mirror gpt-5.4 as a best-known approximation - // (Codex emitted model="gpt-5.5" in the wild — without an entry - // macOS rendered $0 while Windows/Linux priced it). Approximate- - // but-non-zero beats zero for cost-aware UX; replace when official. - "gpt-5.5": .init(inputCostPerToken: 2.5e-6, outputCostPerToken: 1.5e-5, cacheReadCostPerToken: 2.5e-7), - "gpt-5.5-codex": .init(inputCostPerToken: 2.5e-6, outputCostPerToken: 1.5e-5, cacheReadCostPerToken: 2.5e-7), - "gpt-5.5-mini": .init(inputCostPerToken: 7.5e-7, outputCostPerToken: 4.5e-6, cacheReadCostPerToken: 7.5e-8), - "gpt-5.5-nano": .init(inputCostPerToken: 2e-7, outputCostPerToken: 1.25e-6, cacheReadCostPerToken: 2e-8), - "gpt-5.5-pro": .init(inputCostPerToken: 3e-5, outputCostPerToken: 1.8e-4, cacheReadCostPerToken: nil), - ] + /// Which price row a model is charged at, and whether that row is the + /// model's own. + /// + /// v1.56: `isApproximate` is true when the row was borrowed — the Codex + /// version fallback or the Claude family fallback chose a neighbouring + /// model's rate because the model has none of its own. The scanner + /// used to know this at the moment it priced an entry and drop it, so + /// a borrowed rate reached the screen looking exactly like a listed + /// one. It now travels on `DailyEntry.priceIsApproximate`, and the cost + /// card marks such figures with "≈" (`CostCoverage`). + /// + /// An alias (`CodexPricingTable.aliases`) is not approximate: OpenAI + /// bills that name as the aliased model. + struct PriceResolution: Equatable { + let key: String + let isApproximate: Bool + } + + /// Codex rates live in `CodexPricingTable` (ported from CodexBar, with + /// its MIT notice), shared with iOS so the table can be read and tested + /// on every platform. + private static var codexModels: [String: CodexPricingTable.Rates] { CodexPricingTable.current } private static let claudeModels: [String: ClaudeModel] = [ "claude-haiku-4-5-20251001": .init(inputCostPerToken: 1e-6, outputCostPerToken: 5e-6, cacheCreationCostPerToken: 1.25e-6, cacheReadCostPerToken: 1e-7, thresholdTokens: nil, inputAbove: nil, outputAbove: nil, cacheCreationAbove: nil, cacheReadAbove: nil), @@ -694,16 +697,16 @@ public enum CostUsageScanner { /// /// The Claude side has had a family fallback since May 2026. The Codex /// side never had one, so an unrecognised OpenAI model read $0 with no - /// safety net at all — and it is currently reading $0 for - /// `gpt-5.6-sol` and `gpt-5.6-terra`, 940M tokens on the machine this - /// was found on. + /// safety net at all — in August 2026 that was `gpt-5.6-sol` and + /// `gpt-5.6-terra`, 940M tokens on the machine this was found on. /// - /// The precedent for what to do is already in the table above: when - /// `gpt-5.5` appeared with no published billing, it was priced by - /// mirroring `gpt-5.4`, with the reasoning written down — - /// *"Approximate-but-non-zero beats zero for cost-aware UX; replace when - /// official."* This generalises that from a hand-written row into a - /// rule, so the NEXT unrecognised model is approximate instead of free. + /// The precedent for what to do was a hand-written row: when `gpt-5.5` + /// appeared with no published billing, it was priced by mirroring + /// `gpt-5.4`, with the reasoning written down — *"Approximate-but-non-zero + /// beats zero for cost-aware UX; replace when official."* This + /// generalises that into a rule, so the NEXT unrecognised model is + /// approximate instead of free. Since 1.56 the result says so + /// (`codexPriceResolution`), and the screen marks it "≈". /// /// Tier is matched before version, and that ordering carries the whole /// risk: `gpt-5.4-pro` costs 12x `gpt-5.4`. Charging an unknown `-pro` @@ -712,8 +715,23 @@ public enum CostUsageScanner { /// suffix. An unknown suffix (`-sol`, `-terra`, `-codex-max`) is treated /// as base tier, which is where every non-suffixed Codex model has sat. static func codexPricingKey(_ raw: String) -> String? { + codexPriceResolution(raw)?.key + } + + /// The row `raw` is charged at: its own, then an alias's, then the + /// version fallback above (approximate). + static func codexPriceResolution(_ raw: String) -> PriceResolution? { let normalized = normalizeCodexModel(raw) - if codexModels[normalized] != nil { return normalized } + if codexModels[normalized] != nil { + return PriceResolution(key: normalized, isApproximate: false) + } + if let target = CodexPricingTable.aliases[normalized], codexModels[target] != nil { + return PriceResolution(key: target, isApproximate: false) + } + return codexFallbackKey(normalized).map { PriceResolution(key: $0, isApproximate: true) } + } + + private static func codexFallbackKey(_ normalized: String) -> String? { guard let (version, tier) = codexVersionTier(normalized) else { return nil } var best: (key: String, version: (Int, Int))? for key in codexModels.keys { @@ -809,9 +827,17 @@ public enum CostUsageScanner { /// the second. A model we have never seen keeps its real name in the UI /// *and* gets a defensible non-zero rate. static func claudePricingKey(_ raw: String) -> String? { + claudePriceResolution(raw)?.key + } + + /// The row `raw` is charged at: its own, or the family fallback's + /// (approximate). + static func claudePriceResolution(_ raw: String) -> PriceResolution? { let normalized = normalizeClaudeModel(raw) - if claudeModels[normalized] != nil { return normalized } - return familyFallback(normalized) + if claudeModels[normalized] != nil { + return PriceResolution(key: normalized, isApproximate: false) + } + return familyFallback(normalized).map { PriceResolution(key: $0, isApproximate: true) } } /// The newest priced sibling in the same Claude family, or nil. @@ -878,13 +904,43 @@ public enum CostUsageScanner { } - static func codexCostUSD(model: String, inputTokens: Int, cachedInputTokens: Int, outputTokens: Int) -> Double? { + /// Cost of a day's (or a file's) summed Codex usage of `model`. + /// + /// `pricingDate` chooses between a model's current rate and one it had + /// before (`CodexPricingTable.superseded`); nil means today's rate. + /// Standard rates only: the input is a sum of requests, so the 272K + /// long-context tier cannot be applied here + /// (`CodexPricingTable.aggregateCostUSD`). + static func codexCostUSD( + model: String, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + pricingDate: Date? = nil + ) -> Double? { guard let key = codexPricingKey(model), - let p = codexModels[key] else { return nil } - let cached = min(max(0, cachedInputTokens), max(0, inputTokens)) - let nonCached = max(0, inputTokens - cached) - let cachedRate = p.cacheReadCostPerToken ?? p.inputCostPerToken - return Double(nonCached) * p.inputCostPerToken + Double(cached) * cachedRate + Double(max(0, outputTokens)) * p.outputCostPerToken + let rates = CodexPricingTable.rates(forKey: key, at: pricingDate) else { return nil } + return CodexPricingTable.aggregateCostUSD( + rates: rates, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens + ) + } + + /// The moment whose rate a whole day of Codex usage is charged at: + /// noon of that day in `timeZone`. + /// + /// Codex usage reaches the price table already summed per local day, + /// so a repricing that happens partway through a day cannot be split. + /// Noon is the middle of the day, so the rate chosen is the one in + /// force for most of it, whatever the time zone. A repricing at 00:00 + /// UTC falls at 09:00 in Tokyo, and the Tokyo day goes to the new rate; + /// in Los Angeles it falls at 17:00 the day before, and that day keeps + /// the old one. Pricing each request at its own timestamp removes the + /// approximation once usage is priced per request. + static func codexPricingDate(forDayKey dayKey: String, in timeZone: TimeZone = .current) -> Date? { + DayKey.date(from: dayKey, hour: 12, in: timeZone) } static func claudeCostUSD(model: String, inputTokens: Int, cacheReadInputTokens: Int, cacheCreationInputTokens: Int, outputTokens: Int) -> Double? { @@ -1634,13 +1690,16 @@ public enum CostUsageScanner { private static func computeCodexCost(usage: CostUsageFileUsage?) -> Double { guard let usage else { return 0 } var total = 0.0 - for (_, models) in usage.days { + for (day, models) in usage.days { for (model, packed) in models { let input = packed[safeIdx: 0] ?? 0 let cached = packed[safeIdx: 1] ?? 0 let output = packed[safeIdx: 2] ?? 0 if input == 0 && cached == 0 && output == 0 { continue } - if let cost = Pricing.codexCostUSD(model: model, inputTokens: input, cachedInputTokens: cached, outputTokens: output) { + if let cost = Pricing.codexCostUSD( + model: model, inputTokens: input, cachedInputTokens: cached, outputTokens: output, + pricingDate: Pricing.codexPricingDate(forDayKey: day) + ) { total += cost } } diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/DataRefreshManager.swift b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/DataRefreshManager.swift index 8ec7962c8..1c00123e4 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/DataRefreshManager.swift +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/DataRefreshManager.swift @@ -2753,7 +2753,8 @@ extension AppState { // scanned the JSONL ourselves, so we know which entries had a // rate. The cloud-estimate branch below deliberately leaves // this `.unknown`; see `CostCoverage`. - coverage: CostCoverage.from(entries: scan.entries) + coverage: CostCoverage.from(entries: scan.entries), + todayCoverage: CostCoverage.from(entries: todayEntries) ) return } diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/L10n.swift b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/L10n.swift index c06bda652..c225b1faa 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/L10n.swift +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/L10n.swift @@ -1740,13 +1740,25 @@ public enum L10n { tr("cost.coverage_help", models) } public static var partial: String { tr("cost.partial") } - /// The cost-card badge. Three states — see `CostSummary.fidelity` for + // v1.56 — approximate prices. Rendered only when a local scan charged + // some tokens at a rate borrowed from a neighbouring model; see + // `CostCoverage.hasApproximatePrices`. The figures themselves carry + // `CostFormatter.approximateMark`. + public static var approximate: String { tr("cost.approximate") } + public static func approximateSummary(_ modelCount: Int) -> String { + tr("cost.approximate_summary", modelCount) + } + public static func approximateHelp(_ models: String) -> String { + tr("cost.approximate_help", models) + } + /// The cost-card badge. Four states — see `CostSummary.fidelity` for /// why "Exact" alone was not honest. public static func fidelityLabel(_ fidelity: CostCoverage.Fidelity) -> String { switch fidelity { - case .exact: return exact - case .partial: return partial - case .estimated: return estimated + case .exact: return exact + case .approximate: return approximate + case .partial: return partial + case .estimated: return estimated } } /// `cost_status` ("Exact"/"Estimated"/"Unavailable", a server token) as plain diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/ProviderConfig.swift b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/ProviderConfig.swift index 6da6ec2c1..a17b05ae5 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/ProviderConfig.swift +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/ProviderConfig.swift @@ -1051,6 +1051,10 @@ public struct CostSummary: Sendable { /// and the empty state — and for those the UI must say nothing rather than /// imply full coverage. See `CostCoverage`. public let coverage: CostCoverage + /// v1.56: the same, for today's entries alone, so "Today" carries its own + /// "≈". A borrowed rate somewhere in the last 30 days says nothing about + /// today's figure. `.unknown` wherever `coverage` is. + public let todayCoverage: CostCoverage public init( todayTotal: Double = 0, @@ -1065,7 +1069,8 @@ public struct CostSummary: Sendable { thirtyDayTokens: Int = 0, utilization: [SubscriptionUtilization] = [], costByModel: [ModelCostDetail] = [], - coverage: CostCoverage = .unknown + coverage: CostCoverage = .unknown, + todayCoverage: CostCoverage = .unknown ) { self.todayTotal = todayTotal self.todayByProvider = todayByProvider @@ -1080,6 +1085,7 @@ public struct CostSummary: Sendable { self.utilization = utilization self.costByModel = costByModel self.coverage = coverage + self.todayCoverage = todayCoverage } /// v1.51 — what the badge above the cost card should say. @@ -1093,9 +1099,13 @@ public struct CostSummary: Sendable { /// Lives here rather than in the view so it can be tested. Two views render /// this badge (macOS `OverviewTab`, iOS `iOSOverviewTab`) and neither target /// has a test bundle. + /// + /// v1.56: a fourth state. A scan with no unpriced tokens but some priced at + /// a borrowed rate said "Exact" above figures now marked "≈". public var fidelity: CostCoverage.Fidelity { guard isPrecise else { return .estimated } - return coverage.shouldDisclose ? .partial : .exact + if coverage.shouldDisclose { return .partial } + return coverage.hasApproximatePrices ? .approximate : .exact } } diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/en.lproj/Localizable.strings b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/en.lproj/Localizable.strings index 643f08e4f..bc157c8c0 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/en.lproj/Localizable.strings +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/en.lproj/Localizable.strings @@ -697,6 +697,11 @@ "cost.by_model" = "By Model"; "cost.coverage_summary" = "Priced %1$d%% of tokens · %2$d model(s) had no rate"; "cost.coverage_help" = "These models had no price entry, so their tokens counted as $0 toward the total above: %@"; + +/* v1.56 — approximate prices: a model with no price of its own is charged at the closest listed model's rate. The figures carry "≈" (a symbol, kept as is in every language). */ +"cost.approximate" = "Approximate"; +"cost.approximate_summary" = "≈ Partly estimated at approximate rates · %d model(s)"; +"cost.approximate_help" = "These models have no price of their own yet, so each is charged at the rate of the closest listed model. Figures marked ≈ include them: %@"; "watch.unread_count" = "%d unread"; "watch.snooze_15" = "15 minutes"; "watch.snooze_30" = "30 minutes"; diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/es.lproj/Localizable.strings b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/es.lproj/Localizable.strings index 040d5ee3b..e0c205151 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/es.lproj/Localizable.strings +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/es.lproj/Localizable.strings @@ -1116,6 +1116,11 @@ Este ajuste también activaba antes las sesiones de CLI remotas y las aprobacion "cost.coverage_summary" = "Se tarificó el %1$d%% de los tokens · %2$d modelo(s) sin tarifa"; "cost.coverage_help" = "Estos modelos no tienen precio, así que sus tokens contaron como $0 en el total anterior: %@"; +/* v1.56 — approximate prices: a model with no price of its own is charged at the closest listed model's rate. The figures carry "≈" (a symbol, kept as is in every language). */ +"cost.approximate" = "Aproximado"; +"cost.approximate_summary" = "≈ Estimado en parte con tarifas aproximadas · %d modelo(s)"; +"cost.approximate_help" = "Estos modelos aún no tienen precio propio, así que se tarifican con el del modelo más cercano de la lista. Las cifras marcadas con ≈ los incluyen: %@"; + /* v1.51 — cost-card badge third state */ "cost.partial" = "Parcial"; "cost.tokens_suffix" = "· %@ tokens de E/S"; diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/ja.lproj/Localizable.strings b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/ja.lproj/Localizable.strings index 6a7c5e654..3535013b1 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/ja.lproj/Localizable.strings +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/ja.lproj/Localizable.strings @@ -701,6 +701,11 @@ "cost.by_model" = "モデル別"; "cost.coverage_summary" = "トークンの %1$d%% を価格計算 · %2$d 個のモデルに単価がありません"; "cost.coverage_help" = "これらのモデルには単価が登録されていないため、トークンは上の合計に $0 として計上されています: %@"; + +/* v1.56 — approximate prices: a model with no price of its own is charged at the closest listed model's rate. The figures carry "≈" (a symbol, kept as is in every language). */ +"cost.approximate" = "近似"; +"cost.approximate_summary" = "≈ 一部を近似単価で推定 · %d 個のモデル"; +"cost.approximate_help" = "これらのモデルにはまだ固有の単価がないため、最も近い掲載モデルの単価で計算しています。≈ の付いた金額にはこれらが含まれます: %@"; "watch.unread_count" = "%d 件未読"; "watch.snooze_15" = "15分"; "watch.snooze_30" = "30分"; diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/ko.lproj/Localizable.strings b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/ko.lproj/Localizable.strings index 2e9b99425..8aeb0074a 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/ko.lproj/Localizable.strings +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/ko.lproj/Localizable.strings @@ -1103,6 +1103,11 @@ "cost.coverage_summary" = "토큰의 %1$d%%에 가격 적용 · %2$d개 모델에 단가 없음"; "cost.coverage_help" = "이 모델들은 단가가 없어 토큰이 위 합계에 $0으로 계산되었습니다: %@"; +/* v1.56 — approximate prices: a model with no price of its own is charged at the closest listed model's rate. The figures carry "≈" (a symbol, kept as is in every language). */ +"cost.approximate" = "근사"; +"cost.approximate_summary" = "≈ 일부 근사 단가로 추정 · %d개 모델"; +"cost.approximate_help" = "이 모델들은 아직 자체 단가가 없어 가장 가까운 등록 모델의 단가로 계산했습니다. ≈ 표시가 있는 금액에 포함됩니다: %@"; + /* v1.51 — cost-card badge third state */ "cost.partial" = "부분"; "cost.tokens_suffix" = "· %@ I/O 토큰"; diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/zh-Hans.lproj/Localizable.strings b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/zh-Hans.lproj/Localizable.strings index ec5a0cfe3..af360e53b 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/zh-Hans.lproj/Localizable.strings +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/zh-Hans.lproj/Localizable.strings @@ -701,6 +701,11 @@ "cost.by_model" = "按模型"; "cost.coverage_summary" = "已计价 %1$d%% 的 token · %2$d 个模型没有单价"; "cost.coverage_help" = "这些模型没有价格条目,其 token 在上面的合计里按 $0 计入:%@"; + +/* v1.56 — approximate prices: a model with no price of its own is charged at the closest listed model's rate. The figures carry "≈" (a symbol, kept as is in every language). */ +"cost.approximate" = "近似"; +"cost.approximate_summary" = "≈ 部分按近似价估算 · %d 个模型"; +"cost.approximate_help" = "这些模型还没有自己的价格,按最接近的已列出模型的单价计算。标有 ≈ 的金额包含它们:%@"; "watch.unread_count" = "%d 条未读"; "watch.snooze_15" = "15 分钟"; "watch.snooze_30" = "30 分钟"; diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/zh-Hant.lproj/Localizable.strings b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/zh-Hant.lproj/Localizable.strings index 0f0e4e32e..f9c2e4d51 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/zh-Hant.lproj/Localizable.strings +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/zh-Hant.lproj/Localizable.strings @@ -701,6 +701,11 @@ "cost.by_model" = "依模型"; "cost.coverage_summary" = "已計價 %1$d%% 的 token · %2$d 個模型沒有單價"; "cost.coverage_help" = "這些模型沒有價格資料,其 token 在上方合計中以 $0 計入:%@"; + +/* v1.56 — approximate prices: a model with no price of its own is charged at the closest listed model's rate. The figures carry "≈" (a symbol, kept as is in every language). */ +"cost.approximate" = "近似"; +"cost.approximate_summary" = "≈ 部分依近似價預估 · %d 個模型"; +"cost.approximate_help" = "這些模型還沒有自己的價格,依最接近的已列出模型的單價計算。標有 ≈ 的金額包含它們:%@"; "watch.unread_count" = "%d 則未讀"; "watch.snooze_15" = "15 分鐘"; "watch.snooze_30" = "30 分鐘"; diff --git a/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/ApproximatePriceCoverageTests.swift b/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/ApproximatePriceCoverageTests.swift new file mode 100644 index 000000000..5164a629d --- /dev/null +++ b/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/ApproximatePriceCoverageTests.swift @@ -0,0 +1,167 @@ +import XCTest +@testable import CLIPulseCore + +/// v1.56 — a cost charged at a rate borrowed from a neighbouring model is +/// approximate, and the card says so. +/// +/// A model with no price of its own has been charged at the closest listed +/// model's rate since August (Claude since May): better than the $0 it read +/// before. But the scanner knew, at the moment it chose the rate, that the +/// rate was borrowed, and threw that away. The figure reached the card looking +/// exactly like a listed price, under a badge that said "Exact". +/// +/// Text is asserted in zh-Hans: the English fallback of a missing key reads +/// like a translation, so an English-only assertion passes with the lookup +/// broken. +final class ApproximatePriceCoverageTests: XCTestCase { + + private var savedOverride: String? + + override func setUp() { + super.setUp() + savedOverride = LocaleOverrideStore.shared.override + } + + override func tearDown() { + LocaleOverrideStore.shared.set(savedOverride) + super.tearDown() + } + + private func entry( + _ model: String, + provider: String = "Codex", + tokens: Int, + cost: Double?, + approximate: Bool = false + ) -> CostUsageScanResult.DailyEntry { + CostUsageScanResult.DailyEntry( + date: "2026-09-30", provider: provider, model: model, + inputTokens: tokens, cachedTokens: 0, outputTokens: 0, + costUSD: cost, priceIsApproximate: approximate + ) + } + + // MARK: - The class + + /// A borrowed rate goes to `approximate`, not `priced`: the load-bearing + /// assertion. If it lands in `pricedTokens`, nothing downstream can tell a + /// borrowed rate from a listed one again. + func testABorrowedRateIsApproximateNotPriced() { + let coverage = CostCoverage.from(entries: [ + entry("gpt-6-astra", tokens: 700, cost: 7), + entry("gpt-5.7", tokens: 300, cost: 1.2, approximate: true), + ]) + XCTAssertEqual(coverage.pricedTokens, 700) + XCTAssertEqual(coverage.approximateTokens, 300) + XCTAssertEqual(coverage.unpricedTokens, 0) + XCTAssertEqual(coverage.approximateModels, ["gpt-5.7"]) + XCTAssertTrue(coverage.hasApproximatePrices) + } + + /// It still carried a rate, so it does not make the "Priced N% of tokens" + /// line appear: that line is about tokens counted as $0. + func testABorrowedRateStillCountsAsHavingARate() { + let coverage = CostCoverage.from(entries: [ + entry("gpt-6-astra", tokens: 700, cost: 7), + entry("gpt-5.7", tokens: 300, cost: 1.2, approximate: true), + ]) + XCTAssertEqual(coverage.pricedPercent, 100) + XCTAssertFalse(coverage.shouldDisclose) + } + + func testApproximateModelsAreMostTokensFirstAndNamedOnce() { + let coverage = CostCoverage.from(entries: [ + entry("small", tokens: 10, cost: 0.1, approximate: true), + entry("big", tokens: 500, cost: 1, approximate: true), + entry("big", provider: "Codex", tokens: 500, cost: 1, approximate: true), + entry("claude-opus-6", provider: "Claude", tokens: 50, cost: 0.5, approximate: true), + ]) + XCTAssertEqual(coverage.approximateModels, ["big", "claude-opus-6", "small"]) + XCTAssertEqual(coverage.approximateProviders, ["Codex", "Claude"]) + } + + /// A cost of nil is unpriced, whatever the flag says: an entry cannot be + /// both $0-by-omission and approximately priced. + func testAnUnpricedEntryIsNeverApproximate() { + let e = entry("mystery", tokens: 100, cost: nil, approximate: true) + XCTAssertFalse(e.priceIsApproximate) + let coverage = CostCoverage.from(entries: [e]) + XCTAssertEqual(coverage.unpricedModels, ["mystery"]) + XCTAssertFalse(coverage.hasApproximatePrices) + } + + /// A server figure's composition is unknown: it never claims approximate + /// prices any more than it claims full coverage. + func testAServerEstimateSaysNothingAboutApproximatePrices() { + XCTAssertFalse(CostCoverage.unknown.hasApproximatePrices) + XCTAssertFalse(CostCoverage(basis: .serverEstimate, approximateTokens: 10).hasApproximatePrices) + } + + // MARK: - The badge + + func testAScanWithABorrowedRateIsNotBadgedExact() { + let summary = CostSummary(isPrecise: true, coverage: CostCoverage.from(entries: [ + entry("gpt-6-astra", tokens: 700, cost: 7), + entry("gpt-5.7", tokens: 300, cost: 1.2, approximate: true), + ])) + XCTAssertEqual(summary.fidelity, .approximate) + } + + /// $0-by-omission is the bigger error, so it wins the badge; the card still + /// shows both lines. + func testMissingPricesOutrankBorrowedOnes() { + let summary = CostSummary(isPrecise: true, coverage: CostCoverage.from(entries: [ + entry("gpt-5.7", tokens: 300, cost: 1.2, approximate: true), + entry("mystery", tokens: 300, cost: nil), + ])) + XCTAssertEqual(summary.fidelity, .partial) + XCTAssertTrue(summary.coverage.hasApproximatePrices) + } + + func testTodayHasItsOwnCoverage() { + let summary = CostSummary( + isPrecise: true, + coverage: CostCoverage.from(entries: [entry("gpt-5.7", tokens: 1, cost: 1, approximate: true)]), + todayCoverage: CostCoverage.from(entries: [entry("gpt-6-astra", tokens: 1, cost: 1)]) + ) + XCTAssertTrue(summary.coverage.hasApproximatePrices) + XCTAssertFalse(summary.todayCoverage.hasApproximatePrices, + "a borrowed rate last week says nothing about today's figure") + XCTAssertFalse(CostSummary().todayCoverage.hasApproximatePrices) + } + + // MARK: - What it looks like + + func testAnApproximateFigureIsMarked() { + XCTAssertEqual(CostFormatter.format(12.5, approximate: true), "≈" + CostFormatter.format(12.5)) + XCTAssertEqual(CostFormatter.format(12.5, approximate: false), CostFormatter.format(12.5)) + } + + func testTheTextInChinese() { + LocaleOverrideStore.shared.set("zh-Hans") + XCTAssertEqual(L10n.cost.fidelityLabel(.approximate), "近似") + XCTAssertEqual(L10n.cost.approximateSummary(2), "≈ 部分按近似价估算 · 2 个模型") + XCTAssertEqual( + L10n.cost.approximateHelp("gpt-5.7, claude-opus-6"), + "这些模型还没有自己的价格,按最接近的已列出模型的单价计算。标有 ≈ 的金额包含它们:gpt-5.7, claude-opus-6" + ) + // The other three badge states are unchanged. + XCTAssertEqual(L10n.cost.fidelityLabel(.exact), "精确") + XCTAssertEqual(L10n.cost.fidelityLabel(.partial), "部分") + XCTAssertEqual(L10n.cost.fidelityLabel(.estimated), "估算") + } + + /// Every shipped language has the three strings, the "≈" the figures carry, + /// and the model list in the help. + func testEveryLanguageCarriesTheMarkAndTheModels() { + for language in ["en", "es", "ja", "ko", "zh-Hans", "zh-Hant"] { + LocaleOverrideStore.shared.set(language) + XCTAssertFalse(L10n.cost.approximate.isEmpty, language) + XCTAssertNotEqual(L10n.cost.approximate, "cost.approximate", language) + XCTAssertTrue(L10n.cost.approximateSummary(3).hasPrefix("≈"), language) + XCTAssertTrue(L10n.cost.approximateSummary(3).contains("3"), language) + XCTAssertTrue(L10n.cost.approximateHelp("gpt-5.7").contains("gpt-5.7"), language) + XCTAssertTrue(L10n.cost.approximateHelp("gpt-5.7").contains("≈"), language) + } + } +} diff --git a/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/Claude5PricingTests.swift b/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/Claude5PricingTests.swift index 51f27d282..dd84c7728 100644 --- a/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/Claude5PricingTests.swift +++ b/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/Claude5PricingTests.swift @@ -109,8 +109,10 @@ final class Claude5PricingTests: XCTestCase { P.claudePricingKey("claude-opus-6"), "claude-opus-5", "…while still being charged at the newest priced sibling's rate" ) - XCTAssertEqual(P.normalizeCodexModel("gpt-5.6-sol"), "gpt-5.6-sol") - XCTAssertEqual(P.codexPricingKey("gpt-5.6-sol"), "gpt-5.5") + // `gpt-5.6-sol` has its own row since 1.56; `gpt-5.7` is the unknown + // model now. + XCTAssertEqual(P.normalizeCodexModel("gpt-5.7"), "gpt-5.7") + XCTAssertEqual(P.codexPricingKey("gpt-5.7"), "gpt-5.6-sol") } /// Dated and prefixed spellings still resolve, unchanged by the split. @@ -161,9 +163,12 @@ final class Claude5PricingTests: XCTestCase { XCTAssertGreaterThan(pro, base * 5, "a pro-tier model must not be charged base rates") } + /// 1.56 dropped the `gpt-5.5-mini` / `-nano` rows (OpenAI lists neither; + /// they were gpt-5.4's rates copied), so the newest listed mini and nano + /// are gpt-5.4's. func testMiniAndNanoKeepTheirOwnTier() { - XCTAssertEqual(P.codexPricingKey("gpt-5.6-mini"), "gpt-5.5-mini") - XCTAssertEqual(P.codexPricingKey("gpt-5.6-nano"), "gpt-5.5-nano") + XCTAssertEqual(P.codexPricingKey("gpt-5.6-mini"), "gpt-5.4-mini") + XCTAssertEqual(P.codexPricingKey("gpt-5.6-nano"), "gpt-5.4-nano") } /// An unrecognised suffix is base tier — where every non-suffixed Codex @@ -174,18 +179,21 @@ final class Claude5PricingTests: XCTestCase { XCTAssertEqual(P.codexVersionTier("gpt-5.4-pro")?.1, "pro") } - /// `gpt-5.5` and `gpt-5.5-codex` are both (5,5) base tier, and "highest + /// `gpt-5.5` and `gpt-5.5-codex` were both (5,5) base tier, and "highest /// version wins" alone left the winner to Swift's unspecified dictionary /// order — this test failed intermittently against the first draft, picking - /// `gpt-5.5-codex` on one run and `gpt-5.5` on the next. Their rates are - /// identical, so nothing visible moved and it would have shipped; the next - /// pair of same-version rows that disagree on price would have made it a - /// real bug that reproduces once in a while. + /// `gpt-5.5-codex` on one run and `gpt-5.5` on the next. Their rates were + /// identical, so nothing visible moved and it would have shipped. + /// + /// Since 1.56 that is no longer hypothetical: the four (5,6) base rows, + /// Sol, Terra, Luna and Cyber, charge $4, $2, $0.20 and $12.50 per 1M + /// input, so an unknown `gpt-5.7` read at hash order would change price + /// from run to run by up to 60x. /// /// Repeated because a flaky assertion that runs once proves nothing. func testFallbackTieBreakIsDeterministic() { for _ in 0..<50 { - XCTAssertEqual(P.codexPricingKey("gpt-5.6-sol"), "gpt-5.5") + XCTAssertEqual(P.codexPricingKey("gpt-5.7"), "gpt-5.6-sol") XCTAssertEqual(P.claudePricingKey("claude-opus-6"), "claude-opus-5") } } diff --git a/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/CodexPricingGpt55Tests.swift b/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/CodexPricingGpt55Tests.swift index d5faedffd..37685f592 100644 --- a/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/CodexPricingGpt55Tests.swift +++ b/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/CodexPricingGpt55Tests.swift @@ -9,20 +9,17 @@ import XCTest /// dropped via compactMap) while Windows/Linux priced them — a silent /// cross-runtime under-report on Today/Week/forecast/budget on Mac. /// -/// These tests pin the gpt-5.5 family as priced and equal to its gpt-5.4 -/// siblings (OpenAI hasn't published official billing; both tables use -/// gpt-5.4 as the best-known approximation). +/// The fix then priced the whole 5.5 family at gpt-5.4's rates, because OpenAI +/// had published none. 1.56 replaces that: `gpt-5.5` and `gpt-5.5-pro` have +/// OpenAI's published rates, and the three rows OpenAI never listed +/// (`gpt-5.5-codex`, `-mini`, `-nano`) are gone, so those names borrow a +/// neighbour's rate and are marked approximate instead of passing for listed. final class CodexPricingGpt55Tests: XCTestCase { - private let input = 1_000_000 - private let cached = 400_000 - private let output = 500_000 + private typealias P = CostUsageScanner.Pricing - private func codexCost(_ model: String) -> Double? { - CostUsageScanner.Pricing.codexCostUSD( - model: model, inputTokens: input, - cachedInputTokens: cached, outputTokens: output - ) + private func codexCost(_ model: String, input: Int = 1_000_000, cached: Int = 0, output: Int = 0) -> Double? { + P.codexCostUSD(model: model, inputTokens: input, cachedInputTokens: cached, outputTokens: output) } func testGpt55_isPricedNonZero_wasTheBug() { @@ -31,31 +28,34 @@ final class CodexPricingGpt55Tests: XCTestCase { XCTAssertGreaterThan(cost ?? 0, 0) } - func testGpt55Family_matchesGpt54Siblings() { - // Each 5.5 variant mirrors a 5.4 entry's rate card exactly. - let pairs: [(String, String)] = [ - ("gpt-5.5", "gpt-5.4"), - ("gpt-5.5-codex", "gpt-5.4"), // -codex shares the base rate - ("gpt-5.5-mini", "gpt-5.4-mini"), - ("gpt-5.5-nano", "gpt-5.4-nano"), - ("gpt-5.5-pro", "gpt-5.4-pro"), - ] - for (newer, sibling) in pairs { - let a = codexCost(newer) - let b = codexCost(sibling) - XCTAssertNotNil(a, "\(newer) must be priced") - XCTAssertNotNil(b, "\(sibling) must be priced") - XCTAssertEqual(a ?? -1, b ?? -2, accuracy: 1e-9, - "\(newer) must match \(sibling) exactly") - } + /// OpenAI's list price, per 1M tokens: $5 input, $0.50 cached, $30 output. + /// The old row charged $2.50 / $0.25 / $15, half of each. + func testGpt55_chargesOpenAIsPublishedRate() { + XCTAssertEqual(codexCost("gpt-5.5") ?? -1, 5, accuracy: 1e-9) + XCTAssertEqual(codexCost("gpt-5.5", cached: 1_000_000) ?? -1, 0.5, accuracy: 1e-9) + XCTAssertEqual(codexCost("gpt-5.5", input: 0, output: 1_000_000) ?? -1, 30, accuracy: 1e-9) + XCTAssertNotEqual(codexCost("gpt-5.5") ?? -1, codexCost("gpt-5.4") ?? -1, accuracy: 1e-9, + "gpt-5.5 is no longer gpt-5.4's rate") } func testGpt55Pro_cacheReadFallsBackToInputRate() { // -pro carries no cache-read rate (nil) → cached input bills at the - // input rate, same as gpt-5.4-pro. Just assert it's priced & > 0. - let cost = codexCost("gpt-5.5-pro") - XCTAssertNotNil(cost) - XCTAssertGreaterThan(cost ?? 0, 0) + // input rate, same as gpt-5.4-pro. + XCTAssertEqual(codexCost("gpt-5.5-pro", cached: 1_000_000) ?? -1, 30, accuracy: 1e-9) + } + + /// The unlisted names still get a non-zero rate, and say it is borrowed. + func testUnlistedGpt55VariantsBorrowARateAndSaySo() { + let expected: [(model: String, borrowedFrom: String)] = [ + ("gpt-5.5-codex", "gpt-5.5"), + ("gpt-5.5-mini", "gpt-5.4-mini"), + ("gpt-5.5-nano", "gpt-5.4-nano"), + ] + for row in expected { + XCTAssertEqual(P.codexPriceResolution(row.model), + P.PriceResolution(key: row.borrowedFrom, isApproximate: true), row.model) + XCTAssertEqual(codexCost(row.model) ?? -1, codexCost(row.borrowedFrom) ?? -2, accuracy: 1e-9, row.model) + } } } #endif diff --git a/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/CodexPricingTableTests.swift b/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/CodexPricingTableTests.swift new file mode 100644 index 000000000..8f98927ce --- /dev/null +++ b/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/CodexPricingTableTests.swift @@ -0,0 +1,356 @@ +import XCTest +@testable import CLIPulseCore + +/// v1.56 — the Codex price table, with the rates a model had before a +/// repricing. +/// +/// Until 1.56 the Mac priced `gpt-6-astra` and `gpt-5.6-sol` at gpt-5.4's +/// $2.50 / $15 (the only row they could fall back to), a quarter and five +/// eighths of OpenAI's list input price. The rows below are OpenAI's, checked +/// against https://developers.openai.com/api/docs/pricing on 2026-09-30. +/// +/// Dated rates: GPT-5.6 Sol went from $5 / $30 to $4 / $20 on 2026-08-21, and +/// Terra and Luna were cut 20% and 80% on 2026-07-30 (OpenAI changelog). A day +/// before a cut is charged the old rate. One boundary test per date. +final class CodexPricingTableTests: XCTestCase { + + private typealias T = CodexPricingTable + private let million = 1_000_000 + + private func iso(_ string: String) -> Date { + ISO8601DateFormatter().date(from: string)! + } + + /// USD for one million tokens of each kind, at `rates`. + private func perMillion(_ rates: T.Rates) -> (input: Double, cached: Double, output: Double) { + ( + T.aggregateCostUSD(rates: rates, inputTokens: million, cachedInputTokens: 0, outputTokens: 0), + T.aggregateCostUSD(rates: rates, inputTokens: million, cachedInputTokens: million, outputTokens: 0), + T.aggregateCostUSD(rates: rates, inputTokens: 0, cachedInputTokens: 0, outputTokens: million) + ) + } + + // MARK: - Today's rates + + /// OpenAI's standard-tier list prices, per 1M tokens: input, cached input, + /// output. If a rate is edited by accident, this is the test that notices. + func test_publishedRatesPerMillionTokens() { + let expected: [(model: String, input: Double, cached: Double, output: Double)] = [ + ("gpt-5.4", 2.5, 0.25, 15), + ("gpt-5.5", 5, 0.5, 30), + ("gpt-6-astra", 10, 1, 50), + ("gpt-5.6-sol", 4, 0.4, 20), + ("gpt-5.6-terra", 2, 0.2, 12), + ("gpt-5.6-luna", 0.2, 0.02, 1.2), + ("gpt-5.6-cyber", 12.5, 1.25, 75), + ("gpt-5.5-cyber", 12.5, 1.25, 75), + ("gpt-6-sol", 2, 0.2, 10), + ("gpt-6-luna", 0.1, 0.01, 0.5), + ("gpt-6.1-sol", 2, 0.1, 10), + ("gpt-5.5-pro", 30, 30, 180), // no cached rate: cached pays input + ] + for row in expected { + guard let rates = T.rates(forKey: row.model, at: nil) else { + XCTFail("\(row.model) has no row"); continue + } + let got = perMillion(rates) + XCTAssertEqual(got.input, row.input, accuracy: 1e-9, "\(row.model) input") + XCTAssertEqual(got.cached, row.cached, accuracy: 1e-9, "\(row.model) cached input") + XCTAssertEqual(got.output, row.output, accuracy: 1e-9, "\(row.model) output") + } + } + + /// OpenAI's model pages for GPT-5.6 and GPT-6: cached input 10% of input + /// (5% on gpt-6.1-sol), cache writes 1.25x, and above 272K "2x input and + /// cache rates and 1.5x output for the full request". Pins every tiered + /// row, including the dated ones, against a mistyped digit. + func test_tieredRowsFollowOpenAIsDocumentedMultipliers() { + var rows: [(String, T.Rates)] = [] + for model in ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", + "gpt-6-sol", "gpt-6-luna", "gpt-6.1-sol"] { + rows.append((model, T.current[model]!)) + } + for (model, history) in T.superseded { + for entry in history { rows.append(("\(model) before \(entry.until)", entry.rates)) } + } + XCTAssertEqual(rows.count, 10) + for (name, r) in rows { + let cachedShare = name == "gpt-6.1-sol" ? 0.05 : 0.1 + XCTAssertEqual(r.cachedInput ?? -1, r.input * cachedShare, accuracy: 1e-15, "\(name) cached") + XCTAssertEqual(r.cacheWrite ?? -1, r.input * 1.25, accuracy: 1e-15, "\(name) cache write") + XCTAssertEqual(r.longContextThreshold, 272_000, name) + XCTAssertEqual(r.inputAboveThreshold ?? -1, r.input * 2, accuracy: 1e-15, "\(name) long input") + XCTAssertEqual(r.cachedInputAboveThreshold ?? -1, (r.cachedInput ?? 0) * 2, accuracy: 1e-15, "\(name) long cached") + XCTAssertEqual(r.cacheWriteAboveThreshold ?? -1, (r.cacheWrite ?? 0) * 2, accuracy: 1e-15, "\(name) long write") + XCTAssertEqual(r.outputAboveThreshold ?? -1, r.output * 1.5, accuracy: 1e-15, "\(name) long output") + } + } + + // MARK: - Rates a model had before + + /// The instants are the ones OpenAI's changelog dates, at 00:00 UTC. A + /// digit slipped in an epoch literal would move a repricing by days and + /// every other test here would still pass against the wrong date. + func test_repricingDatesAreTheOnesOpenAIPublished() { + XCTAssertEqual(T.solRepricing, iso("2026-08-21T00:00:00Z")) + XCTAssertEqual(T.terraLunaRepricing, iso("2026-07-30T00:00:00Z")) + } + + func test_solChargesItsOldRateUntilTheSecondItWasRepriced() { + let before = perMillion(T.rates(forKey: "gpt-5.6-sol", at: T.solRepricing.addingTimeInterval(-1))!) + let at = perMillion(T.rates(forKey: "gpt-5.6-sol", at: T.solRepricing)!) + XCTAssertEqual(before.input, 5, accuracy: 1e-9) + XCTAssertEqual(before.output, 30, accuracy: 1e-9) + XCTAssertEqual(at.input, 4, accuracy: 1e-9) + XCTAssertEqual(at.output, 20, accuracy: 1e-9) + } + + func test_terraChargesItsOldRateUntilJuly30() { + let before = perMillion(T.rates(forKey: "gpt-5.6-terra", at: T.terraLunaRepricing.addingTimeInterval(-1))!) + let at = perMillion(T.rates(forKey: "gpt-5.6-terra", at: T.terraLunaRepricing)!) + XCTAssertEqual(before.input, 2.5, accuracy: 1e-9) + XCTAssertEqual(before.output, 15, accuracy: 1e-9) + XCTAssertEqual(at.input, 2, accuracy: 1e-9) + XCTAssertEqual(at.output, 12, accuracy: 1e-9) + } + + /// Luna's July cut was 80%: before it, five times today's rate. + func test_lunaChargesItsOldRateUntilJuly30() { + let before = perMillion(T.rates(forKey: "gpt-5.6-luna", at: T.terraLunaRepricing.addingTimeInterval(-1))!) + let at = perMillion(T.rates(forKey: "gpt-5.6-luna", at: T.terraLunaRepricing)!) + XCTAssertEqual(before.input, 1, accuracy: 1e-9) + XCTAssertEqual(before.output, 6, accuracy: 1e-9) + XCTAssertEqual(at.input, 0.2, accuracy: 1e-9) + XCTAssertEqual(at.output, 1.2, accuracy: 1e-9) + } + + /// No date is today's rate, and a model with no history ignores the date. + func test_noDateMeansTodaysRate() { + XCTAssertEqual(T.rates(forKey: "gpt-5.6-sol", at: nil), T.current["gpt-5.6-sol"]) + XCTAssertEqual(T.rates(forKey: "gpt-6-astra", at: iso("2026-01-01T00:00:00Z")), T.current["gpt-6-astra"]) + XCTAssertNil(T.rates(forKey: "gpt-5.6", at: nil), "an alias is not a row") + } + + // MARK: - Long context + + /// "Prompts with more than 272K input tokens are priced at 2x input and + /// cache rates and 1.5x output for the full request." 272,000 is still + /// short context; 272,001 moves every token, not only the excess. + func test_aLongRequestPaysLongContextRatesOnTheWholeRequest() { + let sol = T.current["gpt-5.6-sol"]! + let short = T.requestCostUSD(rates: sol, inputTokens: 272_000, cachedInputTokens: 0, outputTokens: 1_000) + let long = T.requestCostUSD(rates: sol, inputTokens: 272_001, cachedInputTokens: 0, outputTokens: 1_000) + XCTAssertEqual(short, 272_000 * 4e-6 + 1_000 * 2e-5, accuracy: 1e-12) + XCTAssertEqual(long, 272_001 * 8e-6 + 1_000 * 3e-5, accuracy: 1e-12) + } + + /// A day's total is many requests. Taking the tier on a sum would put every + /// busy day on long-context rates. + func test_aSumNeverTakesTheLongContextTier() { + let sol = T.current["gpt-5.6-sol"]! + let day = T.aggregateCostUSD(rates: sol, inputTokens: 50_000_000, cachedInputTokens: 0, outputTokens: 0) + XCTAssertEqual(day, 200, accuracy: 1e-9, "50M input at $4, not $8") + } + + /// Cached reads are a subset of input and cache writes a subset of the + /// rest (upstream's clamp): nothing is charged twice or invented. + func test_cachedAndCacheWriteTokensAreClampedInsideInput() { + let astra = T.current["gpt-6-astra"]! + let cost = T.requestCostUSD(rates: astra, inputTokens: 100, cachedInputTokens: 60, + cacheWriteInputTokens: 80, outputTokens: -5) + // 60 cached, 40 cache-write (clamped from 80), 0 uncached, 0 output. + XCTAssertEqual(cost, 60 * 1e-6 + 40 * 1.25e-5, accuracy: 1e-15) + } + + // MARK: - Aliases + + /// An alias names a row that exists and is not itself a row: otherwise the + /// alias would never be consulted, or would point at nothing and read $0. + func test_everyAliasPointsAtARow() { + XCTAssertFalse(T.aliases.isEmpty) + for (alias, target) in T.aliases { + XCTAssertNotNil(T.current[target], "\(alias) → \(target), which has no row") + XCTAssertNil(T.current[alias], "\(alias) is both an alias and a row") + } + } + + #if os(macOS) + + private typealias P = CostUsageScanner.Pricing + + // MARK: - Exact, alias or borrowed + + func test_aListedModelIsChargedItsOwnRate() { + for model in ["gpt-6-astra", "gpt-5.6-sol", "openai/gpt-5.6-sol", "gpt-5.3-codex-spark"] { + XCTAssertEqual(P.codexPriceResolution(model)?.isApproximate, false, model) + } + XCTAssertEqual(P.claudePriceResolution("claude-opus-5"), + P.PriceResolution(key: "claude-opus-5", isApproximate: false)) + } + + /// An alias is OpenAI billing one name as another model: an exact price, + /// and the name Codex wrote stays the name shown (upstream renames it; + /// here the stored cache key would change and split the model's history). + func test_anAliasIsAnExactPriceAndKeepsItsName() { + XCTAssertEqual(P.codexPriceResolution("gpt-5.6"), + P.PriceResolution(key: "gpt-5.6-sol", isApproximate: false)) + XCTAssertEqual(P.codexPriceResolution("gpt-daybreak-red-latest"), + P.PriceResolution(key: "gpt-5.6-cyber", isApproximate: false)) + XCTAssertEqual(P.codexPriceResolution("gpt-reserve")?.key, "gpt-5.6-luna") + XCTAssertEqual(P.normalizeCodexModel("gpt-5.6"), "gpt-5.6") + XCTAssertEqual(P.normalizeCodexModel("gpt-reserve"), "gpt-reserve") + } + + /// A model with no row of its own borrows the closest one and says so. + /// Both fallbacks, Codex's by version and Claude's by family. + func test_aBorrowedRateIsMarkedApproximate() { + XCTAssertEqual(P.codexPriceResolution("gpt-5.7"), + P.PriceResolution(key: "gpt-5.6-sol", isApproximate: true)) + XCTAssertEqual(P.codexPriceResolution("gpt-6.2"), + P.PriceResolution(key: "gpt-6.1-sol", isApproximate: true)) + XCTAssertEqual(P.claudePriceResolution("claude-opus-6"), + P.PriceResolution(key: "claude-opus-5", isApproximate: true)) + XCTAssertNil(P.codexPriceResolution("o3"), "no neighbour, no rate") + } + + // MARK: - Which day a day's usage is priced as + + /// A repricing at 00:00 UTC falls partway through a local day; the whole + /// day takes the rate in force for most of it. 00:00 UTC on 08-21 is 09:00 + /// in Tokyo (15 of Tokyo's 24 hours on 08-21 are after it) and 17:00 on + /// 08-20 in Los Angeles (17 of LA's hours on 08-20 are before it). + func test_aDayIsChargedTheRateInForceForMostOfIt() throws { + let tokyo = try XCTUnwrap(TimeZone(identifier: "Asia/Tokyo")) + let la = try XCTUnwrap(TimeZone(identifier: "America/Los_Angeles")) + func day(_ key: String, _ zone: TimeZone) throws -> Date { + try XCTUnwrap(P.codexPricingDate(forDayKey: key, in: zone)) + } + XCTAssertLessThan(try day("2026-08-20", tokyo), T.solRepricing) + XCTAssertGreaterThanOrEqual(try day("2026-08-21", tokyo), T.solRepricing) + XCTAssertLessThan(try day("2026-08-20", la), T.solRepricing) + XCTAssertGreaterThanOrEqual(try day("2026-08-21", la), T.solRepricing) + XCTAssertNil(P.codexPricingDate(forDayKey: "2026-02-30")) + } + + func test_codexCostUsesTheRateOfTheDateItIsGiven() { + let before = P.codexCostUSD(model: "gpt-5.6-sol", inputTokens: million, cachedInputTokens: 0, + outputTokens: 0, pricingDate: iso("2026-08-20T12:00:00Z")) + let after = P.codexCostUSD(model: "gpt-5.6-sol", inputTokens: million, cachedInputTokens: 0, + outputTokens: 0, pricingDate: iso("2026-08-21T12:00:00Z")) + XCTAssertEqual(before ?? -1, 5, accuracy: 1e-9) + XCTAssertEqual(after ?? -1, 4, accuracy: 1e-9) + // The alias follows its target's history. + let aliasBefore = P.codexCostUSD(model: "gpt-5.6", inputTokens: million, cachedInputTokens: 0, + outputTokens: 0, pricingDate: iso("2026-08-20T12:00:00Z")) + XCTAssertEqual(aliasBefore ?? -1, 5, accuracy: 1e-9) + } + + // MARK: - Through the scanner + + private var tmpRoot: URL! + + override func tearDownWithError() throws { + if let tmpRoot { try? FileManager.default.removeItem(at: tmpRoot) } + tmpRoot = nil + } + + /// One Codex session in the flat sessions root (no date in the name, so + /// the walk picks it up whatever the window), one request at `at`. + private func writeSession(_ id: String, model: String, at: Date, input: Int, + in root: URL) throws { + let ts = ISO8601DateFormatter().string(from: at) + let lines = [ + #"{"type":"session_meta","timestamp":"\#(ts)","payload":{"session_id":"\#(id)"}}"#, + #"{"type":"turn_context","timestamp":"\#(ts)","payload":{"model":"\#(model)"}}"#, + #"{"type":"event_msg","timestamp":"\#(ts)","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":\#(input),"cached_input_tokens":0,"output_tokens":0}}}}"#, + ] + try (lines.joined(separator: "\n") + "\n") + .write(to: root.appendingPathComponent("rollout-\(id).jsonl"), atomically: true, encoding: .utf8) + } + + /// Scans `sessions` with a window reaching back past July 2026. + private func scan(_ sessions: URL, cache: URL) -> [CostUsageScanResult.DailyEntry] { + let daysSinceJuly = Int(Date().timeIntervalSince(iso("2026-07-01T00:00:00Z")) / 86_400) + 3 + var options = CostUsageScanner.Options(codexSessionsRoot: sessions, claudeProjectsRoots: [], + cacheRoot: cache, daysToScan: max(daysSinceJuly, 30)) + options.forceRescan = true + options.refreshMinIntervalSeconds = 0 + return CostUsageScanner.scan(options: options).entries + .filter { $0.provider == "Codex" } + .sorted { $0.date < $1.date } + } + + private func makeRoots() throws -> (sessions: URL, cache: URL) { + tmpRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-pricing-\(UUID().uuidString)", isDirectory: true) + let sessions = tmpRoot.appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + return (sessions, tmpRoot.appendingPathComponent("cache", isDirectory: true)) + } + + /// End to end: the same 1M Sol tokens two days either side of the cut cost + /// $5 and $4, and Luna either side of July 30 costs $1 and $0.20. Two days + /// is past any time zone's noon, so this holds wherever it runs. + func test_aScannedDayIsChargedTheRateInForceThatDay() throws { + let (sessions, cache) = try makeRoots() + let twoDays: TimeInterval = 2 * 86_400 + try writeSession("sol-before", model: "gpt-5.6-sol", at: T.solRepricing - twoDays, input: million, in: sessions) + try writeSession("sol-after", model: "gpt-5.6-sol", at: T.solRepricing + twoDays, input: million, in: sessions) + try writeSession("luna-before", model: "gpt-5.6-luna", at: T.terraLunaRepricing - twoDays, input: million, in: sessions) + try writeSession("luna-after", model: "gpt-5.6-luna", at: T.terraLunaRepricing + twoDays, input: million, in: sessions) + + let entries = scan(sessions, cache: cache) + func cost(_ model: String) -> [Double] { + entries.filter { $0.model == model }.map { $0.costUSD ?? -1 } + } + XCTAssertEqual(entries.count, 4) + XCTAssertEqual(cost("gpt-5.6-sol").count, 2) + XCTAssertEqual(cost("gpt-5.6-sol")[0], 5, accuracy: 1e-9, "Sol before 08-21") + XCTAssertEqual(cost("gpt-5.6-sol")[1], 4, accuracy: 1e-9, "Sol after 08-21") + XCTAssertEqual(cost("gpt-5.6-luna").count, 2) + XCTAssertEqual(cost("gpt-5.6-luna")[0], 1, accuracy: 1e-9, "Luna before 07-30") + XCTAssertEqual(cost("gpt-5.6-luna")[1], 0.2, accuracy: 1e-9, "Luna after 07-30") + XCTAssertFalse(entries.contains { $0.priceIsApproximate }, "every one of these has its own row") + } + + /// End to end: a model with no row is priced at its neighbour's rate, the + /// entry says so, and so do the coverage and the badge built from it. + func test_aScannedModelWithNoRowIsPricedApproximatelyAndSaysSo() throws { + let (sessions, cache) = try makeRoots() + try writeSession("known", model: "gpt-6-astra", at: Date(), input: million, in: sessions) + try writeSession("unknown", model: "gpt-5.7", at: Date(), input: million, in: sessions) + + let entries = scan(sessions, cache: cache) + let unknown = try XCTUnwrap(entries.first { $0.model == "gpt-5.7" }, "keeps the name Codex wrote") + let known = try XCTUnwrap(entries.first { $0.model == "gpt-6-astra" }) + XCTAssertTrue(unknown.priceIsApproximate) + XCTAssertEqual(unknown.costUSD ?? -1, 4, accuracy: 1e-9, "Sol's rate today") + XCTAssertFalse(known.priceIsApproximate) + XCTAssertEqual(known.costUSD ?? -1, 10, accuracy: 1e-9) + + let coverage = CostCoverage.from(entries: entries) + XCTAssertEqual(coverage.approximateModels, ["gpt-5.7"]) + XCTAssertEqual(coverage.approximateProviders, ["Codex"]) + XCTAssertEqual(CostSummary(isPrecise: true, coverage: coverage).fidelity, .approximate) + } + + /// Codex day rows hold tokens only, `[input, cached, output]`, and are + /// priced on every read. That is why a change to this table needs no + /// `costUsageCachePricingVersion` bump. The day a Codex row starts storing + /// a cost (per-request pricing), this fails: from then on a Codex price + /// change must bump the version, or cached days keep the old price. + func test_codexCacheRowsCarryNoCost() throws { + let (sessions, cache) = try makeRoots() + try writeSession("row", model: "gpt-5.6-sol", at: Date(), input: 1_000, in: sessions) + _ = scan(sessions, cache: cache) + let saved = CostUsageCacheIO.load(provider: "codex", cacheRoot: cache) + XCTAssertFalse(saved.days.isEmpty, "the fixture must reach the cache, or this checks nothing") + for (day, models) in saved.days { + for (model, packed) in models { + XCTAssertEqual(packed.count, 3, "\(day) \(model): a Codex row now stores more than tokens") + } + } + } + + #endif +} diff --git a/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/CostCoverageTests.swift b/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/CostCoverageTests.swift index 1a38523b8..fd36a102c 100644 --- a/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/CostCoverageTests.swift +++ b/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/CostCoverageTests.swift @@ -260,11 +260,11 @@ final class CostCoverageTests: XCTestCase { "an empty scan has nothing unpriced; Partial would be a false alarm") } - /// The three states must be distinguishable, or the badge collapses back - /// into the two-state one this replaced. - func testThreeFidelityStatesAreDistinct() { - let states: Set = [.exact, .partial, .estimated] - XCTAssertEqual(states.count, 3) + /// The four states must be distinguishable, or the badge collapses back + /// into the two-state one this replaced. (`approximate` since 1.56.) + func testFourFidelityStatesAreDistinct() { + let states: Set = [.exact, .approximate, .partial, .estimated] + XCTAssertEqual(states.count, 4) } // MARK: - Rounding From 8868b5616dac3859f7c610661e3905bca5bec4ea Mon Sep 17 00:00:00 2001 From: YE Date: Wed, 30 Sep 2026 23:35:25 +0900 Subject: [PATCH 2/3] Test that a local scan's borrowed rates reach the cost card and the provider card's windows Co-Authored-By: Claude Opus 5.5 --- .../ApproximatePriceCoverageTests.swift | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/ApproximatePriceCoverageTests.swift b/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/ApproximatePriceCoverageTests.swift index 5164a629d..98f8584e6 100644 --- a/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/ApproximatePriceCoverageTests.swift +++ b/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/ApproximatePriceCoverageTests.swift @@ -165,3 +165,66 @@ final class ApproximatePriceCoverageTests: XCTestCase { } } } + +/// The wiring: a local scan reaches the cost card's coverage, today's coverage +/// and the provider card's windows through `AppState`. +@MainActor +final class ApproximatePriceWiringTests: XCTestCase { + + private func key(daysAgo: Int) -> String { + DayKey.string(from: Date().addingTimeInterval(-Double(daysAgo) * 86_400)) + } + + private func entry(_ day: String, _ model: String, provider: String = "Codex", + approximate: Bool) -> CostUsageScanResult.DailyEntry { + CostUsageScanResult.DailyEntry( + date: day, provider: provider, model: model, + inputTokens: 1_000, cachedTokens: 0, outputTokens: 0, + costUSD: 0.01, priceIsApproximate: approximate + ) + } + + /// Today listed, three days ago borrowed: the 30-day figure is "≈", today's + /// is not, and the badge says Approximate. + func testTheScanReachesTheCardsCoverage() { + let state = AppState() + state.costUsageScanResult = CostUsageScanResult(entries: [ + entry(key(daysAgo: 0), "gpt-6-astra", approximate: false), + entry(key(daysAgo: 3), "gpt-5.7", approximate: true), + ]) + state.updateCostSummary() + XCTAssertTrue(state.costSummary.isPrecise) + XCTAssertTrue(state.costSummary.coverage.hasApproximatePrices) + XCTAssertEqual(state.costSummary.coverage.approximateModels, ["gpt-5.7"]) + XCTAssertFalse(state.costSummary.todayCoverage.hasApproximatePrices, + "today's figure has no borrowed rate in it") + XCTAssertEqual(state.costSummary.fidelity, .approximate) + } + + func testTodaysBorrowedRateMarksToday() { + let state = AppState() + state.costUsageScanResult = CostUsageScanResult(entries: [ + entry(key(daysAgo: 0), "gpt-5.7", approximate: true), + ]) + state.updateCostSummary() + XCTAssertTrue(state.costSummary.todayCoverage.hasApproximatePrices) + } + + /// The provider card's Today and This Week columns, per provider. + func testTheProviderCardsWindows() { + let state = AppState() + state.costUsageScanResult = CostUsageScanResult(entries: [ + entry(key(daysAgo: 3), "gpt-5.7", approximate: true), + entry(key(daysAgo: 10), "claude-opus-6", provider: "Claude", approximate: true), + entry(key(daysAgo: 0), "claude-opus-5", provider: "Claude", approximate: false), + ]) + XCTAssertFalse(state.scanPriceIsApproximate(for: "Codex", onDate: Date())) + XCTAssertTrue(state.scanPriceIsApproximateThisWeek(for: "Codex")) + XCTAssertTrue(state.scanPriceIsApproximate(for: "Codex")) + XCTAssertFalse(state.scanPriceIsApproximate(for: "Claude", onDate: Date())) + XCTAssertFalse(state.scanPriceIsApproximateThisWeek(for: "Claude"), + "ten days ago is outside the rolling week") + state.costUsageScanResult = nil + XCTAssertFalse(state.scanPriceIsApproximate(for: "Codex")) + } +} From c660aa2eddad60868cddd666e016c29255721917 Mon Sep 17 00:00:00 2001 From: YE Date: Thu, 1 Oct 2026 02:24:14 +0900 Subject: [PATCH 3/3] =?UTF-8?q?Say=20"=E2=89=88"=20in=20plain=20words,=20m?= =?UTF-8?q?ark=20the=20figures=20that=20were=20missing=20it,=20and=20test?= =?UTF-8?q?=20the=20Claude=20side=20through=20the=20scanner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strings, all six languages: the summary line no longer reuses the Partial and Estimated badges' words ("≈ N model(s) priced at a related model's rate"), and the help no longer promises the "closest" model, a price "yet", or a charge. Korean badge: 근사치. Translator comments match. - "≈" on the Overview's top Cost Today card when it shows the local today total, and on the API-equivalent half of the subscription line. - A tooltip list cut at six models ends in "…". - By Model cost column shrinks instead of wrapping when "≈" adds a character. - A dated spelling of an alias (gpt-5.6-2026-08-01) is the alias's exact price; the stored name is unchanged. - Tests: an unpriced Claude model borrows its family's rate through the scanner and is marked; earlier rates run oldest first and belong to a model with a row today; the summary shares no word with the other badges. - The file header says why Fast pricing is not ported (the rollout JSONL has no service tier; upstream reads the CLI's trace database), a dated reminder sits by Sol's promotional rate, and an owner-machine figure is gone from a comment. Co-Authored-By: Claude Opus 5.5 --- CLI Pulse Bar/CLI Pulse Bar/OverviewTab.swift | 36 +++++--- .../Sources/CLIPulseCore/AppState.swift | 6 +- .../CLIPulseCore/CodexPricingTable.swift | 31 ++++++- .../Sources/CLIPulseCore/CostCoverage.swift | 8 ++ .../CLIPulseCore/CostUsageScanner.swift | 9 +- .../Sources/CLIPulseCore/ProviderConfig.swift | 16 ++++ .../Resources/en.lproj/Localizable.strings | 6 +- .../Resources/es.lproj/Localizable.strings | 6 +- .../Resources/ja.lproj/Localizable.strings | 6 +- .../Resources/ko.lproj/Localizable.strings | 8 +- .../zh-Hans.lproj/Localizable.strings | 6 +- .../zh-Hant.lproj/Localizable.strings | 6 +- .../ApproximatePriceCoverageTests.swift | 69 +++++++++++++-- .../CodexPricingTableTests.swift | 87 ++++++++++++++++++- 14 files changed, 255 insertions(+), 45 deletions(-) diff --git a/CLI Pulse Bar/CLI Pulse Bar/OverviewTab.swift b/CLI Pulse Bar/CLI Pulse Bar/OverviewTab.swift index 27a71e831..6cfa98a1a 100644 --- a/CLI Pulse Bar/CLI Pulse Bar/OverviewTab.swift +++ b/CLI Pulse Bar/CLI Pulse Bar/OverviewTab.swift @@ -321,7 +321,13 @@ struct OverviewTab: View { // it gets the sentence-case words, not the badge's capsule text. MetricCard( title: L10n.dashboard.costToday, - value: CostFormatter.format(dash.total_estimated_cost_today), + // v1.56: "≈" when this is the local scan's today total (see + // `completeRefresh`) and part of it was priced at a borrowed + // rate, so it matches the cost card's Today figure below. + value: CostFormatter.format( + dash.total_estimated_cost_today, + approximate: providerState.costSummary.todayFigureIsApproximate(dash.total_estimated_cost_today) + ), subtitle: L10n.cost.statusLabel(dash.cost_status), icon: "dollarsign.circle", color: .green @@ -458,8 +464,7 @@ struct OverviewTab: View { // `approximateProviders` is empty for a server figure. Text(CostFormatter.format( item.cost, - approximate: providerState.costSummary.isPrecise - && providerState.costSummary.coverage.approximateProviders.contains(item.provider) + approximate: providerState.costSummary.providerFigureIsApproximate(item.provider) )) .font(.system(size: 10, weight: .medium).monospacedDigit()) .foregroundStyle(.green) @@ -517,7 +522,13 @@ struct OverviewTab: View { .font(.system(size: 10)) .foregroundStyle(.secondary) Spacer() - Text(CostFormatter.format(item.apiEquivCost) + " / " + CostFormatter.format(item.subscriptionCost)) + // v1.56: the API-equivalent half is the same + // 30-day scan sum as the provider's row above, + // so it carries the same "≈". + Text(CostFormatter.format( + item.apiEquivCost, + approximate: providerState.costSummary.providerFigureIsApproximate(item.provider) + ) + " / " + CostFormatter.format(item.subscriptionCost)) .font(.system(size: 10, weight: .medium).monospacedDigit()) .foregroundStyle(.secondary) } @@ -598,15 +609,15 @@ struct OverviewTab: View { Spacer() } .help(L10n.cost.coverageHelp( - coverage.unpricedModels.prefix(6).joined(separator: ", ") + CostCoverage.tooltipList(coverage.unpricedModels) )) } // v1.56 — the same honesty for a borrowed rate. A model - // with no price of its own is charged at the closest listed - // model's; that keeps it out of "$0", and until now nothing - // said the rate was not its own. The figures above carry - // "≈"; this line says why, and the help names the models. + // with no price entry is priced at a related model's rate; + // that keeps it out of "$0", and until now nothing said + // the rate was not its own. The figures above carry "≈"; + // this line says why, and the help names the models. if providerState.costSummary.coverage.hasApproximatePrices { let coverage = providerState.costSummary.coverage HStack(spacing: 4) { @@ -616,7 +627,7 @@ struct OverviewTab: View { Spacer() } .help(L10n.cost.approximateHelp( - coverage.approximateModels.prefix(6).joined(separator: ", ") + CostCoverage.tooltipList(coverage.approximateModels) )) } @@ -664,6 +675,11 @@ struct OverviewTab: View { )) .font(.system(size: 9, weight: .medium).monospacedDigit()) .foregroundStyle(.green) + // "≈" makes a 5-digit figure 11 characters, + // which can be more than 55 pt holds at + // 9 pt; shrink it rather than wrap or cut it. + .lineLimit(1) + .minimumScaleFactor(0.75) .frame(width: 55, alignment: .trailing) } GeometryReader { geo in diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/AppState.swift b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/AppState.swift index f8090f4ca..0709f8c14 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/AppState.swift +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/AppState.swift @@ -402,8 +402,10 @@ public final class AppState: ObservableObject { /// model. The provider card shows that cost as "≈$…". /// /// The card's figure can be the cloud's rather than this scan's (it shows - /// the larger), but the cloud's includes what this Mac uploaded, priced - /// the same way, so the mark holds either way. + /// the larger). The cloud's includes what this Mac uploaded, priced with + /// this table, and can also include other Macs' uploads, priced with + /// whatever table their version carries. The mark says this Mac's part + /// includes a borrowed rate; it cannot vouch for the other Macs' part. public func scanPriceIsApproximate(for provider: String, onDate: Date? = nil) -> Bool { guard let scan = costUsageScanResult else { return false } let key = onDate.map { DayKey.string(from: $0) } // the scanner's Gregorian keys diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CodexPricingTable.swift b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CodexPricingTable.swift index e7afe4274..4bce5454a 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CodexPricingTable.swift +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CodexPricingTable.swift @@ -25,7 +25,12 @@ // and model before pricing it, and a per-request tier cannot be applied to // a sum; see that function. // * Not ported: models.dev lookups, the custom-pricing overlay, API Fast -// (priority) multipliers, and the pricing fingerprint. +// (priority) multipliers, and the pricing fingerprint. On Fast: the +// rollout JSONL this app reads does not carry a request's service tier. +// Upstream finds Fast turns in the Codex CLI's trace database (the +// `feedback_log_body` column of its `logs` table) and charges them 2x +// (2.5x for gpt-5.5), so on a day with Fast turns upstream's cost is +// higher than this one. // // ─── MIT License (full notice required by upstream) ─────────────── // @@ -198,6 +203,10 @@ public enum CodexPricingTable { // GPT-5.6. Sol's rate is the one in force since 2026-08-21 (see // `superseded`); OpenAI's page calls it promotional "at least through // November 21, 2026". Terra and Luna since 2026-07-30. + // TODO(2026-11-21): re-check Sol on OpenAI's pricing page. If the + // promotion ends, this row becomes the new rate and today's $4 / $20 + // moves to `superseded` with that date as `until`. The table cannot + // hold a change that has not happened yet. "gpt-5.6-sol": tiered( standard: (input: 4e-6, cached: 4e-7, write: 5e-6, output: 2e-5), longContext: (input: 8e-6, cached: 8e-7, write: 1e-5, output: 3e-5) @@ -255,6 +264,12 @@ public enum CodexPricingTable { /// Earlier rates per model, oldest first. A usage moment before `until` /// is charged `rates`; from `until` on, the next entry or `current`. + /// + /// Two rules `rates(forKey:at:)` relies on, both tested: each list is in + /// ascending `until` order (the lookup takes the first entry the moment + /// is before), and every model here has a `current` row (a model + /// without one is never resolved to this key, so its history would + /// never be read). public static let superseded: [String: [DatedRates]] = [ "gpt-5.6-sol": [DatedRates(until: solRepricing, rates: tiered( standard: (input: 5e-6, cached: 5e-7, write: 6.25e-6, output: 3e-5), @@ -285,6 +300,20 @@ public enum CodexPricingTable { "gpt-daybreak-red-latest": "gpt-5.6-cyber", ] + /// The row an alias is billed as, for the alias itself or a dated + /// spelling of it (`gpt-5.6-2026-08-01`). nil when `name` is not an + /// alias. + /// + /// The date suffix is dropped here and not in `normalizeCodexModel`, + /// which only drops it when the rest names a row. Dropping it there + /// would change the cache key the model's days are stored under. + public static func aliasTarget(_ name: String) -> String? { + if let target = aliases[name] { return target } + guard let dateSuffix = name.range(of: #"-\d{4}-\d{2}-\d{2}$"#, options: .regularExpression) + else { return nil } + return aliases[String(name[.. 0 } + /// Model names for a tooltip: the first `limit`, then ", …" when some + /// were left out, so a cut list does not read as the whole of it. The + /// line the tooltip explains counts every model. + public static func tooltipList(_ models: [String], limit: Int = 6) -> String { + let shown = models.prefix(limit).joined(separator: ", ") + return models.count > limit ? shown + ", …" : shown + } + // MARK: - Fidelity /// How much a cost figure deserves to be trusted, combining where it came diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CostUsageScanner.swift b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CostUsageScanner.swift index 7dc76eef1..4e2c53cbe 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CostUsageScanner.swift +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/CostUsageScanner.swift @@ -698,7 +698,7 @@ public enum CostUsageScanner { /// The Claude side has had a family fallback since May 2026. The Codex /// side never had one, so an unrecognised OpenAI model read $0 with no /// safety net at all — in August 2026 that was `gpt-5.6-sol` and - /// `gpt-5.6-terra`, 940M tokens on the machine this was found on. + /// `gpt-5.6-terra`. /// /// The precedent for what to do was a hand-written row: when `gpt-5.5` /// appeared with no published billing, it was priced by mirroring @@ -718,14 +718,15 @@ public enum CostUsageScanner { codexPriceResolution(raw)?.key } - /// The row `raw` is charged at: its own, then an alias's, then the - /// version fallback above (approximate). + /// The row `raw` is charged at: its own, then an alias's (a dated + /// spelling of an alias included), then the version fallback above + /// (approximate). static func codexPriceResolution(_ raw: String) -> PriceResolution? { let normalized = normalizeCodexModel(raw) if codexModels[normalized] != nil { return PriceResolution(key: normalized, isApproximate: false) } - if let target = CodexPricingTable.aliases[normalized], codexModels[target] != nil { + if let target = CodexPricingTable.aliasTarget(normalized), codexModels[target] != nil { return PriceResolution(key: target, isApproximate: false) } return codexFallbackKey(normalized).map { PriceResolution(key: $0, isApproximate: true) } diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/ProviderConfig.swift b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/ProviderConfig.swift index a17b05ae5..43ffa2405 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/ProviderConfig.swift +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/ProviderConfig.swift @@ -1107,6 +1107,22 @@ public struct CostSummary: Sendable { if coverage.shouldDisclose { return .partial } return coverage.hasApproximatePrices ? .approximate : .exact } + + /// v1.56: whether a figure for `provider` that sums this scan's days (the + /// per-provider 30-day rows, and the API-equivalent cost of a + /// subscription) carries "≈". Only a local scan can know; a server + /// estimate never does. + public func providerFigureIsApproximate(_ provider: String) -> Bool { + isPrecise && coverage.approximateProviders.contains(provider) + } + + /// v1.56: whether a "Cost Today" figure shown outside this card carries + /// "≈". The Overview's top card shows the server's today total unless + /// `completeRefresh` replaced it with `todayTotal`; only that local + /// figure is known to include a borrowed rate, so `figure` must be it. + public func todayFigureIsApproximate(_ figure: Double) -> Bool { + todayCoverage.hasApproximatePrices && figure == todayTotal + } } /// The one compact token count on every surface: "245K", "154.1K", "16.8M", diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/en.lproj/Localizable.strings b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/en.lproj/Localizable.strings index bc157c8c0..0364e658b 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/en.lproj/Localizable.strings +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/en.lproj/Localizable.strings @@ -698,10 +698,10 @@ "cost.coverage_summary" = "Priced %1$d%% of tokens · %2$d model(s) had no rate"; "cost.coverage_help" = "These models had no price entry, so their tokens counted as $0 toward the total above: %@"; -/* v1.56 — approximate prices: a model with no price of its own is charged at the closest listed model's rate. The figures carry "≈" (a symbol, kept as is in every language). */ +/* v1.56 — approximate prices: a model with no price entry is priced at the rate of a related model (same Claude family, or for Codex the same -pro/-mini/-nano tier) of the same or an earlier version. The figures carry "≈" (a symbol, kept as is in every language). Do not reuse the words of the Partial or Estimated badge here. */ "cost.approximate" = "Approximate"; -"cost.approximate_summary" = "≈ Partly estimated at approximate rates · %d model(s)"; -"cost.approximate_help" = "These models have no price of their own yet, so each is charged at the rate of the closest listed model. Figures marked ≈ include them: %@"; +"cost.approximate_summary" = "≈ %d model(s) priced at a related model's rate"; +"cost.approximate_help" = "These models have no price entry, so each is priced at the rate of a related model of the same or an earlier version. Figures marked ≈ include them: %@"; "watch.unread_count" = "%d unread"; "watch.snooze_15" = "15 minutes"; "watch.snooze_30" = "30 minutes"; diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/es.lproj/Localizable.strings b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/es.lproj/Localizable.strings index e0c205151..9060c4580 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/es.lproj/Localizable.strings +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/es.lproj/Localizable.strings @@ -1116,10 +1116,10 @@ Este ajuste también activaba antes las sesiones de CLI remotas y las aprobacion "cost.coverage_summary" = "Se tarificó el %1$d%% de los tokens · %2$d modelo(s) sin tarifa"; "cost.coverage_help" = "Estos modelos no tienen precio, así que sus tokens contaron como $0 en el total anterior: %@"; -/* v1.56 — approximate prices: a model with no price of its own is charged at the closest listed model's rate. The figures carry "≈" (a symbol, kept as is in every language). */ +/* v1.56 — approximate prices: a model with no price entry is priced at the rate of a related model (same Claude family, or for Codex the same -pro/-mini/-nano tier) of the same or an earlier version. The figures carry "≈" (a symbol, kept as is in every language). Do not reuse the words of the Partial or Estimated badge here. */ "cost.approximate" = "Aproximado"; -"cost.approximate_summary" = "≈ Estimado en parte con tarifas aproximadas · %d modelo(s)"; -"cost.approximate_help" = "Estos modelos aún no tienen precio propio, así que se tarifican con el del modelo más cercano de la lista. Las cifras marcadas con ≈ los incluyen: %@"; +"cost.approximate_summary" = "≈ %d modelo(s) con la tarifa de un modelo afín"; +"cost.approximate_help" = "Estos modelos no tienen precio registrado, así que su costo se calcula con la tarifa de un modelo afín de la misma versión o de una anterior. Las cifras marcadas con ≈ los incluyen: %@"; /* v1.51 — cost-card badge third state */ "cost.partial" = "Parcial"; diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/ja.lproj/Localizable.strings b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/ja.lproj/Localizable.strings index 3535013b1..b02811f6a 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/ja.lproj/Localizable.strings +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/ja.lproj/Localizable.strings @@ -702,10 +702,10 @@ "cost.coverage_summary" = "トークンの %1$d%% を価格計算 · %2$d 個のモデルに単価がありません"; "cost.coverage_help" = "これらのモデルには単価が登録されていないため、トークンは上の合計に $0 として計上されています: %@"; -/* v1.56 — approximate prices: a model with no price of its own is charged at the closest listed model's rate. The figures carry "≈" (a symbol, kept as is in every language). */ +/* v1.56 — approximate prices: a model with no price entry is priced at the rate of a related model (same Claude family, or for Codex the same -pro/-mini/-nano tier) of the same or an earlier version. The figures carry "≈" (a symbol, kept as is in every language). Do not reuse the words of the Partial or Estimated badge here. */ "cost.approximate" = "近似"; -"cost.approximate_summary" = "≈ 一部を近似単価で推定 · %d 個のモデル"; -"cost.approximate_help" = "これらのモデルにはまだ固有の単価がないため、最も近い掲載モデルの単価で計算しています。≈ の付いた金額にはこれらが含まれます: %@"; +"cost.approximate_summary" = "≈ %d 個のモデルに同系列モデルの単価を適用"; +"cost.approximate_help" = "これらのモデルには単価が登録されていないため、同系列で同じバージョンかそれ以前のモデルの単価で計算しています。≈ の付いた金額にはこれらのモデルの分が含まれます: %@"; "watch.unread_count" = "%d 件未読"; "watch.snooze_15" = "15分"; "watch.snooze_30" = "30分"; diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/ko.lproj/Localizable.strings b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/ko.lproj/Localizable.strings index 8aeb0074a..9e1c5d038 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/ko.lproj/Localizable.strings +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/ko.lproj/Localizable.strings @@ -1103,10 +1103,10 @@ "cost.coverage_summary" = "토큰의 %1$d%%에 가격 적용 · %2$d개 모델에 단가 없음"; "cost.coverage_help" = "이 모델들은 단가가 없어 토큰이 위 합계에 $0으로 계산되었습니다: %@"; -/* v1.56 — approximate prices: a model with no price of its own is charged at the closest listed model's rate. The figures carry "≈" (a symbol, kept as is in every language). */ -"cost.approximate" = "근사"; -"cost.approximate_summary" = "≈ 일부 근사 단가로 추정 · %d개 모델"; -"cost.approximate_help" = "이 모델들은 아직 자체 단가가 없어 가장 가까운 등록 모델의 단가로 계산했습니다. ≈ 표시가 있는 금액에 포함됩니다: %@"; +/* v1.56 — approximate prices: a model with no price entry is priced at the rate of a related model (same Claude family, or for Codex the same -pro/-mini/-nano tier) of the same or an earlier version. The figures carry "≈" (a symbol, kept as is in every language). Do not reuse the words of the Partial or Estimated badge here. */ +"cost.approximate" = "근사치"; +"cost.approximate_summary" = "≈ %d개 모델에 같은 계열 모델의 단가 적용"; +"cost.approximate_help" = "이 모델들은 등록된 단가가 없어, 같은 계열에서 버전이 같거나 이전인 모델의 단가로 계산했습니다. ≈ 표시가 붙은 금액에는 이 모델들의 비용이 포함됩니다: %@"; /* v1.51 — cost-card badge third state */ "cost.partial" = "부분"; diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/zh-Hans.lproj/Localizable.strings b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/zh-Hans.lproj/Localizable.strings index af360e53b..deb8f45b0 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/zh-Hans.lproj/Localizable.strings +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/zh-Hans.lproj/Localizable.strings @@ -702,10 +702,10 @@ "cost.coverage_summary" = "已计价 %1$d%% 的 token · %2$d 个模型没有单价"; "cost.coverage_help" = "这些模型没有价格条目,其 token 在上面的合计里按 $0 计入:%@"; -/* v1.56 — approximate prices: a model with no price of its own is charged at the closest listed model's rate. The figures carry "≈" (a symbol, kept as is in every language). */ +/* v1.56 — approximate prices: a model with no price entry is priced at the rate of a related model (same Claude family, or for Codex the same -pro/-mini/-nano tier) of the same or an earlier version. The figures carry "≈" (a symbol, kept as is in every language). Do not reuse the words of the Partial or Estimated badge here. */ "cost.approximate" = "近似"; -"cost.approximate_summary" = "≈ 部分按近似价估算 · %d 个模型"; -"cost.approximate_help" = "这些模型还没有自己的价格,按最接近的已列出模型的单价计算。标有 ≈ 的金额包含它们:%@"; +"cost.approximate_summary" = "≈ %d 个模型按同系列模型的单价计算"; +"cost.approximate_help" = "这些模型没有价格条目,因此按同系列中版本相同或更早的模型的单价计算。标有 ≈ 的金额含这些模型的费用:%@"; "watch.unread_count" = "%d 条未读"; "watch.snooze_15" = "15 分钟"; "watch.snooze_30" = "30 分钟"; diff --git a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/zh-Hant.lproj/Localizable.strings b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/zh-Hant.lproj/Localizable.strings index f9c2e4d51..507163243 100644 --- a/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/zh-Hant.lproj/Localizable.strings +++ b/CLI Pulse Bar/CLIPulseCore/Sources/CLIPulseCore/Resources/zh-Hant.lproj/Localizable.strings @@ -702,10 +702,10 @@ "cost.coverage_summary" = "已計價 %1$d%% 的 token · %2$d 個模型沒有單價"; "cost.coverage_help" = "這些模型沒有價格資料,其 token 在上方合計中以 $0 計入:%@"; -/* v1.56 — approximate prices: a model with no price of its own is charged at the closest listed model's rate. The figures carry "≈" (a symbol, kept as is in every language). */ +/* v1.56 — approximate prices: a model with no price entry is priced at the rate of a related model (same Claude family, or for Codex the same -pro/-mini/-nano tier) of the same or an earlier version. The figures carry "≈" (a symbol, kept as is in every language). Do not reuse the words of the Partial or Estimated badge here. */ "cost.approximate" = "近似"; -"cost.approximate_summary" = "≈ 部分依近似價預估 · %d 個模型"; -"cost.approximate_help" = "這些模型還沒有自己的價格,依最接近的已列出模型的單價計算。標有 ≈ 的金額包含它們:%@"; +"cost.approximate_summary" = "≈ %d 個模型依同系列模型的單價計算"; +"cost.approximate_help" = "這些模型沒有價格資料,因此依同系列中版本相同或較早的模型的單價計算。標有 ≈ 的金額含這些模型的費用:%@"; "watch.unread_count" = "%d 則未讀"; "watch.snooze_15" = "15 分鐘"; "watch.snooze_30" = "30 分鐘"; diff --git a/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/ApproximatePriceCoverageTests.swift b/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/ApproximatePriceCoverageTests.swift index 98f8584e6..5262fb10d 100644 --- a/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/ApproximatePriceCoverageTests.swift +++ b/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/ApproximatePriceCoverageTests.swift @@ -4,9 +4,8 @@ import XCTest /// v1.56 — a cost charged at a rate borrowed from a neighbouring model is /// approximate, and the card says so. /// -/// A model with no price of its own has been charged at the closest listed -/// model's rate since August (Claude since May): better than the $0 it read -/// before. But the scanner knew, at the moment it chose the rate, that the +/// A model with no price entry has been priced at a related model's rate +/// since August (Claude since May): better than the $0 it read before. But the scanner knew, at the moment it chose the rate, that the /// rate was borrowed, and threw that away. The figure reached the card looking /// exactly like a listed price, under a badge that said "Exact". /// @@ -140,10 +139,10 @@ final class ApproximatePriceCoverageTests: XCTestCase { func testTheTextInChinese() { LocaleOverrideStore.shared.set("zh-Hans") XCTAssertEqual(L10n.cost.fidelityLabel(.approximate), "近似") - XCTAssertEqual(L10n.cost.approximateSummary(2), "≈ 部分按近似价估算 · 2 个模型") + XCTAssertEqual(L10n.cost.approximateSummary(2), "≈ 2 个模型按同系列模型的单价计算") XCTAssertEqual( L10n.cost.approximateHelp("gpt-5.7, claude-opus-6"), - "这些模型还没有自己的价格,按最接近的已列出模型的单价计算。标有 ≈ 的金额包含它们:gpt-5.7, claude-opus-6" + "这些模型没有价格条目,因此按同系列中版本相同或更早的模型的单价计算。标有 ≈ 的金额含这些模型的费用:gpt-5.7, claude-opus-6" ) // The other three badge states are unchanged. XCTAssertEqual(L10n.cost.fidelityLabel(.exact), "精确") @@ -164,6 +163,66 @@ final class ApproximatePriceCoverageTests: XCTestCase { XCTAssertTrue(L10n.cost.approximateHelp("gpt-5.7").contains("≈"), language) } } + + /// The summary line explains "≈". Built from the Partial or Estimated + /// badge's words it read as the explanation of a different badge, and + /// "partly estimated" implied the rest of the card was not an estimate, + /// when all of it is one at API prices. + func testTheSummaryDoesNotBorrowTheOtherBadgesWords() { + for language in ["en", "es", "ja", "ko", "zh-Hans", "zh-Hant"] { + LocaleOverrideStore.shared.set(language) + let summary = L10n.cost.approximateSummary(3).lowercased() + for badge in [L10n.cost.partial, L10n.cost.estimated] { + XCTAssertFalse(summary.contains(badge.lowercased()), "\(language): \(badge)") + } + } + } + + /// The two tooltips name at most six models. A cut list ends in "…", so it + /// does not read as the whole of what the line above counted. + func testATooltipListSaysWhenItIsCut() { + let seven = (1...7).map { "m\($0)" } + XCTAssertEqual(CostCoverage.tooltipList(Array(seven.prefix(6))), "m1, m2, m3, m4, m5, m6") + XCTAssertEqual(CostCoverage.tooltipList(seven), "m1, m2, m3, m4, m5, m6, …") + XCTAssertEqual(CostCoverage.tooltipList([]), "") + } + + // MARK: - Figures outside the cost card + + /// A provider's 30-day figure (its row in the card, and the API-equivalent + /// half of its subscription line) is marked when that provider had a + /// borrowed rate, and only then. + func testAProvidersFigureIsMarkedOnlyForThatProvider() { + let coverage = CostCoverage.from(entries: [ + entry("claude-opus-6", provider: "Claude", tokens: 1, cost: 1, approximate: true), + entry("gpt-6-astra", tokens: 1, cost: 1), + ]) + let local = CostSummary(isPrecise: true, coverage: coverage) + XCTAssertTrue(local.providerFigureIsApproximate("Claude")) + XCTAssertFalse(local.providerFigureIsApproximate("Codex")) + XCTAssertFalse(CostSummary(isPrecise: false, coverage: coverage).providerFigureIsApproximate("Claude"), + "a server figure never claims it") + } + + /// The Overview's top "Cost Today" card shows the server's today total + /// unless the local one replaced it. Only the local figure is known to + /// include a borrowed rate, so only it is marked. + func testTheTopCardsTodayIsMarkedOnlyWhenItIsTheLocalFigure() { + let borrowedToday = CostSummary( + todayTotal: 12.5, + isPrecise: true, + todayCoverage: CostCoverage.from(entries: [entry("gpt-5.7", tokens: 1, cost: 12.5, approximate: true)]) + ) + XCTAssertTrue(borrowedToday.todayFigureIsApproximate(12.5)) + XCTAssertFalse(borrowedToday.todayFigureIsApproximate(3), "the server's figure") + let listedToday = CostSummary( + todayTotal: 12.5, + isPrecise: true, + todayCoverage: CostCoverage.from(entries: [entry("gpt-6-astra", tokens: 1, cost: 12.5)]) + ) + XCTAssertFalse(listedToday.todayFigureIsApproximate(12.5)) + XCTAssertFalse(CostSummary().todayFigureIsApproximate(0)) + } } /// The wiring: a local scan reaches the cost card's coverage, today's coverage diff --git a/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/CodexPricingTableTests.swift b/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/CodexPricingTableTests.swift index 8f98927ce..2a3ad289d 100644 --- a/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/CodexPricingTableTests.swift +++ b/CLI Pulse Bar/CLIPulseCore/Tests/CLIPulseCoreTests/CodexPricingTableTests.swift @@ -131,6 +131,27 @@ final class CodexPricingTableTests: XCTestCase { XCTAssertNil(T.rates(forKey: "gpt-5.6", at: nil), "an alias is not a row") } + /// `rates(forKey:at:)` is only ever asked about a key that has a row + /// today, so a model's history is read only if the model has one. + func test_everyModelWithEarlierRatesHasARowToday() { + XCTAssertFalse(T.superseded.isEmpty) + for model in T.superseded.keys { + XCTAssertNotNil(T.current[model], "\(model) has earlier rates and no row today") + } + } + + /// The lookup takes the first entry the moment is before, so a model's + /// earlier rates must run oldest first. Out of order, a date before the + /// older cut would be charged the newer of the two old rates. + func test_eachModelsEarlierRatesRunOldestFirst() { + for (model, history) in T.superseded { + XCTAssertFalse(history.isEmpty, model) + let untils = history.map(\.until) + XCTAssertEqual(untils, untils.sorted(), "\(model): earlier rates out of order") + XCTAssertEqual(Set(untils).count, untils.count, "\(model): two entries end at the same instant") + } + } + // MARK: - Long context /// "Prompts with more than 272K input tokens are priced at 2x input and @@ -174,6 +195,16 @@ final class CodexPricingTableTests: XCTestCase { } } + /// A dated spelling of an alias is the same alias. `normalizeCodexModel` + /// only drops a date when the rest names a row, and an alias is not one. + func test_aDatedSpellingOfAnAliasIsTheAlias() { + XCTAssertEqual(T.aliasTarget("gpt-5.6"), "gpt-5.6-sol") + XCTAssertEqual(T.aliasTarget("gpt-5.6-2026-08-01"), "gpt-5.6-sol") + XCTAssertEqual(T.aliasTarget("gpt-reserve-2026-09-15"), "gpt-5.6-luna") + XCTAssertNil(T.aliasTarget("gpt-5.6-sol"), "a row is not an alias") + XCTAssertNil(T.aliasTarget("gpt-5.6-20260801"), "only a -YYYY-MM-DD suffix is a date") + } + #if os(macOS) private typealias P = CostUsageScanner.Pricing @@ -201,6 +232,18 @@ final class CodexPricingTableTests: XCTestCase { XCTAssertEqual(P.normalizeCodexModel("gpt-reserve"), "gpt-reserve") } + /// A dated alias is the alias's exact price. Without the date dropped, + /// `gpt-5.6-2026-08-01` borrowed Sol's rate through the version fallback + /// (the right number, marked "≈") and `gpt-reserve-…` had no rate at all. + func test_aDatedAliasIsAnExactPriceAndKeepsItsName() { + XCTAssertEqual(P.codexPriceResolution("gpt-5.6-2026-08-01"), + P.PriceResolution(key: "gpt-5.6-sol", isApproximate: false)) + XCTAssertEqual(P.codexPriceResolution("gpt-reserve-2026-09-15"), + P.PriceResolution(key: "gpt-5.6-luna", isApproximate: false)) + XCTAssertEqual(P.normalizeCodexModel("gpt-5.6-2026-08-01"), "gpt-5.6-2026-08-01", + "the stored name stays the one Codex wrote") + } + /// A model with no row of its own borrows the closest one and says so. /// Both fallbacks, Codex's by version and Claude's by family. func test_aBorrowedRateIsMarkedApproximate() { @@ -247,7 +290,7 @@ final class CodexPricingTableTests: XCTestCase { // MARK: - Through the scanner - private var tmpRoot: URL! + private var tmpRoot: URL? override func tearDownWithError() throws { if let tmpRoot { try? FileManager.default.removeItem(at: tmpRoot) } @@ -281,11 +324,12 @@ final class CodexPricingTableTests: XCTestCase { } private func makeRoots() throws -> (sessions: URL, cache: URL) { - tmpRoot = FileManager.default.temporaryDirectory + let root = FileManager.default.temporaryDirectory .appendingPathComponent("codex-pricing-\(UUID().uuidString)", isDirectory: true) - let sessions = tmpRoot.appendingPathComponent("sessions", isDirectory: true) + tmpRoot = root + let sessions = root.appendingPathComponent("sessions", isDirectory: true) try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) - return (sessions, tmpRoot.appendingPathComponent("cache", isDirectory: true)) + return (sessions, root.appendingPathComponent("cache", isDirectory: true)) } /// End to end: the same 1M Sol tokens two days either side of the cut cost @@ -334,6 +378,41 @@ final class CodexPricingTableTests: XCTestCase { XCTAssertEqual(CostSummary(isPrecise: true, coverage: coverage).fidelity, .approximate) } + /// The Claude half of the same path: a model with no row borrows the + /// newest rate in its family at or below its version, and the entry the + /// scanner rebuilds from its cache says so. Without this test, + /// `entriesFromClaudeCache` could stop passing the flag and every other + /// test would stay green. + func test_aScannedClaudeModelWithNoRowIsPricedApproximatelyAndSaysSo() throws { + let (sessions, cache) = try makeRoots() + let projects = sessions.deletingLastPathComponent().appendingPathComponent("projects", isDirectory: true) + let project = projects.appendingPathComponent("-Users-stub-fixture", isDirectory: true) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + let ts = ISO8601DateFormatter().string(from: Date().addingTimeInterval(-3 * 86_400)) + func line(_ id: String, _ model: String) -> String { + #"{"type":"assistant","timestamp":"\#(ts)","requestId":"req-\#(id)","message":{"id":"msg-\#(id)","model":"\#(model)","usage":{"input_tokens":1000000,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"output_tokens":0}}}"# + } + try ([line("1", "claude-opus-6"), line("2", "claude-opus-5")].joined(separator: "\n") + "\n") + .write(to: project.appendingPathComponent("synthetic.jsonl"), atomically: true, encoding: .utf8) + + var options = CostUsageScanner.Options(codexSessionsRoot: sessions, claudeProjectsRoots: [projects], + cacheRoot: cache, daysToScan: 30) + options.forceRescan = true + options.refreshMinIntervalSeconds = 0 + let entries = CostUsageScanner.scan(options: options).entries.filter { $0.provider == "Claude" } + + let unknown = try XCTUnwrap(entries.first { $0.model == "claude-opus-6" }, "keeps the name Claude Code wrote") + let known = try XCTUnwrap(entries.first { $0.model == "claude-opus-5" }) + XCTAssertTrue(unknown.priceIsApproximate) + XCTAssertEqual(unknown.costUSD ?? -1, 5, accuracy: 1e-9, "Opus 5's $5 per 1M input") + XCTAssertFalse(known.priceIsApproximate) + XCTAssertEqual(known.costUSD ?? -1, 5, accuracy: 1e-9) + + let coverage = CostCoverage.from(entries: entries) + XCTAssertEqual(coverage.approximateModels, ["claude-opus-6"]) + XCTAssertEqual(coverage.approximateProviders, ["Claude"]) + } + /// Codex day rows hold tokens only, `[input, cached, output]`, and are /// priced on every read. That is why a change to this table needs no /// `costUsageCachePricingVersion` bump. The day a Codex row starts storing