From ca0c8a698c0ed555f61f3943f9d42d1070eda115 Mon Sep 17 00:00:00 2001 From: Ignacio Bustos Date: Tue, 11 Aug 2026 16:38:13 +0200 Subject: [PATCH 1/9] Add Replicate billing endpoint constants from dashboard capture. Lock the cookie-authenticated JSON URLs before implementing the provider fetch pipeline. Co-authored-by: Cursor --- .../Replicate/ReplicateBillingEndpoints.swift | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 Sources/CodexBarCore/Providers/Replicate/ReplicateBillingEndpoints.swift diff --git a/Sources/CodexBarCore/Providers/Replicate/ReplicateBillingEndpoints.swift b/Sources/CodexBarCore/Providers/Replicate/ReplicateBillingEndpoints.swift new file mode 100644 index 0000000000..dfce123fb2 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Replicate/ReplicateBillingEndpoints.swift @@ -0,0 +1,70 @@ +import Foundation + +/// Constants locked from Replicate dashboard frontend route table + billing UI field usage. +/// Source: public frontend bundle `index-BJr3klVG.js` route table (verified in task-2 discovery). +/// Update only when Replicate changes the dashboard network surface. +public enum ReplicateBillingEndpoints: Sendable { + private static let baseURLString = "https://replicate.com" + + /// Domains passed to SweetCookieKit / BrowserCookieClient for Automatic import. + /// Session cookie: Django-style `sessionid`; also import `csrftoken` for completeness. + public static let cookieDomains = ["replicate.com"] + + public static let dashboardURLString = "https://replicate.com/account/billing" + public static let timeoutSeconds: TimeInterval = 30 + + // MARK: - Invoices (current-month spend) + + /// GET — returns invoices including the current `monthly-usage` row. + /// + /// JSON field mapping (menu-bar spend): + /// - Filter `invoices[]` where `type == "monthly-usage"`. + /// - Current invoice = first where `ended_before` is null or parses to a future date. + /// - **Usage this month** (`currentMonthSpend`): `Number(invoice.total_cost_before_adjustments ?? "0")`. + /// - Outstanding balance (optional): `Number(invoice.total_cost ?? "0")` — not the menu-bar metric. + /// - `currencyCode`: USD implied when absent (amounts are string decimals). + /// - Period: calendar month via `started_on` / `ended_before` on the draft monthly-usage invoice. + public static func userInvoicesURL(username: String) -> URL { + Self.apiURL(pathComponents: ["api", "users", username, "invoices"]) + } + + /// GET — org-scoped invoices (same response shape as user invoices). + public static func organizationInvoicesURL(organizationName: String) -> URL { + Self.apiURL(pathComponents: ["api", "organizations", organizationName, "invoices"]) + } + + // MARK: - Unused credit (prepaid balance) + + /// GET — prepaid unused credit. + /// + /// JSON field mapping: + /// - **Credit balance** (`creditBalance`): `Number(unused_credit ?? "0")` (string number). + /// - `link_to_add_credit` (optional URL string). + public static func userUnusedCreditURL(username: String) -> URL { + Self.apiURL(pathComponents: ["api", "users", username, "unused-credit"]) + } + + /// GET — org-scoped unused credit. + public static func organizationUnusedCreditURL(organizationName: String) -> URL { + Self.apiURL(pathComponents: ["api", "organizations", organizationName, "unused-credit"]) + } + + // MARK: - Account bootstrap (later tasks) + + /// Invoices/credit URLs require `{username}` and account kind (`user` vs `organization`). + /// Bootstrap strategy: with session cookies, GET `dashboardURLString` and parse + /// `".utf8) } private struct ReplicateInvoicesResponse: Decodable { diff --git a/Tests/CodexBarTests/ReplicateCookieStrategyTests.swift b/Tests/CodexBarTests/ReplicateCookieStrategyTests.swift new file mode 100644 index 0000000000..b2d9bda187 --- /dev/null +++ b/Tests/CodexBarTests/ReplicateCookieStrategyTests.swift @@ -0,0 +1,101 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ReplicateCookieStrategyTests { + #if os(macOS) + @Test + func `cookie importer uses Replicate billing domains`() { + #expect(!ReplicateCookieImporter.cookieDomains.isEmpty) + #expect(Set(ReplicateCookieImporter.cookieDomains) == Set(ReplicateBillingEndpoints.cookieDomains)) + + let referenceDate = Date(timeIntervalSince1970: 1_700_000_000) + let query = ReplicateCookieImporter.cookieQuery(referenceDate: referenceDate) + #expect(query.domains == ReplicateCookieImporter.cookieDomains) + #expect(query.includeExpired == false) + #expect(query.referenceDate == referenceDate) + guard case .exact = query.domainMatch else { + Issue.record("Expected exact Replicate cookie-domain matching") + return + } + } + + @Test + func `session cookie predicate accepts Django sessionid`() throws { + let sessionCookie = try #require(HTTPCookie(properties: [ + .domain: "replicate.com", + .path: "/", + .name: "sessionid", + .value: "abc123", + .secure: true, + ])) + #expect(ReplicateCookieImporter.hasSessionCookie([sessionCookie])) + + let csrfCookie = try #require(HTTPCookie(properties: [ + .domain: "replicate.com", + .path: "/", + .name: "csrftoken", + .value: "token", + .secure: true, + ])) + #expect(ReplicateCookieImporter.hasSessionCookie([sessionCookie, csrfCookie])) + } + + @Test + func `session cookie predicate rejects unrelated cookies`() throws { + let unrelated = try #require(HTTPCookie(properties: [ + .domain: "replicate.com", + .path: "/", + .name: "tracking_id", + .value: "xyz", + .secure: true, + ])) + #expect(!ReplicateCookieImporter.hasSessionCookie([unrelated])) + } + #endif + + @Test + func `resolveAccount reads account props from billing HTML`() throws { + let html = """ + + + + """ + + let account = try ReplicateUsageFetcher.resolveAccount(fromBillingHTML: html) + #expect(account.kind == "user") + #expect(account.username == "demo-user") + } + + @Test + func `resolveAccount finds nested account dictionary`() throws { + let html = """ + + + + """ + + let account = try ReplicateUsageFetcher.resolveAccount(fromBillingHTML: html) + #expect(account.kind == "organization") + #expect(account.username == "my-org") + } + + @Test + func `resolveAccount fails when account props missing`() { + let html = """ + + """ + + #expect { + _ = try ReplicateUsageFetcher.resolveAccount(fromBillingHTML: html) + } throws: { error in + guard case ReplicateUsageError.parseFailed = error else { return false } + return true + } + } +} diff --git a/Tests/CodexBarTests/ReplicateUsageFetcherTests.swift b/Tests/CodexBarTests/ReplicateUsageFetcherTests.swift index b4c5b34788..543acc86e5 100644 --- a/Tests/CodexBarTests/ReplicateUsageFetcherTests.swift +++ b/Tests/CodexBarTests/ReplicateUsageFetcherTests.swift @@ -1,9 +1,36 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif import Testing @testable import CodexBarCore @Suite(.serialized) struct ReplicateUsageFetcherTests { + private static let invoicesJSON = """ + { + "invoices": [ + { + "id": "inv_1", + "type": "monthly-usage", + "status": "DRAFT", + "started_on": "2026-08-01", + "ended_before": null, + "total_cost": "12.40", + "total_cost_before_adjustments": "12.40" + } + ] + } + """ + + private static let creditJSON = """ + { "unused_credit": "80.0", "link_to_add_credit": "https://replicate.com/account/billing#add-credit" } + """ + + private static func httpResponse(url: URL, statusCode: Int) throws -> HTTPURLResponse { + try #require(HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil)) + } + @Test func `maps spend balance and username into usage snapshot`() throws { let invoicesJSON = """ @@ -92,4 +119,72 @@ struct ReplicateUsageFetcherTests { return true } } + + @Test + func `http 401 maps to invalidCredentials`() async { + let transport = ProviderHTTPTransportStub { request in + let response = try Self.httpResponse(url: #require(request.url), statusCode: 401) + return (Data(), response) + } + + await #expect(throws: ReplicateUsageError.invalidCredentials) { + _ = try await ReplicateUsageFetcher._fetchUsageForTesting( + cookieHeader: "sessionid=test", + username: "demo", + accountKind: "user", + transport: transport) + } + } + + @Test + func `http 429 maps to rateLimited`() async { + let transport = ProviderHTTPTransportStub { request in + let response = try Self.httpResponse(url: #require(request.url), statusCode: 429) + return (Data(), response) + } + + await #expect(throws: ReplicateUsageError.rateLimited) { + _ = try await ReplicateUsageFetcher._fetchUsageForTesting( + cookieHeader: "sessionid=test", + username: "demo", + accountKind: "user", + transport: transport) + } + } + + @Test + func `fetches invoices then best effort unused credit`() async throws { + let invoicesURL = ReplicateBillingEndpoints.userInvoicesURL(username: "demo") + let creditURL = ReplicateBillingEndpoints.userUnusedCreditURL(username: "demo") + let transport = ProviderHTTPTransportStub { request in + guard let url = request.url else { throw URLError(.badURL) } + if url == invoicesURL { + let response = try Self.httpResponse(url: url, statusCode: 200) + return (Data(Self.invoicesJSON.utf8), response) + } + if url == creditURL { + let response = try Self.httpResponse(url: url, statusCode: 200) + return (Data(Self.creditJSON.utf8), response) + } + throw URLError(.unsupportedURL) + } + + let summary = try await ReplicateUsageFetcher._fetchUsageForTesting( + cookieHeader: "sessionid=test; csrftoken=abc", + username: "demo", + accountKind: "user", + transport: transport) + + #expect(abs(summary.currentMonthSpend - 12.4) <= 0.0001) + #expect(summary.creditBalance == 80.0) + #expect(summary.username == "demo") + + let requests = await transport.requests() + #expect(requests.count == 2) + #expect(requests[0].url == invoicesURL) + #expect(requests[1].url == creditURL) + #expect(requests.allSatisfy { $0.value(forHTTPHeaderField: "Accept") == "application/json" }) + #expect(requests.allSatisfy { $0.value(forHTTPHeaderField: "Cookie") == "sessionid=test; csrftoken=abc" }) + #expect(requests.allSatisfy { $0.timeoutInterval == ReplicateBillingEndpoints.timeoutSeconds }) + } } From c8213db30043710d20829141cd8d47c32a029c8d Mon Sep 17 00:00:00 2001 From: Ignacio Bustos Date: Tue, 11 Aug 2026 17:23:51 +0200 Subject: [PATCH 4/9] Register Replicate provider descriptor and manifests. Wire cookie-based web fetch strategy and spend-oriented menu metadata. Co-authored-by: Cursor --- .../ReplicateProviderImplementation.swift | 24 ++ .../Replicate/ReplicateSettingsStore.swift | 38 +++ .../ProviderImplementationManifest.swift | 1 + .../Resources/ProviderIcon-replicate.svg | 23 ++ .../StatusItemController+Animation.swift | 19 ++ .../ProviderInstanceIDAliases.generated.swift | 1 + .../Providers/ProviderManifest.swift | 1 + .../ReplicateProviderDescriptor.swift | 237 ++++++++++++++++++ .../Replicate/ReplicateProviderSettings.swift | 33 +++ .../ProviderArchitectureGatekeeperTests.swift | 14 +- ...atusItemBalanceDisplayReplicateTests.swift | 55 ++++ .../StatusItemBalanceDisplayTests.swift | 4 +- docs/provider-ids.md | 2 +- 13 files changed, 442 insertions(+), 10 deletions(-) create mode 100644 Sources/CodexBar/Providers/Replicate/ReplicateProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/Replicate/ReplicateSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-replicate.svg create mode 100644 Sources/CodexBarCore/Providers/Replicate/ReplicateProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Replicate/ReplicateProviderSettings.swift create mode 100644 Tests/CodexBarTests/StatusItemBalanceDisplayReplicateTests.swift diff --git a/Sources/CodexBar/Providers/Replicate/ReplicateProviderImplementation.swift b/Sources/CodexBar/Providers/Replicate/ReplicateProviderImplementation.swift new file mode 100644 index 0000000000..fb70732c67 --- /dev/null +++ b/Sources/CodexBar/Providers/Replicate/ReplicateProviderImplementation.swift @@ -0,0 +1,24 @@ +import CodexBarCore +import Foundation + +/// Minimal implementation to satisfy the provider manifest generator. Settings UI +/// (cookie source picker, manual header field, settings store bindings) lands separately. +struct ReplicateProviderImplementation: ProviderImplementation { + let id: UsageProvider = .replicate + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "web" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.replicateCookieSource + _ = settings.replicateCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .replicate(context.settings.replicateSettingsSnapshot(tokenOverride: context.tokenOverride)) + } +} diff --git a/Sources/CodexBar/Providers/Replicate/ReplicateSettingsStore.swift b/Sources/CodexBar/Providers/Replicate/ReplicateSettingsStore.swift new file mode 100644 index 0000000000..cd4af4e752 --- /dev/null +++ b/Sources/CodexBar/Providers/Replicate/ReplicateSettingsStore.swift @@ -0,0 +1,38 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var replicateCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .replicate)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .replicate) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .replicate, field: "cookieHeader", value: newValue) + } + } + + var replicateCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .replicate, fallback: .auto) } + set { + self.updateProviderConfig(provider: .replicate) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .replicate, field: "cookieSource", value: newValue.rawValue) + } + } + + func ensureReplicateCookieLoaded() {} +} + +extension SettingsStore { + func replicateSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot + .ReplicateProviderSettings + { + self.resolvedCookieSettings( + provider: .replicate, + configuredSource: self.replicateCookieSource, + configuredHeader: self.replicateCookieHeader, + tokenOverride: tokenOverride) + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift index 49ee8bb14b..29ab32e932 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift @@ -55,6 +55,7 @@ enum ProviderImplementationManifest { { VeniceProviderImplementation() }, { CommandCodeProviderImplementation() }, { QoderProviderImplementation() }, + { ReplicateProviderImplementation() }, { StepFunProviderImplementation() }, { BedrockProviderImplementation() }, { GrokProviderImplementation() }, diff --git a/Sources/CodexBar/Resources/ProviderIcon-replicate.svg b/Sources/CodexBar/Resources/ProviderIcon-replicate.svg new file mode 100644 index 0000000000..065e32e991 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-replicate.svg @@ -0,0 +1,23 @@ + + Replicate + + + + + + + + + + + + + + + + + + + + + diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 0f8d519ba8..b17b44ffd6 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -912,6 +912,11 @@ extension StatusItemController { return spend } } + if provider == .replicate, + let spend = Self.replicateSpendDisplayText(snapshot: snapshot) + { + return spend + } if provider == .kiro { return Self.kiroDisplayText( snapshot: snapshot, @@ -1055,6 +1060,20 @@ extension StatusItemController { removingSuffix: " this month") } + nonisolated static func replicateSpendDisplayText(snapshot: UsageSnapshot?) -> String? { + guard + let detail = snapshot?.primary?.resetDescription? + .trimmingCharacters(in: .whitespacesAndNewlines), + let spendDetail = detail.components(separatedBy: " · ").first? + .trimmingCharacters(in: .whitespacesAndNewlines), + spendDetail.hasPrefix("$"), + let value = spendDetail.split(separator: " ", maxSplits: 1).first + else { + return nil + } + return String(value) + } + nonisolated static func extraUsageSpendDisplayText(snapshot: UsageSnapshot?) -> String? { guard let cost = snapshot?.providerCost, cost.limit > 0, diff --git a/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift b/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift index 1a6682c057..8b99eed6ea 100644 --- a/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift +++ b/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift @@ -52,6 +52,7 @@ extension ProviderInstanceID { public static let venice = UsageProvider.venice.instanceID public static let commandcode = UsageProvider.commandcode.instanceID public static let qoder = UsageProvider.qoder.instanceID + public static let replicate = UsageProvider.replicate.instanceID public static let stepfun = UsageProvider.stepfun.instanceID public static let bedrock = UsageProvider.bedrock.instanceID public static let grok = UsageProvider.grok.instanceID diff --git a/Sources/CodexBarCore/Providers/ProviderManifest.swift b/Sources/CodexBarCore/Providers/ProviderManifest.swift index 47631b6564..a4529fdf1f 100644 --- a/Sources/CodexBarCore/Providers/ProviderManifest.swift +++ b/Sources/CodexBarCore/Providers/ProviderManifest.swift @@ -54,6 +54,7 @@ public enum ProviderManifest { VeniceProviderDescriptor.descriptor, CommandCodeProviderDescriptor.descriptor, QoderProviderDescriptor.descriptor, + ReplicateProviderDescriptor.descriptor, StepFunProviderDescriptor.descriptor, BedrockProviderDescriptor.descriptor, GrokProviderDescriptor.descriptor, diff --git a/Sources/CodexBarCore/Providers/Replicate/ReplicateProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Replicate/ReplicateProviderDescriptor.swift new file mode 100644 index 0000000000..298c9d4fc7 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Replicate/ReplicateProviderDescriptor.swift @@ -0,0 +1,237 @@ +import Foundation +import SweetCookieKit + +public enum ReplicateProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + private static let credentials = ProviderCredentialAdapter(tokenAccountSupport: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple Replicate Cookie headers.", + placeholder: "Cookie: …", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil)) + + /// Preserve Chrome-first behavior, then Firefox and Safari; other Chromium forks remain manual-only. + private static var browserCookieOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.chrome, .firefox, .safari] + #else + nil + #endif + } + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .replicate, + settingsSection: .init(ReplicateProviderSettingsKey.self, cookieSettings: ReplicateProviderSettings.self), + credentials: self.credentials, + metadata: ProviderMetadata( + id: .replicate, + displayName: "Replicate", + sessionLabel: "Spend", + weeklyLabel: "Spend", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Replicate usage", + cliName: "replicate", + defaultEnabled: false, + widgetSelectable: false, + isPrimaryProvider: false, + usesAccountFallback: false, + balanceOnly: false, + usesDetailBackedWindow: true, + browserCookieOrder: self.browserCookieOrder, + dashboardURL: ReplicateBillingEndpoints.dashboardURLString, + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .init(provider: .replicate), + iconResourceName: "ProviderIcon-replicate", + color: ProviderColor(red: 0 / 255, green: 0 / 255, blue: 0 / 255), + confettiPalette: [ + ProviderColor(hex: 0x000000), + ProviderColor(hex: 0x525252), + ProviderColor(hex: 0xFFFFFF), + ]), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Replicate spend comes from the billing summary page; cost history is not tracked." }), + presentation: ProviderUsagePresentation( + menuCard: ProviderMenuCardPresentation( + showsPrimaryBalanceDescription: true, + hidesPrimaryResetWithoutDate: true, + movePrimaryDetailToStatus: { _ in true }), + menu: ProviderMenuDescriptorPresentation(primaryDescriptionIsDetail: { _ in true })), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [ReplicateWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "replicate", + aliases: ["r8"], + versionDetector: nil)) + } +} + +struct ReplicateWebFetchStrategy: ProviderFetchStrategy { + let id: String = "replicate.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.replicate?.cookieSource != .off else { return false } + return true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let cookieSource = context.settings?.replicate?.cookieSource ?? .auto + let session = try Self.resolveCookieSession(context: context, allowCached: true) + do { + let usage = try await Self.fetchUsage( + cookieHeader: session.cookieHeader, + timeout: context.webTimeout) + return self.makeResult(usage: usage, sourceLabel: "web") + } catch ReplicateUsageError.invalidCredentials where cookieSource != .manual { + #if os(macOS) + CookieHeaderCache.clear(provider: .replicate) + let excludedSourceLabels = if session.wasCached { + Set() + } else { + Set([session.sourceLabel].compactMap(\.self)) + } + let sessions: [ReplicateCookieImporter.SessionInfo] + do { + sessions = try ReplicateCookieImporter.importSessions( + browserDetection: context.browserDetection, + excludingSourceLabels: excludedSourceLabels) + } catch ReplicateCookieImportError.noCookies { + throw ReplicateUsageError.invalidCredentials + } + let (usage, session) = try await Self.fetchUsageFromSessions( + sessions, + timeout: context.webTimeout) + CookieHeaderCache.store( + provider: .replicate, + cookieHeader: session.cookieHeader, + sourceLabel: session.sourceLabel) + return self.makeResult(usage: usage, sourceLabel: "web") + #else + throw ReplicateUsageError.invalidCredentials + #endif + } + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + #if os(macOS) + static func fetchUsageFromSessions( + _ sessions: [ReplicateCookieImporter.SessionInfo], + timeout: TimeInterval, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws + -> (usage: UsageSnapshot, session: ReplicateCookieImporter.SessionInfo) + { + for session in sessions { + do { + let usage = try await Self.fetchUsage( + cookieHeader: session.cookieHeader, + timeout: timeout, + transport: transport) + return (usage, session) + } catch ReplicateUsageError.invalidCredentials { + continue + } + } + throw ReplicateUsageError.invalidCredentials + } + #endif + + static func fetchUsage( + cookieHeader: String, + timeout: TimeInterval, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> UsageSnapshot + { + let html = try await Self.fetchBillingHTML(cookieHeader: cookieHeader, timeout: timeout, transport: transport) + let account = try ReplicateUsageFetcher.resolveAccount(fromBillingHTML: html) + let summary = try await ReplicateUsageFetcher.fetchUsage( + cookieHeader: cookieHeader, + username: account.username, + accountKind: account.kind, + transport: transport) + return summary.toUsageSnapshot() + } + + private static func fetchBillingHTML( + cookieHeader: String, + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> String + { + guard let url = URL(string: ReplicateBillingEndpoints.dashboardURLString) else { + throw ReplicateUsageError.parseFailed("Invalid dashboard URL") + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("text/html", forHTTPHeaderField: "Accept") + request.timeoutInterval = timeout + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request, retryPolicy: .transientIdempotent) + } catch is CancellationError { + throw CancellationError() + } catch { + throw ReplicateUsageError.networkError(error.localizedDescription) + } + + switch response.statusCode { + case 200: + break + case 401, 403: + throw ReplicateUsageError.invalidCredentials + case 429: + throw ReplicateUsageError.rateLimited + default: + throw ReplicateUsageError.apiError(response.statusCode) + } + + guard let html = String(data: response.data, encoding: .utf8) else { + throw ReplicateUsageError.parseFailed("Non-UTF8 billing HTML") + } + return html + } + + private static func resolveCookieSession( + context: ProviderFetchContext, + allowCached: Bool) throws + -> (cookieHeader: String, sourceLabel: String?, wasCached: Bool) + { + if let settings = context.settings?.replicate, settings.cookieSource == .manual { + guard let header = CookieHeaderNormalizer.normalize(settings.manualCookieHeader) else { + throw ReplicateUsageError.invalidCookie + } + let pairs = CookieHeaderNormalizer.pairs(from: header) + guard pairs.contains(where: { $0.name == "sessionid" }) else { + throw ReplicateUsageError.invalidCookie + } + return (header, nil, false) + } + + #if os(macOS) + if allowCached, + let cached = CookieHeaderCache.load(provider: .replicate), + !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return (cached.cookieHeader, cached.sourceLabel, true) + } + let session = try ReplicateCookieImporter.importSession(browserDetection: context.browserDetection) + CookieHeaderCache.store( + provider: .replicate, + cookieHeader: session.cookieHeader, + sourceLabel: session.sourceLabel) + return (session.cookieHeader, session.sourceLabel, false) + #else + throw ReplicateUsageError.missingCookie + #endif + } +} diff --git a/Sources/CodexBarCore/Providers/Replicate/ReplicateProviderSettings.swift b/Sources/CodexBarCore/Providers/Replicate/ReplicateProviderSettings.swift new file mode 100644 index 0000000000..eafef57cea --- /dev/null +++ b/Sources/CodexBarCore/Providers/Replicate/ReplicateProviderSettings.swift @@ -0,0 +1,33 @@ +import Foundation + +public struct ReplicateProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } +} + +public enum ReplicateProviderSettingsKey: ProviderSettingsSectionKey { + public static let providerID = ProviderInstanceID.replicate + public typealias Section = ReplicateProviderSettings +} + +extension ProviderSettingsSnapshot { + public typealias ReplicateProviderSettings = CodexBarCore.ReplicateProviderSettings + public var replicate: ReplicateProviderSettings? { + self[ReplicateProviderSettingsKey.self] + } + + public static func make(replicate: ReplicateProviderSettings?) -> Self { + self.make(replicate, for: ReplicateProviderSettingsKey.self) + } +} + +extension ProviderSettingsSnapshotContribution { + public static func replicate(_ section: ReplicateProviderSettings) -> Self { + Self(section, for: ReplicateProviderSettingsKey.self) + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index c9ca73a5ce..3ac47b7917 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -154,8 +154,8 @@ struct ProviderArchitectureGatekeeperTests { Self.hash(descriptor.branding.burnDownWidgetColor, into: &burnDownFingerprint) } - #expect(widgetFingerprint == 16_873_014_858_015_536_126) - #expect(burnDownFingerprint == 8_686_456_525_451_224_704) + #expect(widgetFingerprint == 8_085_161_549_220_867_019) + #expect(burnDownFingerprint == 12_629_362_811_078_279_269) } @Test @@ -197,7 +197,7 @@ struct ProviderArchitectureGatekeeperTests { .deepseek, .deepinfra, .mistral, .moonshot, .poe, ]) #expect(Set(descriptors.filter(\.metadata.usesDetailBackedWindow).map(\.id)) == [ - .warp, .kilo, .mistral, .deepseek, .deepinfra, .qoder, .crof, .chutes, + .warp, .kilo, .mistral, .deepseek, .deepinfra, .qoder, .crof, .chutes, .replicate, ]) #if os(macOS) #expect(Set(descriptors.filter(\.tokenCost.supportsTokenSnapshot).map(\.id)) == [ @@ -2437,10 +2437,10 @@ struct ProviderArchitectureGatekeeperTests { AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Animation.swift", line: 915, - anchor: "if provider == .kiro {", - expectedProviderIDs: ["cursor", "kiro"], - expectedReferenceCount: 2, - expectedReferenceFingerprint: ["kiro@0", "cursor@8"], + anchor: "if provider == .replicate,", + expectedProviderIDs: ["cursor", "kiro", "replicate"], + expectedReferenceCount: 3, + expectedReferenceFingerprint: ["replicate@0", "kiro@5", "cursor@13"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+CostMenuCard.swift", diff --git a/Tests/CodexBarTests/StatusItemBalanceDisplayReplicateTests.swift b/Tests/CodexBarTests/StatusItemBalanceDisplayReplicateTests.swift new file mode 100644 index 0000000000..15ce8a761c --- /dev/null +++ b/Tests/CodexBarTests/StatusItemBalanceDisplayReplicateTests.swift @@ -0,0 +1,55 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +extension StatusItemBalanceDisplayTests { + @Test + func `menu bar display text extracts Replicate month spend`() { + let snapshot = ReplicateUsageSummary( + currentMonthSpend: 12.4, + currencyCode: "USD", + creditBalance: 80, + spendLimit: nil, + username: "demo", + updatedAt: Date()) + .toUsageSnapshot() + + #expect(StatusItemController.replicateSpendDisplayText(snapshot: snapshot) == "$12.40") + } + + @Test + func `menu bar display text ignores Replicate snapshot without spend detail`() { + #expect(StatusItemController.replicateSpendDisplayText(snapshot: nil) == nil) + + let bareSnapshot = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: nil) + #expect(StatusItemController.replicateSpendDisplayText(snapshot: bareSnapshot) == nil) + } + + @Test + func `menu bar display text shows Replicate month spend`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-replicate-spend", + provider: .replicate) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = ReplicateUsageSummary( + currentMonthSpend: 12.4, + currencyCode: "USD", + creditBalance: 80, + spendLimit: nil, + username: "demo", + updatedAt: Date()) + .toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .replicate) + store._setErrorForTesting(nil, provider: .replicate) + + #expect(controller.menuBarDisplayText(for: .replicate, snapshot: snapshot) == "$12.40") + } +} diff --git a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift index 9c44e161ee..fd15d3c60c 100644 --- a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift +++ b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift @@ -855,7 +855,7 @@ struct StatusItemBalanceDisplayTests { #expect(StatusItemController.statusItemAccessibilityTitle(isDebugApp: false) == "CodexBar") } - private func makeSettings(suiteName: String, provider: UsageProvider) -> SettingsStore { + func makeSettings(suiteName: String, provider: UsageProvider) -> SettingsStore { let settings = testSettingsStore(suiteName: suiteName) settings.statusChecksEnabled = false settings.refreshFrequency = .manual @@ -871,7 +871,7 @@ struct StatusItemBalanceDisplayTests { return settings } - private func makeStoreAndController(settings: SettingsStore) -> (UsageStore, StatusItemController) { + func makeStoreAndController(settings: SettingsStore) -> (UsageStore, StatusItemController) { let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) let controller = StatusItemController( diff --git a/docs/provider-ids.md b/docs/provider-ids.md index 214a88e646..ace042e3e9 100644 --- a/docs/provider-ids.md +++ b/docs/provider-ids.md @@ -2,4 +2,4 @@ # Provider IDs -`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `factory`, `fireworks`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `openrouter`, `elevenlabs`, `warp`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`, `zoommate`, `xai`, `notion`, `ibmbob`. +`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `factory`, `fireworks`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `openrouter`, `elevenlabs`, `warp`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `replicate`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`, `zoommate`, `xai`, `notion`, `ibmbob`. From d75adcfc2f23eeaed1fab155405ebf190f307b4d Mon Sep 17 00:00:00 2001 From: Ignacio Bustos Date: Tue, 11 Aug 2026 17:31:35 +0200 Subject: [PATCH 5/9] Add Replicate Settings UI for Automatic and Manual cookies. Expose billing-session cookie configuration without requiring an API token. Co-authored-by: Cursor --- .../ReplicateProviderImplementation.swift | 82 ++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/Providers/Replicate/ReplicateProviderImplementation.swift b/Sources/CodexBar/Providers/Replicate/ReplicateProviderImplementation.swift index fb70732c67..646d020e49 100644 --- a/Sources/CodexBar/Providers/Replicate/ReplicateProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Replicate/ReplicateProviderImplementation.swift @@ -1,8 +1,8 @@ +import AppKit import CodexBarCore import Foundation +import SwiftUI -/// Minimal implementation to satisfy the provider manifest generator. Settings UI -/// (cookie source picker, manual header field, settings store bindings) lands separately. struct ReplicateProviderImplementation: ProviderImplementation { let id: UsageProvider = .replicate @@ -21,4 +21,82 @@ struct ReplicateProviderImplementation: ProviderImplementation { func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { .replicate(context.settings.replicateSettingsSnapshot(tokenOverride: context.tokenOverride)) } + + @MainActor + func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool { + guard support.requiresManualCookieSource else { return true } + if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true } + return context.settings.replicateCookieSource == .manual + } + + @MainActor + func applyTokenAccountCookieSource(settings: SettingsStore) { + if settings.replicateCookieSource != .manual { + settings.replicateCookieSource = .manual + } + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.replicateCookieSource.rawValue }, + set: { raw in + context.settings.replicateCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.replicateCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports browser cookies from replicate.com.", + manual: "Paste a Cookie header captured from the billing page.", + off: "Replicate cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "replicate-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports browser cookies from replicate.com.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: { + ProviderCookieSourceUI.cachedTrailingText(provider: .replicate) + }), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "replicate-cookie-header", + title: "Cookie header", + subtitle: "Paste the Cookie header from a request to replicate.com/account/billing. " + + "Must contain a sessionid cookie.", + kind: .secure, + placeholder: "sessionid=…; csrftoken=…", + binding: context.stringBinding(\.replicateCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "replicate-open-billing", + title: "Open Replicate Billing", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://replicate.com/account/billing") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { context.settings.replicateCookieSource == .manual }, + onActivate: nil), + ] + } } From 1a9b7ca6607f914c0438ee0693ce22b3b850243c Mon Sep 17 00:00:00 2001 From: Ignacio Bustos Date: Tue, 11 Aug 2026 17:33:44 +0200 Subject: [PATCH 6/9] Normalize ReplicateBillingEndpoints static call style. Use explicit self for static helper calls and demote bootstrap notes to line comments. Co-authored-by: Cursor --- .../Replicate/ReplicateBillingEndpoints.swift | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Replicate/ReplicateBillingEndpoints.swift b/Sources/CodexBarCore/Providers/Replicate/ReplicateBillingEndpoints.swift index dfce123fb2..fe26f02b2e 100644 --- a/Sources/CodexBarCore/Providers/Replicate/ReplicateBillingEndpoints.swift +++ b/Sources/CodexBarCore/Providers/Replicate/ReplicateBillingEndpoints.swift @@ -25,12 +25,12 @@ public enum ReplicateBillingEndpoints: Sendable { /// - `currencyCode`: USD implied when absent (amounts are string decimals). /// - Period: calendar month via `started_on` / `ended_before` on the draft monthly-usage invoice. public static func userInvoicesURL(username: String) -> URL { - Self.apiURL(pathComponents: ["api", "users", username, "invoices"]) + self.apiURL(pathComponents: ["api", "users", username, "invoices"]) } /// GET — org-scoped invoices (same response shape as user invoices). public static func organizationInvoicesURL(organizationName: String) -> URL { - Self.apiURL(pathComponents: ["api", "organizations", organizationName, "invoices"]) + self.apiURL(pathComponents: ["api", "organizations", organizationName, "invoices"]) } // MARK: - Unused credit (prepaid balance) @@ -41,24 +41,24 @@ public enum ReplicateBillingEndpoints: Sendable { /// - **Credit balance** (`creditBalance`): `Number(unused_credit ?? "0")` (string number). /// - `link_to_add_credit` (optional URL string). public static func userUnusedCreditURL(username: String) -> URL { - Self.apiURL(pathComponents: ["api", "users", username, "unused-credit"]) + self.apiURL(pathComponents: ["api", "users", username, "unused-credit"]) } /// GET — org-scoped unused credit. public static func organizationUnusedCreditURL(organizationName: String) -> URL { - Self.apiURL(pathComponents: ["api", "organizations", organizationName, "unused-credit"]) + self.apiURL(pathComponents: ["api", "organizations", organizationName, "unused-credit"]) } // MARK: - Account bootstrap (later tasks) - /// Invoices/credit URLs require `{username}` and account kind (`user` vs `organization`). - /// Bootstrap strategy: with session cookies, GET `dashboardURLString` and parse - /// ` """ @@ -94,8 +94,38 @@ struct ReplicateCookieStrategyTests { #expect { _ = try ReplicateUsageFetcher.resolveAccount(fromBillingHTML: html) } throws: { error in - guard case ReplicateUsageError.parseFailed = error else { return false } + guard case ReplicateUsageError.invalidCredentials = error else { return false } return true } } + + @Test + func `resolveAccount treats missing props scripts as invalid credentials`() { + let html = "

