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 @@ -18,6 +18,7 @@
- Providers: route app-owned provider HTTP calls through a shared transport seam for cleaner proxy and test support (#892). Thanks @serezha93!

### Fixed
- Codex: keep local cost history attributed to the correct model when long or oversized `turn_context` rows precede model-less token events (#1014, fixes #1013). Thanks @hhh2210!
- Codex: improve multi-account switching with quota-aware ordering, workspace grouping, persisted per-account snapshots, health labels, and auth fingerprint matching.
- Overview: expose provider chart and storage detail submenus from overview rows instead of requiring a provider-tab switch first.
- Claude: reset stuck CLI sessions after usage probe timeouts, give slow probes longer to render, and keep stale data visible across transient timeouts.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ enum CostUsageCacheIO {
private static func artifactVersion(for provider: UsageProvider) -> Int {
switch provider {
case .codex:
7
8
case .claude, .vertexai:
2
default:
Expand Down
10 changes: 6 additions & 4 deletions Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,15 @@ enum CostUsageJsonl {
func appendSegment(_ segment: Data.SubSequence) {
guard !segment.isEmpty else { return }
lineBytes += segment.count
guard !truncated else { return }
if current.count < prefixBytes {
let appendCount = min(prefixBytes - current.count, segment.count)
if appendCount > 0 {
current.append(contentsOf: segment.prefix(appendCount))
}
}
if lineBytes > maxLineBytes || lineBytes > prefixBytes {
truncated = true
current.removeAll(keepingCapacity: true)
return
}
current.append(contentsOf: segment)
}

func flushLine() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import Foundation

extension CostUsageScanner {
static func extractCodexTurnContextModel(from bytes: Data) -> String? {
guard let text = truncatedUTF8String(from: bytes) else { return nil }
let object = text[...]
guard Self.extractJSONStringField("type", from: object, atDepth: 1) == "turn_context",
let payloadText = Self.extractJSONObjectField("payload", from: object, atDepth: 1)
else { return nil }

let payloadModel = Self.extractJSONStringField("model", from: payloadText, atDepth: 1)
?? Self.extractJSONStringField("model_name", from: payloadText, atDepth: 1)
if let payloadModel { return payloadModel }

guard let infoText = Self.extractJSONObjectField("info", from: payloadText, atDepth: 1) else { return nil }
return Self.extractJSONStringField("model", from: infoText, atDepth: 1)
?? Self.extractJSONStringField("model_name", from: infoText, atDepth: 1)
}

private static func truncatedUTF8String(from bytes: Data) -> String? {
for dropCount in 0...min(4, bytes.count) {
let end = bytes.count - dropCount
if let text = String(bytes: bytes.prefix(end), encoding: .utf8) {
return text
}
}
return nil
}

private static func extractJSONStringField(
_ field: String,
from text: Substring,
atDepth targetDepth: Int) -> String?
{
self.extractJSONField(field, from: text, atDepth: targetDepth) { text, index in
guard index < text.endIndex, text[index] == "\"" else { return nil }
let value = Self.parseJSONString(in: text, index: &index)
return value?.isEmpty == true ? nil : value
}
}

private static func extractJSONObjectField(
_ field: String,
from text: Substring,
atDepth targetDepth: Int) -> Substring?
{
self.extractJSONField(field, from: text, atDepth: targetDepth) { text, index in
guard index < text.endIndex, text[index] == "{" else { return nil }
return text[index...]
}
}

private static func extractJSONField<T>(
_ field: String,
from text: Substring,
atDepth targetDepth: Int,
parseValue: (Substring, inout String.Index) -> T?) -> T?
{
var index = text.startIndex
var depth = 0

while index < text.endIndex {
let character = text[index]
if character == "{" {
depth += 1
text.formIndex(after: &index)
} else if character == "}" {
depth -= 1
text.formIndex(after: &index)
} else if character == "\"" {
var valueIndex = index
guard let key = Self.parseJSONString(in: text, index: &valueIndex) else { return nil }
defer { index = valueIndex }
guard depth == targetDepth, key == field else { continue }

Self.skipJSONWhitespace(in: text, index: &valueIndex)
guard valueIndex < text.endIndex, text[valueIndex] == ":" else { continue }

text.formIndex(after: &valueIndex)
Self.skipJSONWhitespace(in: text, index: &valueIndex)
if let value = parseValue(text, &valueIndex) {
return value
}
} else {
text.formIndex(after: &index)
}
}

return nil
}

private static func parseJSONString(in text: Substring, index: inout String.Index) -> String? {
guard index < text.endIndex, text[index] == "\"" else { return nil }
text.formIndex(after: &index)
var value = ""
var isEscaped = false
while index < text.endIndex {
let character = text[index]
if isEscaped {
value.append(character)
isEscaped = false
} else if character == "\\" {
isEscaped = true
} else if character == "\"" {
text.formIndex(after: &index)
return value
} else {
value.append(character)
}
text.formIndex(after: &index)
}

return nil
}

private static func skipJSONWhitespace(in text: Substring, index: inout String.Index) {
while index < text.endIndex, text[index].isWhitespace {
text.formIndex(after: &index)
}
}
}
10 changes: 8 additions & 2 deletions Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -769,7 +769,7 @@ enum CostUsageScanner {
}

let maxLineBytes = 256 * 1024
let prefixBytes = 32 * 1024
let prefixBytes = maxLineBytes

if startOffset == 0,
let metadata = Self.parseCodexSessionMetadata(fileURL: fileURL)
Expand All @@ -794,7 +794,13 @@ enum CostUsageScanner {
prefixBytes: prefixBytes,
onLine: { line in
guard !line.bytes.isEmpty else { return }
guard !line.wasTruncated else { return }
if line.wasTruncated {
// `turn_context` can carry very large prompts, but its model usually appears near the start.
if let model = Self.extractCodexTurnContextModel(from: line.bytes) {
currentModel = model
Comment on lines +799 to +800

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prefer event model over recovered turn_context model

When a truncated turn_context line sets currentModel, later token_count events that include their own model can still be attributed to the stale context model because parseCodexFile resolves model as currentModel ?? modelFromInfo. This became a practical regression with this change because oversized contexts now update currentModel more often; if Codex emits a differing explicit model on token_count (for example after a model switch), usage will be booked to the wrong model. Keep the recovered context as fallback only, and let explicit event-level model win.

Useful? React with 👍 / 👎.

}
return
}

guard
line.bytes.containsAscii(#""type":"event_msg""#)
Expand Down
2 changes: 1 addition & 1 deletion Tests/CodexBarTests/CostUsageCacheTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ struct CostUsageCacheTests {
let codexURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root)
let claudeURL = CostUsageCacheIO.cacheFileURL(provider: .claude, cacheRoot: root)

#expect(codexURL.lastPathComponent == "codex-v7.json")
#expect(codexURL.lastPathComponent == "codex-v8.json")
#expect(claudeURL.lastPathComponent == "claude-v2.json")
}
}
5 changes: 3 additions & 2 deletions Tests/CodexBarTests/CostUsageJsonlScannerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ struct CostUsageJsonlScannerTests {
}

@Test
func `jsonl scanner marks prefix limited lines as truncated`() throws {
func `jsonl scanner retains prefix for truncated lines`() throws {
let root = try self.makeTemporaryRoot()
defer { try? FileManager.default.removeItem(at: root) }

Expand All @@ -53,7 +53,8 @@ struct CostUsageJsonlScannerTests {
#expect(scanned.count == 2)
#expect(String(data: scanned[0].bytes, encoding: .utf8) == "ok")
#expect(scanned[0].wasTruncated == false)
#expect(scanned[1].bytes.isEmpty)
#expect(scanned[1].bytes.count == 64)
#expect(String(data: scanned[1].bytes, encoding: .utf8) == String(repeating: "a", count: 64))
#expect(scanned[1].wasTruncated == true)
}

Expand Down
Loading