Skip to content

Commit eb2e136

Browse files
committed
Fix Qoder cookie routing gaps
1 parent ed0d45d commit eb2e136

14 files changed

Lines changed: 214 additions & 25 deletions

Sources/CodexBar/MenuCardView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1320,7 +1320,7 @@ extension UsageMenuCardView.Model {
13201320
primaryDetailLeft = detail
13211321
}
13221322
if input.provider == .warp || input.provider == .kilo || input.provider == .mimo || input.provider == .deepseek
1323-
|| input.provider == .litellm,
1323+
|| input.provider == .qoder || input.provider == .litellm,
13241324
let detail = primary.resetDescription,
13251325
!detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
13261326
{
@@ -1345,7 +1345,7 @@ extension UsageMenuCardView.Model {
13451345
primaryDetailText = detail
13461346
if input.provider == .manus { primaryResetText = nil }
13471347
}
1348-
if [.warp, .kilo, .mimo, .deepseek, .litellm].contains(input.provider), primary.resetsAt == nil {
1348+
if [.warp, .kilo, .mimo, .deepseek, .qoder, .litellm].contains(input.provider), primary.resetsAt == nil {
13491349
primaryResetText = nil
13501350
}
13511351
// Abacus: show credits as detail, compute pace on the primary monthly window

Sources/CodexBarCLI/CLIRenderer.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ enum CLIRenderer {
350350
lines: inout [String])
351351
{
352352
if provider == .warp || provider == .kilo || provider == .mistral || provider == .deepseek ||
353+
provider == .qoder ||
353354
provider == .crof
354355
{
355356
if let reset = self.resetLineForDetailBackedWindow(window: window, style: context.resetStyle, now: now) {

Sources/CodexBarCLI/CLIUsageCommand.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,11 @@ extension CodexBarCLI {
592592
{
593593
return false
594594
}
595+
if provider == .qoder,
596+
settings?.qoder?.cookieSource == .manual
597+
{
598+
return false
599+
}
595600
if provider == .ollama,
596601
sourceMode == .auto
597602
{

Sources/CodexBarCLI/TokenAccountCLI.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ struct TokenAccountCLIContext {
203203
return self.makeSnapshot(abacus: self.makeProviderCookieSettings(cookieSettings))
204204
case .mistral:
205205
return self.makeSnapshot(mistral: self.makeProviderCookieSettings(cookieSettings))
206+
case .qoder:
207+
return self.makeSnapshot(qoder: self.makeProviderCookieSettings(cookieSettings))
206208
case .stepfun:
207209
let stepfunSettings = self.cookieSettings(
208210
provider: provider,
@@ -244,6 +246,7 @@ struct TokenAccountCLIContext {
244246
mimo: ProviderSettingsSnapshot.MiMoProviderSettings? = nil,
245247
abacus: ProviderSettingsSnapshot.AbacusProviderSettings? = nil,
246248
mistral: ProviderSettingsSnapshot.MistralProviderSettings? = nil,
249+
qoder: ProviderSettingsSnapshot.QoderProviderSettings? = nil,
247250
stepfun: ProviderSettingsSnapshot.StepFunProviderSettings? = nil) -> ProviderSettingsSnapshot
248251
{
249252
ProviderSettingsSnapshot.make(
@@ -270,6 +273,7 @@ struct TokenAccountCLIContext {
270273
mimo: mimo,
271274
abacus: abacus,
272275
mistral: mistral,
276+
qoder: qoder,
273277
stepfun: stepfun)
274278
}
275279

Sources/CodexBarCore/Providers/Providers.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,4 +262,14 @@ public enum ProviderBrowserCookieDefaults {
262262
nil
263263
#endif
264264
}
265+
266+
/// Qoder sessions are documented through Chrome cookie import. Keep automatic import narrow
267+
/// so enabling this provider does not probe unrelated browser keychains.
268+
public static var qoderCookieImportOrder: BrowserCookieImportOrder? {
269+
#if os(macOS)
270+
[.chrome]
271+
#else
272+
nil
273+
#endif
274+
}
265275
}

Sources/CodexBarCore/Providers/Qoder/QoderCookieImporter.swift

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,26 @@ public enum QoderCookieImporter {
2828
browserDetection: BrowserDetection = BrowserDetection(),
2929
preferredBrowsers: [Browser] = [],
3030
logger: ((String) -> Void)? = nil) throws -> SessionInfo
31+
{
32+
guard let session = try self.importSessions(
33+
browserDetection: browserDetection,
34+
preferredBrowsers: preferredBrowsers,
35+
logger: logger).first
36+
else {
37+
throw QoderUsageError.missingCredentials
38+
}
39+
return session
40+
}
41+
42+
public static func importSessions(
43+
browserDetection: BrowserDetection = BrowserDetection(),
44+
preferredBrowsers: [Browser] = [],
45+
logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
3146
{
3247
let installedBrowsers = preferredBrowsers.isEmpty
3348
? self.cookieImportOrder.cookieImportCandidates(using: browserDetection)
3449
: preferredBrowsers.cookieImportCandidates(using: browserDetection)
50+
var sessions: [SessionInfo] = []
3551

3652
for browserSource in installedBrowsers {
3753
do {
@@ -44,7 +60,7 @@ public enum QoderCookieImporter {
4460
let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin)
4561
guard !cookies.isEmpty else { continue }
4662
self.emit("Found \(cookies.count) cookies in \(source.label)", logger: logger)
47-
return SessionInfo(cookies: cookies, sourceLabel: source.label)
63+
sessions.append(SessionInfo(cookies: cookies, sourceLabel: source.label))
4864
}
4965
} catch {
5066
BrowserCookieAccessGate.recordIfNeeded(error)
@@ -54,7 +70,10 @@ public enum QoderCookieImporter {
5470
}
5571
}
5672

57-
throw QoderUsageError.missingCredentials
73+
guard !sessions.isEmpty else {
74+
throw QoderUsageError.missingCredentials
75+
}
76+
return sessions
5877
}
5978

6079
private static func emit(_ message: String, logger: ((String) -> Void)?) {

Sources/CodexBarCore/Providers/Qoder/QoderProviderDescriptor.swift

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public enum QoderProviderDescriptor {
2020
defaultEnabled: false,
2121
isPrimaryProvider: false,
2222
usesAccountFallback: false,
23-
browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder,
23+
browserCookieOrder: ProviderBrowserCookieDefaults.qoderCookieImportOrder,
2424
dashboardURL: "https://qoder.com/account/usage",
2525
statusPageURL: nil,
2626
statusLinkURL: nil),
@@ -46,7 +46,11 @@ struct QoderWebFetchStrategy: ProviderFetchStrategy {
4646
let kind: ProviderFetchKind = .web
4747

4848
func isAvailable(_ context: ProviderFetchContext) async -> Bool {
49-
guard context.settings?.qoder?.cookieSource != .off else { return false }
49+
let cookieSource = context.settings?.qoder?.cookieSource ?? .auto
50+
guard cookieSource != .off else { return false }
51+
if cookieSource == .manual {
52+
return CookieHeaderNormalizer.normalize(context.settings?.qoder?.manualCookieHeader) != nil
53+
}
5054
#if os(macOS)
5155
return true
5256
#else
@@ -55,26 +59,35 @@ struct QoderWebFetchStrategy: ProviderFetchStrategy {
5559
}
5660

5761
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
58-
#if os(macOS)
5962
let cookieSource = context.settings?.qoder?.cookieSource ?? .auto
63+
var attemptedSourceLabel: String?
6064
do {
61-
let (cookieHeader, sourceLabel) = try Self.resolveCookieHeader(context: context, allowCached: true)
62-
let snapshot = try await QoderUsageFetcher.fetchUsage(cookieHeader: cookieHeader)
65+
let (cookieHeader, sourceLabel) = try Self.resolveCookieHeader(
66+
context: context,
67+
allowCached: true,
68+
skippingSourceLabel: nil)
69+
attemptedSourceLabel = sourceLabel
70+
let snapshot = try await QoderUsageFetcher.fetchUsage(
71+
cookieHeader: cookieHeader,
72+
timeout: context.webTimeout)
6373
return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: sourceLabel)
6474
} catch QoderUsageError.invalidCredentials where cookieSource != .manual {
6575
CookieHeaderCache.clear(provider: .qoder)
66-
let (cookieHeader, sourceLabel) = try Self.resolveCookieHeader(context: context, allowCached: false)
67-
let snapshot = try await QoderUsageFetcher.fetchUsage(cookieHeader: cookieHeader)
76+
let (cookieHeader, sourceLabel) = try Self.resolveCookieHeader(
77+
context: context,
78+
allowCached: false,
79+
skippingSourceLabel: attemptedSourceLabel)
80+
let snapshot = try await QoderUsageFetcher.fetchUsage(
81+
cookieHeader: cookieHeader,
82+
timeout: context.webTimeout)
6883
return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: sourceLabel)
6984
}
70-
#else
71-
throw QoderUsageError.missingCredentials
72-
#endif
7385
}
7486

7587
private static func resolveCookieHeader(
7688
context: ProviderFetchContext,
77-
allowCached: Bool) throws -> (cookieHeader: String, sourceLabel: String)
89+
allowCached: Bool,
90+
skippingSourceLabel: String?) throws -> (cookieHeader: String, sourceLabel: String)
7891
{
7992
if context.settings?.qoder?.cookieSource == .manual {
8093
guard let manual = CookieHeaderNormalizer.normalize(context.settings?.qoder?.manualCookieHeader) else {
@@ -86,17 +99,24 @@ struct QoderWebFetchStrategy: ProviderFetchStrategy {
8699
#if os(macOS)
87100
if allowCached,
88101
let cached = CookieHeaderCache.load(provider: .qoder),
89-
!cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
102+
!cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
103+
shouldUseSourceLabel(cached.sourceLabel, skipping: skippingSourceLabel)
90104
{
91-
return (cached.cookieHeader, "cached")
105+
return (cached.cookieHeader, cached.sourceLabel)
92106
}
93107

94-
let session: QoderCookieImporter.SessionInfo
108+
let sessions: [QoderCookieImporter.SessionInfo]
95109
do {
96-
session = try QoderCookieImporter.importSession(browserDetection: context.browserDetection)
110+
sessions = try QoderCookieImporter.importSessions(browserDetection: context.browserDetection)
97111
} catch {
98112
throw QoderUsageError.missingCredentials
99113
}
114+
guard let session = sessions.first(where: { Self.shouldUseSourceLabel(
115+
$0.sourceLabel,
116+
skipping: skippingSourceLabel) })
117+
else {
118+
throw QoderUsageError.missingCredentials
119+
}
100120
guard !session.cookies.isEmpty else {
101121
throw QoderUsageError.missingCredentials
102122
}
@@ -110,6 +130,11 @@ struct QoderWebFetchStrategy: ProviderFetchStrategy {
110130
#endif
111131
}
112132

133+
private static func shouldUseSourceLabel(_ sourceLabel: String, skipping skippedLabel: String?) -> Bool {
134+
guard let skippedLabel else { return true }
135+
return sourceLabel != skippedLabel
136+
}
137+
113138
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
114139
false
115140
}

Sources/CodexBarCore/Providers/Qoder/QoderUsageFetcher.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import FoundationNetworking
66
public enum QoderUsageFetcher {
77
private static let log = CodexBarLog.logger(LogCategories.qoderUsage)
88
private static let usageURL = URL(string: "https://qoder.com/api/v2/me/usages/big_model_credits")!
9-
private static let requestTimeoutSeconds: TimeInterval = 15
109
private static let webOrigin = "https://qoder.com"
1110
private static let userAgent =
1211
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
@@ -15,19 +14,21 @@ public enum QoderUsageFetcher {
1514
public static func fetchUsage(
1615
cookieHeader: String,
1716
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
18-
now: Date = Date()) async throws -> QoderUsageSnapshot
17+
now: Date = Date(),
18+
timeout: TimeInterval = 15) async throws -> QoderUsageSnapshot
1919
{
20-
let data = try await self.send(cookieHeader: cookieHeader, transport: transport)
20+
let data = try await self.send(cookieHeader: cookieHeader, transport: transport, timeout: timeout)
2121
return try self.parseUsage(data: data, now: now)
2222
}
2323

2424
private static func send(
2525
cookieHeader: String,
26-
transport: any ProviderHTTPTransport) async throws -> Data
26+
transport: any ProviderHTTPTransport,
27+
timeout: TimeInterval) async throws -> Data
2728
{
2829
var request = URLRequest(url: self.usageURL)
2930
request.httpMethod = "GET"
30-
request.timeoutInterval = self.requestTimeoutSeconds
31+
request.timeoutInterval = timeout
3132
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
3233
request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept")
3334
request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")

Tests/CodexBarTests/CLIEntryTests.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,20 @@ final class CLIEntryTests: XCTestCase {
377377
commandcode: .init(
378378
cookieSource: .auto,
379379
manualCookieHeader: nil))))
380+
XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport(
381+
.web,
382+
provider: .qoder,
383+
settings: ProviderSettingsSnapshot.make(
384+
qoder: .init(
385+
cookieSource: .manual,
386+
manualCookieHeader: "sid=manual"))))
387+
XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport(
388+
.web,
389+
provider: .qoder,
390+
settings: ProviderSettingsSnapshot.make(
391+
qoder: .init(
392+
cookieSource: .auto,
393+
manualCookieHeader: nil))))
380394
XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport(
381395
.auto,
382396
provider: .opencode,

Tests/CodexBarTests/CLISnapshotTests.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,36 @@ struct CLISnapshotTests {
302302
#expect(!output.contains("Resets $9.99"))
303303
}
304304

305+
@Test
306+
func `renders qoder credit total as detail not reset`() {
307+
let meta = ProviderDescriptorRegistry.descriptor(for: .qoder).metadata
308+
let now = Date(timeIntervalSince1970: 0)
309+
let snap = UsageSnapshot(
310+
primary: .init(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: "125 / 500 credits"),
311+
secondary: nil,
312+
tertiary: nil,
313+
updatedAt: now,
314+
identity: ProviderIdentitySnapshot(
315+
providerID: .qoder,
316+
accountEmail: nil,
317+
accountOrganization: nil,
318+
loginMethod: "375 credits remaining"))
319+
320+
let output = CLIRenderer.renderText(
321+
provider: .qoder,
322+
snapshot: snap,
323+
credits: nil,
324+
context: RenderContext(
325+
header: "Qoder",
326+
status: nil,
327+
useColor: false,
328+
resetStyle: .countdown))
329+
330+
#expect(output.contains("\(meta.sessionLabel): 75% left"))
331+
#expect(output.contains("125 / 500 credits"))
332+
#expect(!output.contains("Resets 125 / 500 credits"))
333+
}
334+
305335
@Test
306336
func `renders kilo plan activity and fallback note`() {
307337
let now = Date(timeIntervalSince1970: 0)

0 commit comments

Comments
 (0)