Sign in

" + + #expect { + _ = try ReplicateUsageFetcher.resolveAccount(fromBillingHTML: html) + } throws: { error in + guard case ReplicateUsageError.invalidCredentials = error else { return false } + return true + } + } + + #if os(macOS) + @Test + func `descriptor defaults automatic cookie import to Chrome only`() { + #expect(ProviderDescriptorRegistry.descriptor(for: .replicate).metadata.browserCookieOrder == [.chrome]) + } + #endif + + @Test + func `manual cookie source exempts Linux web CLI gate`() { + let settings = ProviderSettingsSnapshot.make( + replicate: ReplicateProviderSettings(cookieSource: .manual, manualCookieHeader: "sessionid=abc")) + let descriptor = ProviderDescriptorRegistry.descriptor(for: .replicate) + #expect(descriptor.cli.isBrowserSupportExempt( + sourceMode: .auto, + environment: [:], + settings: settings)) + } } diff --git a/Tests/CodexBarTests/ReplicateUsageFetcherTests.swift b/Tests/CodexBarTests/ReplicateUsageFetcherTests.swift index bd10acd0c9..3e94ae14f2 100644 --- a/Tests/CodexBarTests/ReplicateUsageFetcherTests.swift +++ b/Tests/CodexBarTests/ReplicateUsageFetcherTests.swift @@ -55,13 +55,15 @@ struct ReplicateUsageFetcherTests { let summary = try ReplicateUsageFetcher._parseSummaryForTesting( Data(invoicesJSON.utf8), creditData: Data(creditJSON.utf8), - username: "demo") + username: "demo", + accountKind: "user") #expect(abs(summary.currentMonthSpend - 12.4) <= 0.0001) #expect(summary.currencyCode == "USD") #expect(summary.creditBalance == 80.0) #expect(summary.spendLimit == nil) #expect(summary.username == "demo") + #expect(summary.accountKind == "user") let usage = summary.toUsageSnapshot() #expect(usage.primary?.usedPercent == 0) @@ -72,7 +74,7 @@ struct ReplicateUsageFetcherTests { #expect(usage.providerCost?.limit == 0) #expect(usage.providerCost?.balance == 80.0) #expect(usage.identity?.providerID == UsageProvider.replicate.instanceID) - #expect(usage.identity?.accountOrganization == "demo") + #expect(usage.identity?.accountOrganization == nil) #expect(usage.dataConfidence == .exact) let detail = usage.primary?.resetDescription ?? "" @@ -80,6 +82,17 @@ struct ReplicateUsageFetcherTests { #expect(detail.contains("$80.00 credit")) } + @Test + func `organization account populates identity organization`() throws { + let summary = try ReplicateUsageFetcher._parseSummaryForTesting( + Data(Self.invoicesJSON.utf8), + username: "acme", + accountKind: "organization") + + let usage = summary.toUsageSnapshot() + #expect(usage.identity?.accountOrganization == "acme") + } + @Test func `spend only omits missing extras`() throws { let invoicesJSON = """ diff --git a/docs/providers.md b/docs/providers.md index b2a8c83046..02afb25bb1 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -423,7 +423,7 @@ provider-specific cookie validation, endpoints, login detection, and error trans ## Replicate - Session cookie (`sessionid`) from browser auto-import or manual `Cookie:` header. -- Cookie import order: Chrome → Firefox → Safari. Other Chromium forks use Manual mode. Automatic import reads only unexpired cookies from `replicate.com`. +- Automatic import is Chrome-only by default; Firefox, Safari, and other Chromium forks use Manual mode. Automatic import reads only unexpired cookies from `replicate.com`. - Bootstraps `account: { kind, username }` from billing page React props, then reads monthly spend from the invoices API and prepaid credit from the unused-credit API. - The menu bar shows current calendar-month spend in dollars; the provider card shows credit balance when available. - Spend limit is omitted in v1 (no confirmed JSON read API). diff --git a/docs/replicate.md b/docs/replicate.md index 5bddfa6772..bc4bcc8f16 100644 --- a/docs/replicate.md +++ b/docs/replicate.md @@ -17,16 +17,16 @@ Replicate public API tokens cannot supply spend or credit data — CodexBar requ 1. Open **Settings → Providers**. 2. Enable **Replicate**. -3. Sign in to [Replicate Billing](https://replicate.com/account/billing) in Chrome, Firefox, or Safari. +3. Sign in to [Replicate Billing](https://replicate.com/account/billing) in Chrome (Automatic), or any browser if you + will paste a Manual Cookie header. 4. Leave Cookie source on **Automatic**, or switch to **Manual** and paste a `Cookie:` header from a request to `replicate.com`. Manual cookies must include a Django-style `sessionid` cookie. A `csrftoken` cookie is imported when present but is not required for the read-only billing GET requests CodexBar makes. -Automatic import tries Chrome, Firefox (including Developer Edition), then Safari. Safari requires Full Disk Access. -Other Chromium browsers remain available through Manual mode. Automatic import reads only unexpired cookies from -`replicate.com`. +Automatic import is Chrome-only by default (to avoid extra Keychain / Full Disk Access prompts). Use Manual mode for +Firefox, Safari, or other Chromium browsers. Automatic import reads only unexpired cookies from `replicate.com`. ## Data Sources @@ -64,22 +64,19 @@ codexbar usage --provider replicate --verbose ### "No Replicate session cookies found" -Sign in to [Replicate Billing](https://replicate.com/account/billing) in Chrome, Firefox, or Safari, then refresh. +Sign in to [Replicate Billing](https://replicate.com/account/billing) in Chrome (Automatic) or paste a Manual Cookie +header, then refresh. ### "Replicate cookie header is invalid" In manual mode, paste a full `Cookie:` header from a `replicate.com` request. The header must include a `sessionid` -cookie. +cookie. Manual mode also works on Linux CLI without browser import. ### HTTP 401/403 or "Replicate session rejected" The billing session expired or the cookie header is stale. Sign in again, copy a fresh `Cookie:` header, or let -Automatic mode re-import from the browser. - -### Safari automatic import fails - -Grant CodexBar **Full Disk Access** in System Settings → Privacy & Security, then sign in to Replicate in Safari and -refresh. +Automatic mode re-import from Chrome (stale cached headers are cleared when the billing page returns a sign-in +session). ### Credit balance is missing