Skip to content

Commit 671c2e1

Browse files
committed
feat: use Codex app-server rate limits
1 parent 2a0be53 commit 671c2e1

15 files changed

Lines changed: 614 additions & 103 deletions

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ let package = Package(
1313
path: "src",
1414
sources: [
1515
"App.swift",
16-
"FeatureFlags.swift",
1716
"Model.swift",
1817
"AccountIdentity.swift",
1918
"AccountSnapshotMerger.swift",
2019
"UsagePayloadParser.swift",
20+
"CodexAppServerRateLimits.swift",
2121
"CodexAuthenticatedSession.swift",
2222
"WorkspaceLabelResolver.swift",
2323
"SystemRefreshErrorPolicy.swift",

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ If Comux does not show an account, open Codex and confirm that account is signed
5555
swift run comux
5656
```
5757

58-
Short-horizon limit status is hidden by default while Codex does not enforce the 5-hour limit. To exercise short-window status and locking locally, launch Comux with `COMUX_SUPPORTS_FIVE_HOUR_LIMIT=true`.
58+
When Codex returns a 5-hour window, Comux automatically shows it in the menu bar and enables short-window locking. Accounts without one continue to show weekly usage.
5959

6060
### Build a local DMG
6161

assets/demo.png

741 Bytes
Loading

scripts/demo.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ build_dir="$(mktemp -d "${TMPDIR:-/tmp}/comux-demo-mockup.XXXXXX")"
88
trap 'rm -rf "$build_dir"' EXIT
99

1010
swiftc \
11-
src/FeatureFlags.swift \
1211
src/Model.swift \
1312
src/AccountIdentity.swift \
1413
src/AccountSnapshotMerger.swift \
1514
src/UsagePayloadParser.swift \
15+
src/CodexAppServerRateLimits.swift \
16+
src/CodexAuthenticatedSession.swift \
1617
src/WorkspaceLabelResolver.swift \
1718
src/SystemRefreshErrorPolicy.swift \
1819
src/Path.swift \

src/CodexAppServerRateLimits.swift

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import Foundation
2+
3+
struct CodexRateLimitsSnapshot: Equatable {
4+
let usageWindows: [UsageWindow]
5+
let resetCredits: CodexResetCredits?
6+
}
7+
8+
enum CodexAppServerRateLimitsParser {
9+
static func parse(
10+
_ payload: [String: Any],
11+
now: Date = Date()
12+
) -> CodexRateLimitsSnapshot? {
13+
guard let result = payload["result"] as? [String: Any],
14+
let rateLimits = result["rateLimits"] as? [String: Any]
15+
else {
16+
return nil
17+
}
18+
19+
let usageWindows = [
20+
Self.window(id: "app-server-primary", payload: rateLimits["primary"]),
21+
Self.window(id: "app-server-secondary", payload: rateLimits["secondary"]),
22+
].compactMap { $0 }
23+
24+
return CodexRateLimitsSnapshot(
25+
usageWindows: usageWindows,
26+
resetCredits: Self.resetCredits(
27+
from: result["rateLimitResetCredits"],
28+
now: now
29+
)
30+
)
31+
}
32+
33+
private static func window(
34+
id: String,
35+
payload: Any?
36+
) -> UsageWindow? {
37+
guard let payload = payload as? [String: Any],
38+
let durationMinutes = (payload["windowDurationMins"] as? NSNumber)?.intValue,
39+
durationMinutes > 0
40+
else {
41+
return nil
42+
}
43+
44+
return UsageWindowFactory.make(
45+
id: id,
46+
durationSeconds: durationMinutes * 60,
47+
usedPercent: (payload["usedPercent"] as? NSNumber)?.doubleValue ?? 0,
48+
resetsAtEpoch: (payload["resetsAt"] as? NSNumber)?.doubleValue
49+
)
50+
}
51+
52+
private static func resetCredits(
53+
from payload: Any?,
54+
now: Date
55+
) -> CodexResetCredits? {
56+
guard let payload = payload as? [String: Any] else {
57+
return nil
58+
}
59+
60+
let rawCredits = payload["credits"] as? [[String: Any]] ?? []
61+
let availableCredits = rawCredits.filter { credit in
62+
(credit["status"] as? String) == "available"
63+
}
64+
let availableCount = (payload["availableCount"] as? NSNumber)
65+
.map { max($0.intValue, 0) }
66+
?? availableCredits.count
67+
let nextExpiry = availableCredits.compactMap { credit -> Date? in
68+
guard let epoch = (credit["expiresAt"] as? NSNumber)?.doubleValue,
69+
epoch > 0
70+
else {
71+
return nil
72+
}
73+
74+
let expiry = Date(timeIntervalSince1970: epoch)
75+
return expiry > now ? expiry : nil
76+
}.min()
77+
78+
return CodexResetCredits(
79+
availableCount: availableCount,
80+
nextExpiresAt: nextExpiry?.ISO8601Format(),
81+
updatedAt: now.ISO8601Format()
82+
)
83+
}
84+
}
85+
86+
enum CodexAppServerExecutable {
87+
static func resolve(
88+
environment: [String: String] = ProcessInfo.processInfo.environment,
89+
fileManager: FileManager = .default
90+
) -> String? {
91+
for candidate in Self.candidates(environment: environment) {
92+
if fileManager.isExecutableFile(atPath: candidate) {
93+
return candidate
94+
}
95+
}
96+
97+
return nil
98+
}
99+
100+
private static func candidates(environment: [String: String]) -> [String] {
101+
let home = NSHomeDirectory()
102+
let bundledCandidates = [
103+
"/Applications/Codex.app/Contents/Resources/codex",
104+
"/Applications/ChatGPT.app/Contents/Resources/codex",
105+
"\(home)/Applications/Codex.app/Contents/Resources/codex",
106+
"\(home)/Applications/ChatGPT.app/Contents/Resources/codex",
107+
]
108+
let pathCandidates = (environment["PATH"] ?? "")
109+
.split(separator: ":")
110+
.map { directory in
111+
URL(fileURLWithPath: String(directory), isDirectory: true)
112+
.appendingPathComponent("codex", isDirectory: false)
113+
.path
114+
}
115+
116+
var seen = Set<String>()
117+
return (bundledCandidates + pathCandidates).filter { seen.insert($0).inserted }
118+
}
119+
}
120+
121+
enum CodexAppServerRateLimitsReader {
122+
static func read(
123+
timeout: TimeInterval = 5,
124+
environment: [String: String] = ProcessInfo.processInfo.environment
125+
) async -> CodexRateLimitsSnapshot? {
126+
guard let executable = CodexAppServerExecutable.resolve(environment: environment) else {
127+
return nil
128+
}
129+
130+
return await Task.detached(priority: .utility) {
131+
Self.read(
132+
executable: executable,
133+
timeout: timeout,
134+
environment: environment
135+
)
136+
}.value
137+
}
138+
139+
static func read(
140+
executable: String,
141+
timeout: TimeInterval,
142+
environment: [String: String]
143+
) -> CodexRateLimitsSnapshot? {
144+
let process = Process()
145+
process.executableURL = URL(fileURLWithPath: executable)
146+
process.arguments = ["app-server"]
147+
process.environment = environment
148+
149+
let standardInput = Pipe()
150+
let standardOutput = Pipe()
151+
let standardError = Pipe()
152+
let responseCapture = CodexAppServerResponseCapture(
153+
input: standardInput.fileHandleForWriting
154+
)
155+
156+
process.standardInput = standardInput
157+
process.standardOutput = standardOutput
158+
process.standardError = standardError
159+
160+
responseCapture.start(reading: standardOutput.fileHandleForReading)
161+
standardError.fileHandleForReading.readabilityHandler = { handle in
162+
_ = handle.availableData
163+
}
164+
165+
do {
166+
try process.run()
167+
try responseCapture.send([
168+
"method": "initialize",
169+
"id": 1,
170+
"params": [
171+
"clientInfo": [
172+
"name": "comux",
173+
"title": "Comux",
174+
"version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev",
175+
],
176+
],
177+
])
178+
} catch {
179+
responseCapture.stop()
180+
standardError.fileHandleForReading.readabilityHandler = nil
181+
return nil
182+
}
183+
184+
let snapshot = responseCapture.wait(timeout: max(timeout, 0))
185+
responseCapture.stop()
186+
standardError.fileHandleForReading.readabilityHandler = nil
187+
try? standardInput.fileHandleForWriting.close()
188+
189+
if process.isRunning {
190+
process.terminate()
191+
}
192+
193+
return snapshot
194+
}
195+
}
196+
197+
private final class CodexAppServerResponseCapture: @unchecked Sendable {
198+
private let input: FileHandle
199+
private let lock = NSLock()
200+
private let completion = DispatchSemaphore(value: 0)
201+
private var buffer = Data()
202+
private var didRequestRateLimits = false
203+
private var didComplete = false
204+
private var snapshot: CodexRateLimitsSnapshot?
205+
206+
init(input: FileHandle) {
207+
self.input = input
208+
}
209+
210+
func start(reading output: FileHandle) {
211+
output.readabilityHandler = { [weak self] handle in
212+
self?.receive(handle.availableData)
213+
}
214+
}
215+
216+
func stop() {
217+
self.lock.lock()
218+
let shouldSignal = !self.didComplete
219+
self.didComplete = true
220+
self.lock.unlock()
221+
222+
if shouldSignal {
223+
self.completion.signal()
224+
}
225+
}
226+
227+
func send(_ payload: [String: Any]) throws {
228+
let data = try JSONSerialization.data(withJSONObject: payload)
229+
try self.input.write(contentsOf: data + Data([0x0A]))
230+
}
231+
232+
func wait(timeout: TimeInterval) -> CodexRateLimitsSnapshot? {
233+
_ = self.completion.wait(timeout: .now() + timeout)
234+
235+
self.lock.lock()
236+
defer { self.lock.unlock() }
237+
return self.snapshot
238+
}
239+
240+
private func receive(_ data: Data) {
241+
guard !data.isEmpty else {
242+
self.stop()
243+
return
244+
}
245+
246+
self.lock.lock()
247+
self.buffer.append(data)
248+
let messages = self.drainMessagesLocked()
249+
self.lock.unlock()
250+
251+
for message in messages {
252+
self.handle(message)
253+
}
254+
}
255+
256+
private func drainMessagesLocked() -> [[String: Any]] {
257+
var messages: [[String: Any]] = []
258+
259+
while let newline = self.buffer.firstIndex(of: 0x0A) {
260+
let line = self.buffer[..<newline]
261+
self.buffer.removeSubrange(...newline)
262+
263+
guard !line.isEmpty,
264+
let message = try? JSONSerialization.jsonObject(with: Data(line)) as? [String: Any]
265+
else {
266+
continue
267+
}
268+
269+
messages.append(message)
270+
}
271+
272+
return messages
273+
}
274+
275+
private func handle(_ message: [String: Any]) {
276+
let responseID = (message["id"] as? NSNumber)?.intValue
277+
278+
if responseID == 1 {
279+
self.lock.lock()
280+
let shouldRequest = !self.didRequestRateLimits && !self.didComplete
281+
self.didRequestRateLimits = true
282+
self.lock.unlock()
283+
284+
guard shouldRequest else {
285+
return
286+
}
287+
288+
try? self.send(["method": "initialized", "params": [:]])
289+
try? self.send(["method": "account/rateLimits/read", "id": 2])
290+
return
291+
}
292+
293+
guard responseID == 2 else {
294+
return
295+
}
296+
297+
let snapshot = CodexAppServerRateLimitsParser.parse(message)
298+
299+
self.lock.lock()
300+
guard !self.didComplete else {
301+
self.lock.unlock()
302+
return
303+
}
304+
self.snapshot = snapshot
305+
self.didComplete = true
306+
self.lock.unlock()
307+
self.completion.signal()
308+
}
309+
}

src/FeatureFlags.swift

Lines changed: 0 additions & 15 deletions
This file was deleted.

0 commit comments

Comments
 (0)