diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e5e6f2257..52329c44c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.56.1 — Unreleased ### Fixed +- Claude: price the documented Kimi `k3[1m]` context alias in local usage reports, including freshly downloaded prices, without changing model names or mixing provider catalogs; refresh affected Pi costs while preserving native Codex caches (investigated alongside #2374). Thanks @joeVenner! - Localization: translate missing Catalan iCloud, spend, and Plugins sidebar labels, restore the Projects translation, and align command labels and instructions with their UI roles (#3245). Thanks @pmontp19! - Keychain: apply saved disabled-access preferences before startup credential migration, including shared-defaults fallback, and keep deferred migrations retryable (investigated alongside #3249). Thanks @jaychou0642-create! diff --git a/Scripts/ci_swift_test_by_suite.py b/Scripts/ci_swift_test_by_suite.py index 8b5107c29e..9a2d466219 100755 --- a/Scripts/ci_swift_test_by_suite.py +++ b/Scripts/ci_swift_test_by_suite.py @@ -121,6 +121,8 @@ class AuditToken(ctypes.Structure): _libproc = ctypes.CDLL("/usr/lib/libproc.dylib", use_errno=True) _libproc.proc_pidinfo.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_uint64, ctypes.c_void_p, ctypes.c_int] _libproc.proc_pidinfo.restype = ctypes.c_int + _libproc.proc_listpids.argtypes = [ctypes.c_uint32, ctypes.c_uint32, ctypes.c_void_p, ctypes.c_int] + _libproc.proc_listpids.restype = ctypes.c_int _proc_signal_with_audittoken = getattr(_libproc, "proc_signal_with_audittoken", None) if _proc_signal_with_audittoken is not None: _proc_signal_with_audittoken.argtypes = [ctypes.POINTER(AuditToken), ctypes.c_int] @@ -181,12 +183,41 @@ def test_process(pid: int) -> TestProcess | None: raise OSError(errno.EIO, f"Incomplete process metadata for PID {pid}") from error +def test_process_ids() -> set[int]: + # Native enumeration avoids launching ps during every ownership/cleanup poll. + if sys.platform == "darwin": + ctypes.set_errno(0) + size = _libproc.proc_listpids(1, 0, None, 0) # PROC_ALL_PIDS; result is bytes, not a PID count. + if size <= 0 or size % ctypes.sizeof(ctypes.c_int): + raise OSError(ctypes.get_errno() or errno.EIO, "Cannot size process inventory") + capacity = size // ctypes.sizeof(ctypes.c_int) + 128 + for _ in range(4): + if capacity > (2**31 - 1) // ctypes.sizeof(ctypes.c_int): + raise OSError(errno.EOVERFLOW, "Process inventory exceeds native buffer size") + pids = (ctypes.c_int * capacity)() + ctypes.set_errno(0) + size = _libproc.proc_listpids(1, 0, pids, ctypes.sizeof(pids)) + if size <= 0 or size % ctypes.sizeof(ctypes.c_int) or size > ctypes.sizeof(pids): + raise OSError(ctypes.get_errno() or errno.EIO, "Cannot enumerate process inventory") + if size < ctypes.sizeof(pids): + return {pid for pid in pids[: size // ctypes.sizeof(ctypes.c_int)] if pid > 0} + # A full buffer may omit descendants; retry growth instead of accepting a partial inventory. + capacity *= 2 + raise OSError(errno.EAGAIN, "Process inventory kept growing during enumeration") + if sys.platform.startswith("linux"): + return { + int(path.name) for path in Path("/proc").iterdir() + if path.name.isascii() and path.name.isdecimal() and int(path.name) > 0 + } + raise RuntimeError("Swift test process containment requires macOS or Linux") + + def test_process_snapshot(required: Iterable[int] = ()) -> dict[int, TestProcess]: # Numeric metadata only; never inspect command lines or environments of peer jobs. - pids = subprocess.check_output(["ps", "-axo", "pid="], text=True, timeout=2).split() + pids = test_process_ids() required = set(required) snapshot = {} - for pid in {int(pid) for pid in pids} | required: + for pid in pids | required: try: info = test_process(pid) except OSError: diff --git a/Scripts/test_swift_test_process_cleanup.py b/Scripts/test_swift_test_process_cleanup.py index eb68d268bc..2f26817900 100644 --- a/Scripts/test_swift_test_process_cleanup.py +++ b/Scripts/test_swift_test_process_cleanup.py @@ -19,10 +19,8 @@ def running(pid: int) -> bool: - result = subprocess.run( - ["ps", "-p", str(pid), "-o", "stat="], capture_output=True, text=True, timeout=2 - ) - return bool(result.stdout.strip()) and not result.stdout.strip().startswith("Z") + info = runner.test_process(pid) + return info is not None and not info.zombie def wait_until(predicate, timeout=3): @@ -525,6 +523,83 @@ def test_wrong_generation_survives_then_matching_identity_kills_and_reaps(self): self.exercise(signal.SIGKILL) +class ProcessInventoryTests(unittest.TestCase): + def test_linux_inventory_reads_numeric_proc_entries_without_spawning(self): + entries = [Path("/proc") / name for name in ("10", "20", "0", "self", "thread-self", "-1", "12")] + with patch.object(runner.sys, "platform", "linux"), \ + patch.object(runner.Path, "iterdir", return_value=iter(entries)), \ + patch.object(runner.subprocess, "check_output", side_effect=AssertionError("must not spawn")): + self.assertEqual(runner.test_process_ids(), {10, 20}) + + def test_linux_inventory_failure_is_not_an_empty_snapshot(self): + with patch.object(runner.sys, "platform", "linux"), \ + patch.object(runner.Path, "iterdir", side_effect=PermissionError(errno.EACCES, "denied")): + with self.assertRaises(PermissionError): + runner.test_process_snapshot([10]) + + def test_snapshot_keeps_required_pids_missing_from_the_initial_inventory(self): + processes = {pid: runner.TestProcess(pid, 1, pid, (100, 0)) for pid in (10, 20)} + with patch.object(runner, "test_process_ids", return_value={20}), \ + patch.object(runner, "test_process", side_effect=processes.get): + self.assertEqual(runner.test_process_snapshot([10]), processes) + + @unittest.skipUnless(sys.platform == "darwin", "Darwin native inventory") + def test_darwin_inventory_retries_a_full_buffer_before_returning_pids(self): + sizes = [] + def enumerate_pids(kind, info, buffer, size): + self.assertEqual((kind, info), (1, 0)) + sizes.append(size) + if buffer is None: + return ctypes.sizeof(ctypes.c_int) + buffer[0], buffer[1], buffer[2], buffer[3] = 10, 20, 0, -1 + return size if len(sizes) == 2 else 4 * ctypes.sizeof(ctypes.c_int) + with patch.object(runner._libproc, "proc_listpids", side_effect=enumerate_pids): + self.assertEqual(runner.test_process_ids(), {10, 20}) + self.assertEqual(len(sizes), 3) + self.assertEqual(sizes[2], sizes[1] * 2) + + @unittest.skipUnless(sys.platform == "darwin", "Darwin native inventory") + def test_darwin_inventory_failures_preserve_errno_and_fail_closed(self): + for fail_probe in (False, True): + with self.subTest(fail_probe=fail_probe): + def enumerate_pids(_kind, _info, buffer, _size): + if buffer is None and not fail_probe: + return ctypes.sizeof(ctypes.c_int) + ctypes.set_errno(errno.EPERM) + return 0 + with patch.object(runner._libproc, "proc_listpids", side_effect=enumerate_pids): + with self.assertRaises(PermissionError): + runner.test_process_ids() + + @unittest.skipUnless(sys.platform == "darwin", "Darwin native inventory") + def test_darwin_inventory_rejects_invalid_native_byte_counts(self): + for count in (-1, 0, 3, 1024): + with self.subTest(count=count): + ctypes.set_errno(errno.EPERM) + with patch.object(runner._libproc, "proc_listpids", side_effect=[4, count]): + with self.assertRaises(OSError) as raised: + runner.test_process_ids() + self.assertEqual(raised.exception.errno, errno.EIO) + + @unittest.skipUnless(sys.platform == "darwin", "Darwin native inventory") + def test_darwin_inventory_growth_is_bounded_and_never_returns_partial_data(self): + def enumerate_pids(_kind, _info, buffer, size): + return 4 if buffer is None else size + with patch.object(runner._libproc, "proc_listpids", side_effect=enumerate_pids) as enumerate_mock: + with self.assertRaises(OSError) as raised: + runner.test_process_ids() + self.assertEqual(raised.exception.errno, errno.EAGAIN) + self.assertEqual(enumerate_mock.call_count, 5) + + @unittest.skipUnless(sys.platform == "darwin", "Darwin native inventory") + def test_darwin_inventory_rejects_native_buffer_size_overflow_before_allocation(self): + with patch.object(runner._libproc, "proc_listpids", return_value=2**31 - 4) as enumerate_mock: + with self.assertRaises(OSError) as raised: + runner.test_process_ids() + self.assertEqual(raised.exception.errno, errno.EOVERFLOW) + enumerate_mock.assert_called_once() + + class ReviewRegressionTests(unittest.TestCase): def test_recycled_sid_without_replacement_leader_is_not_adopted(self): root = runner.TestProcess(10, 1, 10, (100, 0)) @@ -557,20 +632,29 @@ def read(path): raise PermissionError(errno.EACCES, "denied") ownership = runner.TestProcessOwnership(root) with patch.object(runner.sys, "platform", "linux"), \ - patch.object(runner.subprocess, "check_output", return_value="10 20"), \ + patch.object(runner, "test_process_ids", return_value={10, 20}), \ patch.object(runner.Path, "read_bytes", autospec=True, side_effect=read): self.assertEqual(set(ownership.refresh()), {10}) ownership.known[20] = (101, 0) with self.assertRaises(PermissionError): ownership.refresh() + def test_snapshot_does_not_spawn_ps(self): + with patch.object(runner.subprocess, "check_output", side_effect=subprocess.TimeoutExpired("ps", 2)): + snapshot = runner.test_process_snapshot([os.getpid()]) + self.assertIn(os.getpid(), snapshot) + + def test_running_fixture_probe_does_not_spawn_ps(self): + with patch.object(subprocess, "run", side_effect=subprocess.TimeoutExpired("ps", 2)): + self.assertTrue(running(os.getpid())) + @unittest.skipUnless(sys.platform == "darwin", "Darwin native error regression") def test_known_unreadable_darwin_metadata_fails_snapshot_but_unrelated_peer_does_not(self): def denied(*_): ctypes.set_errno(errno.EPERM) return 0 with patch.object(runner._libproc, "proc_pidinfo", side_effect=denied), \ - patch.object(runner.subprocess, "check_output", return_value="123"): + patch.object(runner, "test_process_ids", return_value={123}): self.assertEqual(runner.test_process_snapshot(), {}) with self.assertRaises(PermissionError): runner.TestProcessOwnership(runner.TestProcess(123, 1, 123, (100, 0))).refresh() @@ -625,7 +709,7 @@ def read(path): return b"20 (child) S 10 20 20 " + b"0 " * 15 + b"101 0" process = Mock() with patch.object(runner.sys, "platform", "linux"), \ - patch.object(runner.subprocess, "check_output", return_value="10 20"), \ + patch.object(runner, "test_process_ids", return_value={10, 20}), \ patch.object(runner.Path, "read_bytes", autospec=True, side_effect=read), \ patch.object(ownership, "send") as send, \ patch.object(runner, "stop_unreaped_child") as stop: diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index c37b2e4a5e..2e45a2094d 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -756,7 +756,11 @@ public struct CostUsageFetcher: Sendable { return true } } - return false + // An earlier group may refresh the shared catalog without resolving its own alias. + let catalog = ModelsDevCache.load(now: request.now, cacheRoot: request.cacheRoot).artifact?.catalog + return request.targets.contains { + catalog?.pricing(providerID: $0.providerID, modelID: $0.modelID) != nil + } } if inBackground { @@ -1293,7 +1297,9 @@ public struct CostUsageFetcher: Sendable { var sum = 0 for t in daily.data.compactMap(\.totalTokens) { let (res, overflow) = sum.addingReportingOverflow(t) - if overflow { return nil } + if overflow { + return nil + } sum = res } return sum diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index a7741441e1..eaca0ed9c1 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "f8577be489f4c13d" + static let value = "d9a91f31d0addc15" } diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index 3f20914ce3..4df25f39b0 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -66,12 +66,6 @@ enum PiSessionCostScanner { let catalog: ModelsDevCatalog? let cacheRoot: URL? let pricingKey: String - let compatiblePricingKeys: Set - - func matches(_ key: String?) -> Bool { - guard let key else { return false } - return key == self.pricingKey || self.compatiblePricingKeys.contains(key) - } } private struct ScanContext { @@ -132,7 +126,7 @@ enum PiSessionCostScanner { let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) let pricingContext = self.pricingContext(now: now, cacheRoot: options.cacheRoot) let windowExpanded = self.requestedWindowExpandsCache(range: range, cache: cache) - let pricingChanged = !pricingContext.matches(cache.pricingKey) + let pricingChanged = cache.pricingKey != pricingContext.pricingKey let shouldRefresh = options.forceRescan || windowExpanded || pricingChanged @@ -242,7 +236,7 @@ enum PiSessionCostScanner { guard !self.requestedWindowExpandsCache(range: range, cache: cache) else { return nil } let pricingContext = self.pricingContext(now: now, cacheRoot: cacheRoot) - guard pricingContext.matches(cache.pricingKey) else { return nil } + guard cache.pricingKey == pricingContext.pricingKey else { return nil } let report = self.buildReport( provider: provider, cache: cache, @@ -258,25 +252,16 @@ enum PiSessionCostScanner { private static func pricingContext(now: Date, cacheRoot: URL?) -> ModelsDevPricingContext { let modelsDevArtifact = ModelsDevCache.load(now: now, cacheRoot: cacheRoot).artifact let customPricingFingerprint = CostUsageCustomPricing.load().fingerprint - func key(parserHash: String) -> String { - CostUsagePricingKey.codex( + return ModelsDevPricingContext( + catalog: modelsDevArtifact?.catalog, + cacheRoot: cacheRoot, + pricingKey: CostUsagePricingKey.codex( modelsDevArtifact: modelsDevArtifact, formulaVersion: Self.costFormulaVersion, - parserHash: parserHash, + parserHash: CodexParserHash.value, modelsDevProviderIDs: CostUsagePricing.codexModelsDevProviderIDs.union( Set(CostUsagePricing.claudeFirstPartyModelsDevProviderIDs)), - customPricingFingerprint: customPricingFingerprint) - } - // Reviewed scheduler/report-field and Codex read-view/storage-only transitions leave Pi pricing unchanged. - // A later parser change must invalidate normally unless separately reviewed for compatibility. - let compatiblePricingKeys: Set = CodexParserHash.value == "f8577be489f4c13d" - ? Set(["c6c46a376ba16304", "55f640e6bb0ccba4", "21f10143afe00c55"].map { key(parserHash: $0) }) - : [] - return ModelsDevPricingContext( - catalog: modelsDevArtifact?.catalog, - cacheRoot: cacheRoot, - pricingKey: key(parserHash: CodexParserHash.value), - compatiblePricingKeys: compatiblePricingKeys) + customPricingFingerprint: customPricingFingerprint)) } private static func requestedWindowExpandsCache( diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index fdaba8f0e9..1daf4ed92e 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -862,6 +862,20 @@ extension CostUsagePricing { ] static func claudeModelsDevPricingTargets(for rawModel: String) -> [(providerID: String, modelID: String)] { + var targets = self.claudeUnaliasedModelsDevPricingTargets(for: rawModel) + // Claude's documented context-window alias stays inside Kimi Code, after every exact route match. + if targets.contains(where: { + $0.providerID == "kimi-for-coding" + && $0.modelID.trimmingCharacters(in: .whitespacesAndNewlines) == "k3[1m]" + }) { + targets.append(("kimi-for-coding", "k3")) + } + return targets + } + + private static func claudeUnaliasedModelsDevPricingTargets( + for rawModel: String) -> [(providerID: String, modelID: String)] + { let trimmed = rawModel.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return [] } if let slash = trimmed.firstIndex(of: "/") { @@ -908,7 +922,7 @@ extension CostUsagePricing { if ["gemini-", "gemma-", "deep-research-", "veo-", "lyria-"].contains(where: model.hasPrefix) { return ["google"] } - if model == "kimi-for-coding" || model == "k3" || model.hasPrefix("k3-") { + if model == "kimi-for-coding" || model == "k3" || model == "k3[1m]" || model.hasPrefix("k3-") { return ["kimi-for-coding"] } if model.hasPrefix("kimi-") || model.hasPrefix("moonshot-") { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift index 05baa4cf42..6836d4bd74 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore.swift @@ -77,6 +77,7 @@ actor CostUsageStore { parserHash: CodexParserHash.value) static let cacheGeneration = "sqlite:\(CostUsageStore.schemaVersion)" static let compatiblePredecessorParserHashes: Set = [ + "f8577be489f4c13d", // Claude-only pricing aliases leave native Codex rows, cursors, and reports unchanged. "21f10143afe00c55", // Read-view retry presence leaves parsed rows and persisted scanner state unchanged. "55f640e6bb0ccba4", // Cursor's optional coverage field leaves native rows and retained reports unchanged. "c6c46a376ba16304", // 0.55.1 scheduler transition; rows and scoped retained reports are unchanged. diff --git a/Tests/CodexBarTests/CostUsageClaudeKimiAliasTests.swift b/Tests/CodexBarTests/CostUsageClaudeKimiAliasTests.swift new file mode 100644 index 0000000000..0f8e993e65 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageClaudeKimiAliasTests.swift @@ -0,0 +1,275 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CostUsageClaudeKimiAliasTests { + private static let aliases = ["k3[1m]", "kimi-coding/k3[1m]", "kimi-for-coding/k3[1m]"] + + @Test(arguments: Self.aliases) + func `documented Claude Kimi alias resolves without changing recorded identity`(model: String) async throws { + let fixture = try AliasFixture(model: model) + defer { fixture.environment.cleanup() } + let catalog = try Self.catalog(["kimi-for-coding": ["k3": Self.rates]]) + #expect(catalog.pricing(providerID: "kimi-for-coding", modelID: "k3") != nil) + #expect(ModelsDevCache.save(catalog: catalog, fetchedAt: fixture.day, cacheRoot: fixture.environment.cacheRoot)) + + let report = fixture.report() + let row = try #require(report.data.first?.modelBreakdowns?.first) + #expect(row.modelName == model) + #expect(row.totalTokens == 160) + #expect(try abs(#require(row.costUSD) - 0.000385) < 1e-12) + let snapshot = try await fixture.snapshot() + let snapshotRow = try #require(snapshot.daily.first?.modelBreakdowns?.first) + #expect(snapshotRow.modelName == model) + #expect(snapshotRow.totalTokens == 160) + #expect(try abs(#require(snapshotRow.costUSD) - 0.000385) < 1e-12) + #expect(CostUsagePricing.normalizeClaudeModel(model) == model) + } + + @Test(arguments: Self.aliases) + func `subscription catalog zero is priced while absent or null rates stay unknown`(model: String) throws { + let fixture = try AliasFixture(model: model) + defer { fixture.environment.cleanup() } + let zero = try Self.catalog(["kimi-for-coding": ["k3": ["input": 0, "output": 0]]]) + #expect(ModelsDevCache.save(catalog: zero, fetchedAt: fixture.day, cacheRoot: fixture.environment.cacheRoot)) + let row = try #require(fixture.report().data.first?.modelBreakdowns?.first) + #expect(row.totalTokens == 160) + #expect(row.costUSD == 0) + for cost in [["output": 0], ["input": NSNull(), "output": 0]] as [[String: Any]] { + #expect(try Self.cost(model, catalog: Self.catalog(["kimi-for-coding": ["k3": cost]])) == nil) + } + } + + @Test + func `alias expansion preserves exact rows and explicit provider routes`() throws { + let catalog = try Self.catalog([ + "kimi-coding": ["k3": ["input": 91, "output": 92]], + "kimi-for-coding": [ + "k3": Self.rates, + "k3[1m]": ["input": 7, "output": 11], + "k3-256k": ["input": 13, "output": 17], + ], + "openai": ["k3": ["input": 97, "output": 98], "k3[1m]": ["input": 19, "output": 23]], + ]) + for model in Self.aliases { + #expect(Self.cost(model, catalog: catalog) == 7e-6) + } + #expect(Self.cost("openai/k3[1m]", catalog: catalog) == 19e-6) + #expect(Self.cost("k3-256k", catalog: catalog) == 13e-6) + let exactRoute = try Self.catalog([ + "kimi-coding": ["k3[1m]": ["input": 29, "output": 31]], + "kimi-for-coding": ["k3[1m]": ["input": 7, "output": 11], "k3": Self.rates], + ]) + #expect(Self.cost("kimi-coding/k3[1m]", catalog: exactRoute) == 29e-6) + } + + @Test + func `alias never guesses a different vendor or context variant`() throws { + let catalog = try Self.catalog([ + "kimi-for-coding": ["k3": Self.rates], + "openai": ["k3": ["input": 97, "output": 98], "k3[1m]": ["input": 19, "output": 23]], + "moonshotai": ["k3": ["input": 41, "output": 43]], + "moonshotai-cn": ["k3": ["input": 47, "output": 53]], + ]) + #expect(Self.cost("k3[1m]", catalog: catalog) == 2e-6) + for model in ["k3[2m]", "k3-256k", "kimi-k3[1m]", "anthropic/k3[1m]", "unknown/k3[1m]"] { + #expect(Self.cost(model, catalog: catalog) == nil) + } + let nonKimi = try Self.catalog([ + "openai": ["k3[1m]": ["input": 19, "output": 23]], + "moonshot": ["k3": ["input": 41, "output": 43]], + "moonshotai": ["k3": ["input": 47, "output": 53]], + ]) + #expect(Self.cost("k3[1m]", catalog: nonKimi) == nil) + let canonicalOnly = try Self.catalog(["openai": ["k3": Self.rates]]) + #expect(Self.cost("openai/k3[1m]", catalog: canonicalOnly) == nil) + #expect(!CostUsagePricing.codexModelsDevPricingTargets(for: "kimi-coding/k3[1m]") + .contains { $0.modelID == "k3" }) + } + + @Test(arguments: Self.aliases) + func `unknown alias requests one catalog refresh and reprices persisted tokens`(model: String) async throws { + let fixture = try AliasFixture(model: model) + defer { fixture.environment.cleanup() } + let old = try Self.catalog(["kimi-for-coding": ["kimi-test-old": Self.rates]]) + #expect(ModelsDevCache.save( + catalog: old, + fetchedAt: fixture.day.addingTimeInterval(-901), + cacheRoot: fixture.environment.cacheRoot)) + let first = try #require(fixture.report().data.first?.modelBreakdowns?.first) + #expect(first.costUSD == nil) + #expect(first.totalTokens == 160) + let transport = try AliasCatalogTransport(data: Self.catalogData(["kimi-for-coding": ["k3": Self.rates]])) + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .claude, + environment: [:], + now: fixture.day, + refreshPricingInBackground: false, + includePiSessions: false, + scannerOptions: fixture.options, + modelsDevClient: ModelsDevClient(transport: transport)) + + let row = try #require(snapshot.daily.first?.modelBreakdowns?.first) + #expect(row.modelName == model) + #expect(row.totalTokens == 160) + #expect(try abs(#require(row.costUSD) - 0.000385) < 1e-12) + #expect(await transport.requestCount == 1) + } + + @Test + func `fresh unknown alias reprices on warm and cold loads without rewriting transcripts or cache`() throws { + let fixture = try AliasFixture(model: "k3[1m]", refreshMinIntervalSeconds: 3600) + defer { fixture.environment.cleanup() } + #expect(try ModelsDevCache.save( + catalog: Self.catalog([:]), fetchedAt: fixture.day, cacheRoot: fixture.environment.cacheRoot)) + let first = try #require(fixture.report().data.first?.modelBreakdowns?.first) + #expect(first.costUSD == nil) + let cacheURL = CostUsageClaudeCacheIO.cacheFileURL(provider: .claude, cacheRoot: fixture.environment.cacheRoot) + let cacheBefore = try Data(contentsOf: cacheURL) + #expect(try ModelsDevCache.save( + catalog: Self.catalog(["kimi-for-coding": ["k3": Self.rates]]), + fetchedAt: fixture.day.addingTimeInterval(1), + cacheRoot: fixture.environment.cacheRoot)) + + for cold in [false, true] { + if cold { + CostUsageScanner.evictClaudeReportMemoForTesting( + provider: .claude, + cacheRoot: fixture.environment.cacheRoot) + } + let recorder = CostUsageScanner.ClaudeScanWorkRecorder() + let report = CostUsageScanner.withClaudeScanWorkRecorderForTesting(recorder) { fixture.report() } + let row = try #require(report.data.first?.modelBreakdowns?.first) + #expect(row.modelName == "k3[1m]") + #expect(row.totalTokens == 160) + #expect(try abs(#require(row.costUSD) - 0.000385) < 1e-12) + let metrics = recorder.snapshot() + #expect(metrics.cacheDecodes == 1) + #expect(metrics.transcriptParses == 0) + #expect(metrics.cacheEncodes == 0) + #expect(metrics.repricedRows == 1) + #expect(try Data(contentsOf: cacheURL) == cacheBefore) + } + } + + @Test(arguments: [199_950, 199_951]) + func `alias uses existing cache and long context arithmetic`(input: Int) throws { + var rates = Self.rates + rates["context_over_200k"] = ["input": 4, "output": 16, "cache_read": 1, "cache_write": 6] + let catalog = try Self.catalog(["kimi-for-coding": ["k3": rates]]) + let cost = try #require(CostUsagePricing.claudeCostUSD( + model: "k3[1m]", + inputTokens: input, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 30, + cacheCreationInputTokens1h: 5, + outputTokens: 10, + modelsDevCatalog: catalog)) + let multiplier = input + 50 > 200_000 ? 2.0 : 1.0 + let expected = (Double(input) * 2 + 20 * 0.5 + 25 * 3 + 5 * 4 + 10 * 8) * multiplier / 1_000_000 + #expect(abs(cost - expected) < 1e-12) + } + + private static var rates: [String: Any] { + // Synthetic, deliberately distinct rates exercise routing and all token classes. + ["input": 2, "output": 8, "cache_read": 0.5, "cache_write": 3] + } + + private static func cost(_ model: String, catalog: ModelsDevCatalog) -> Double? { + CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: 1, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + modelsDevCatalog: catalog) + } + + private static func catalog(_ rows: [String: [String: [String: Any]]]) throws -> ModelsDevCatalog { + try JSONDecoder().decode(ModelsDevCatalog.self, from: self.catalogData(rows)) + } + + private static func catalogData(_ rows: [String: [String: [String: Any]]]) throws -> Data { + var providers = rows + providers["anthropic", default: [:]]["claude-test-pricing"] = ["input": 3, "output": 15] + providers["openai", default: [:]]["gpt-test-pricing"] = ["input": 1, "output": 4] + var payload: [String: Any] = [:] + for (providerID, models) in providers { + let rows = Dictionary(uniqueKeysWithValues: models.map { modelID, cost in + (modelID, ["id": modelID, "cost": cost] as [String: Any]) + }) + payload[providerID] = ["id": providerID, "models": rows] + } + return try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + } +} + +private struct AliasFixture { + let environment: CostUsageTestEnvironment + let day: Date + let options: CostUsageScanner.Options + + init(model: String, refreshMinIntervalSeconds: TimeInterval = 0) throws { + let environment = try CostUsageTestEnvironment() + self.environment = environment + self.day = try environment.makeLocalNoon(year: 2026, month: 8, day: 28) + var options = CostUsageScanner.Options( + codexSessionsRoot: environment.codexSessionsRoot, + claudeProjectsRoots: [environment.claudeProjectsRoot], + cacheRoot: environment.cacheRoot, + codexTraceDatabaseURL: environment.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = refreshMinIntervalSeconds + self.options = options + _ = try environment.writeClaudeProjectFile( + relativePath: "synthetic/alias.jsonl", + contents: environment.jsonl([[ + "type": "assistant", "timestamp": environment.isoString(for: self.day), + "sessionId": "synthetic-alias", "requestId": "synthetic-request", + "message": [ + "id": "synthetic-message", "model": model, + "usage": [ + "input_tokens": 100, "output_tokens": 10, + "cache_read_input_tokens": 20, "cache_creation_input_tokens": 30, + "cache_creation": ["ephemeral_1h_input_tokens": 5, "ephemeral_5m_input_tokens": 25], + ], + ], + ]])) + } + + func report() -> CostUsageDailyReport { + CostUsageScanner.loadDailyReport( + provider: .claude, since: self.day, until: self.day, now: self.day, options: self.options) + } + + func snapshot() async throws -> CostUsageTokenSnapshot { + try await CostUsageFetcher.loadTokenSnapshot( + provider: .claude, + environment: [:], + now: self.day, + allowPricingRefresh: false, + refreshPricingInBackground: false, + includePiSessions: false, + scannerOptions: self.options) + } +} + +private actor AliasCatalogTransport: ModelsDevHTTPTransport { + let catalogData: Data + private(set) var requestCount = 0 + + init(data: Data) { + self.catalogData = data + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + self.requestCount += 1 + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, statusCode: 200, httpVersion: nil, headerFields: nil)) + return (self.catalogData, response) + } +} diff --git a/Tests/CodexBarTests/CostUsageStoreTests.swift b/Tests/CodexBarTests/CostUsageStoreTests.swift index 196ec94691..aaa72c2dd8 100644 --- a/Tests/CodexBarTests/CostUsageStoreTests.swift +++ b/Tests/CodexBarTests/CostUsageStoreTests.swift @@ -1002,11 +1002,18 @@ extension CostUsageStoreTests { } extension CostUsageStoreTests { - @Test(arguments: ["cfd84d13ad7d4cfa", "c6c46a376ba16304", "55f640e6bb0ccba4", "21f10143afe00c55"]) + @Test(arguments: [ + "cfd84d13ad7d4cfa", + "c6c46a376ba16304", + "55f640e6bb0ccba4", + "21f10143afe00c55", + "f8577be489f4c13d", + ]) func `compatible predecessor parser hash adopts without rebuilding`(predecessorHash: String) async throws { let fixture = try StoreFixture() defer { fixture.remove() } #expect(CostUsageStore.compatiblePredecessorParserHashes == [ + "f8577be489f4c13d", "21f10143afe00c55", "55f640e6bb0ccba4", "c6c46a376ba16304", diff --git a/Tests/CodexBarTests/PiSessionCostCompatibilityTests.swift b/Tests/CodexBarTests/PiSessionCostCompatibilityTests.swift index ee243cbf84..563ca03b49 100644 --- a/Tests/CodexBarTests/PiSessionCostCompatibilityTests.swift +++ b/Tests/CodexBarTests/PiSessionCostCompatibilityTests.swift @@ -15,8 +15,8 @@ private final class PiSessionParseCounter: @unchecked Sendable { } struct PiSessionCostCompatibilityTests { - @Test(arguments: [false, true], ["c6c46a376ba16304", "55f640e6bb0ccba4", "21f10143afe00c55"]) - func `reviewed hash adoption preserves pi and omp parsing but still invalidates pricing`( + @Test(arguments: [false, true], ["c6c46a376ba16304", "55f640e6bb0ccba4", "21f10143afe00c55", "f8577be489f4c13d"]) + func `parser changes reprice pi and omp while current caches preserve independent invalidation`( catalogPresent: Bool, predecessorHash: String) throws { let env = try CostUsageTestEnvironment() @@ -71,18 +71,20 @@ struct PiSessionCostCompatibilityTests { until: day, now: day, cacheRoot: env.cacheRoot) - #expect(cached?.data == original.data) + #expect(cached == nil) let counter = PiSessionParseCounter() let observer: @Sendable () -> Void = { counter.increment() } try PiSessionCostScanner.$sessionParseObserverForTesting.withValue(observer) { - _ = try PiSessionCostScanner.loadDailyReportCancellable( + let repriced = try PiSessionCostScanner.loadDailyReportCancellable( provider: .codex, since: day, until: day, now: day.addingTimeInterval(1), options: options, checkCancellation: nil) - #expect(PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot).lastScanUnixMs == predecessor.lastScanUnixMs) + #expect(repriced.data == original.data) + #expect(counter.value == 2) + #expect(PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot).lastScanUnixMs > predecessor.lastScanUnixMs) options.refreshMinIntervalSeconds = 0 let refreshed = try PiSessionCostScanner.loadDailyReportCancellable( provider: .codex, @@ -92,7 +94,7 @@ struct PiSessionCostCompatibilityTests { options: options, checkCancellation: nil) #expect(refreshed.data == original.data) - #expect(counter.value == 0) + #expect(counter.value == 2) let adopted = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) #expect(adopted.pricingKey == currentKey) #expect(adopted.files.mapValues(\.parsedBytes) == predecessor.files.mapValues(\.parsedBytes)) @@ -100,11 +102,11 @@ struct PiSessionCostCompatibilityTests { #expect(adopted.daysByProvider == predecessor.daysByProvider) #expect(adopted.files.mapValues(\.contributions) == predecessor.files.mapValues(\.contributions)) for (formula, fingerprint) in [(1, "none"), (2, "changed-custom-rates")] { - var changedPricing = predecessor + var changedPricing = adopted changedPricing.pricingKey = CostUsagePricingKey.codex( modelsDevArtifact: ModelsDevCache.load(now: day, cacheRoot: env.cacheRoot).artifact, formulaVersion: formula, - parserHash: predecessorHash, + parserHash: CodexParserHash.value, modelsDevProviderIDs: CostUsagePricing.codexModelsDevProviderIDs.union( Set(CostUsagePricing.claudeFirstPartyModelsDevProviderIDs)), customPricingFingerprint: fingerprint) @@ -121,7 +123,7 @@ struct PiSessionCostCompatibilityTests { checkCancellation: nil) #expect(counter.value == parsesBefore + 2) } - var unrelated = predecessor + var unrelated = adopted unrelated.pricingKey = CostUsagePricingKey.codex( modelsDevArtifact: ModelsDevCache.load(now: day, cacheRoot: env.cacheRoot).artifact, formulaVersion: 2, @@ -132,6 +134,7 @@ struct PiSessionCostCompatibilityTests { PiSessionCostCacheIO.save(cache: unrelated, cacheRoot: env.cacheRoot) #expect(PiSessionCostScanner.loadCachedDailyReport( provider: .codex, since: day, until: day, now: day, cacheRoot: env.cacheRoot) == nil) + let parsesBeforeUnrelated = counter.value _ = try PiSessionCostScanner.loadDailyReportCancellable( provider: .codex, since: day, @@ -139,9 +142,9 @@ struct PiSessionCostCompatibilityTests { now: day.addingTimeInterval(2), options: options, checkCancellation: nil) - #expect(counter.value == 6) - // Restore the old key before changing real rates: adoption must not mask a pricing change. - PiSessionCostCacheIO.save(cache: predecessor, cacheRoot: env.cacheRoot) + #expect(counter.value == parsesBeforeUnrelated + 2) + // Change only catalog rates so this cannot pass merely because a parser key is stale. + PiSessionCostCacheIO.save(cache: adopted, cacheRoot: env.cacheRoot) #expect(try ModelsDevCache.save( catalog: Self.modelsDevCatalog(inputCostPerMillion: 8), fetchedAt: day, @@ -152,6 +155,7 @@ struct PiSessionCostCompatibilityTests { until: day, now: day, cacheRoot: env.cacheRoot) == nil) + let parsesBeforeCatalogChange = counter.value _ = try PiSessionCostScanner.loadDailyReportCancellable( provider: .codex, since: day, @@ -159,11 +163,65 @@ struct PiSessionCostCompatibilityTests { now: day.addingTimeInterval(3), options: options, checkCancellation: nil) - #expect(counter.value == 8) + #expect(counter.value == parsesBeforeCatalogChange + 2) #expect(PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot).pricingKey != currentKey) } } + @Test + func `predecessor Claude alias cache is rejected and repriced inside refresh interval`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 8, day: 28) + _ = try env.writePiSessionFile( + relativePath: "2026-08-28T12-00-00-000Z_alias.jsonl", + contents: env.jsonl([[ + "type": "message", "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", "provider": "anthropic", "model": "k3[1m]", + "usage": ["input": 100, "output": 10, "totalTokens": 110], + ], + ]])) + let options = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + ompSessionsRoot: env.root.appendingPathComponent("empty-omp"), + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 3600) + #expect(try ModelsDevCache.save( + catalog: Self.modelsDevCatalog(inputCostPerMillion: 4), fetchedAt: day, cacheRoot: env.cacheRoot)) + let unknown = PiSessionCostScanner.loadDailyReport( + provider: .claude, since: day, until: day, now: day, options: options) + #expect(try #require(unknown.data.first?.modelBreakdowns?.first).costUSD == nil) + var predecessor = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(""" + {"kimi-for-coding":{"models":{"k3":{"id":"k3","cost":{"input":2,"output":8}}}}} + """.utf8)) + #expect(ModelsDevCache.save(catalog: catalog, fetchedAt: day, cacheRoot: env.cacheRoot)) + // Reproduce the old parser's unknown row under the same catalog; only its parser fingerprint is old. + predecessor.pricingKey = CostUsagePricingKey.codex( + modelsDevArtifact: ModelsDevCache.load(now: day, cacheRoot: env.cacheRoot).artifact, + formulaVersion: 2, + parserHash: "f8577be489f4c13d", + modelsDevProviderIDs: CostUsagePricing.codexModelsDevProviderIDs.union( + Set(CostUsagePricing.claudeFirstPartyModelsDevProviderIDs)), + customPricingFingerprint: CostUsageCustomPricing.empty.fingerprint) + PiSessionCostCacheIO.save(cache: predecessor, cacheRoot: env.cacheRoot) + #expect(PiSessionCostScanner.loadCachedDailyReport( + provider: .claude, since: day, until: day, now: day, cacheRoot: env.cacheRoot) == nil) + let counter = PiSessionParseCounter() + let observer: @Sendable () -> Void = { counter.increment() } + let report = PiSessionCostScanner.$sessionParseObserverForTesting.withValue(observer) { + PiSessionCostScanner.loadDailyReport( + provider: .claude, since: day, until: day, now: day.addingTimeInterval(1), options: options) + } + let row = try #require(report.data.first?.modelBreakdowns?.first) + #expect(row.modelName == "k3[1m]") + #expect(row.totalTokens == 110) + #expect(try abs(#require(row.costUSD) - 0.00028) < 1e-12) + #expect(counter.value == 1) + #expect(PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot).pricingKey != predecessor.pricingKey) + } + private static func modelsDevCatalog(inputCostPerMillion: Double) throws -> ModelsDevCatalog { let json = """ {"openai":{"id":"openai","models":{"gpt-5.6-sol":{ diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 822d2aca45..15959f64cf 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1378,19 +1378,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 800, + line: 804, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 876, + line: 880, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 961, + line: 965, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -3554,7 +3554,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1365, + line: 1371, anchor: "if provider == .vertexai {", expectedProviderIDs: ["claude", "vertexai"], expectedReferenceCount: 2, @@ -3562,7 +3562,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1721, + line: 1727, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3618,7 +3618,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBarCore/PiSessionCostScanner.swift", - line: 236, + line: 230, anchor: "guard provider == .codex || provider == .claude else { return nil }", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3626,7 +3626,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/PiSessionCostScanner.swift", - line: 855, + line: 840, anchor: "case .codex:", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3634,7 +3634,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/PiSessionCostScanner.swift", - line: 868, + line: 853, anchor: "case .claude:", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3642,7 +3642,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/PiSessionCostScanner.swift", - line: 905, + line: 890, anchor: ".codex", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, diff --git a/docs/claude.md b/docs/claude.md index f4b8da4860..82c25fd656 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -246,7 +246,7 @@ Model-scoped weekly-window proof (synthetic data, no real accounts or credential single pi-compatible session can contribute to multiple models/days. - Matching assistant entry IDs within the same session are counted once across roots; distinct turns are retained. - Cache: - - Native + merged provider cache: `~/Library/Caches/CodexBar/cost-usage/claude-v2.json` + - Native provider cache: `~/Library/Caches/CodexBar/cost-usage/claude-v6.json` - pi-compatible session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v7.json` ## Key files diff --git a/docs/model-pricing.md b/docs/model-pricing.md index d43c110ec2..e03e18d822 100644 --- a/docs/model-pricing.md +++ b/docs/model-pricing.md @@ -30,6 +30,7 @@ Local cost scanners preserve that scope when selecting a catalog: - Recognizable bare Claude-session model families use their first-party vendor catalog, including Anthropic, OpenAI, Google, Moonshot/Kimi, MiniMax, and DeepSeek. - Other bare Claude-session IDs are priced only when exactly one selected first-party catalog matches. Ambiguous cross-vendor matches remain unpriced. - Provider-qualified Claude-session IDs stay on an approved explicit route and never fall through to another vendor. +- Claude's [documented `k3[1m]` alias](https://www.kimi.com/code/docs/en/third-party-tools/claude-code.html) resolves to `kimi-for-coding/k3` after exact-row lookup, including the existing `kimi-coding/` and `kimi-for-coding/` routes. Recorded model names stay unchanged; other context variants and paid Moonshot routes are not inferred. Catalog zero rates remain known estimates, not a claim that subscriptions or extra usage are free. - Vertex AI Claude logs: models.dev provider id `google-vertex-anthropic` ## Units