Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!

Expand Down
35 changes: 33 additions & 2 deletions Scripts/ci_swift_test_by_suite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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:
Expand Down
98 changes: 91 additions & 7 deletions Scripts/test_swift_test_process_cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 8 additions & 2 deletions Sources/CodexBarCore/CostUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
31 changes: 8 additions & 23 deletions Sources/CodexBarCore/PiSessionCostScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,6 @@ enum PiSessionCostScanner {
let catalog: ModelsDevCatalog?
let cacheRoot: URL?
let pricingKey: String
let compatiblePricingKeys: Set<String>

func matches(_ key: String?) -> Bool {
guard let key else { return false }
return key == self.pricingKey || self.compatiblePricingKeys.contains(key)
}
}

private struct ScanContext {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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<String> = 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(
Expand Down
16 changes: 15 additions & 1 deletion Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Comment thread
steipete marked this conversation as resolved.
}
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: "/") {
Expand Down Expand Up @@ -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-") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ actor CostUsageStore {
parserHash: CodexParserHash.value)
static let cacheGeneration = "sqlite:\(CostUsageStore.schemaVersion)"
static let compatiblePredecessorParserHashes: Set<String> = [
"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.
Expand Down
Loading
Loading