Skip to content
Open
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
18 changes: 18 additions & 0 deletions Sources/CodexBar/Providers/Poe/PoeProviderImplementation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import CodexBarCore
import CodexBarMacroSupport
import Foundation

@ProviderImplementationRegistration
struct PoeProviderImplementation: ProviderImplementation {
let id: UsageProvider = .poe

@MainActor
func isAvailable(context: ProviderAvailabilityContext) -> Bool {
ProviderTokenResolver.poeToken(environment: context.environment) != nil
}

@MainActor
func sourceMode(context _: ProviderSourceModeContext) -> ProviderSourceMode {
.api
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ enum ProviderImplementationRegistry {
case .kimik2: KimiK2ProviderImplementation()
case .amp: AmpProviderImplementation()
case .synthetic: SyntheticProviderImplementation()
case .poe: PoeProviderImplementation()
}
}

Expand Down
5 changes: 5 additions & 0 deletions Sources/CodexBar/Resources/ProviderIcon-poe.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,13 @@ extension UsageStore {
let text = "JetBrains AI debug log not yet implemented"
await MainActor.run { self.probeLogs[.jetbrains] = text }
return text
case .poe:
let resolution = ProviderTokenResolver.poeResolution()
let hasAny = resolution != nil
let source = resolution?.source.rawValue ?? "none"
let text = "POE_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
await MainActor.run { self.probeLogs[.poe] = text }
return text
}
}.value
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBarCLI/TokenAccountCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ struct TokenAccountCLIContext {
return self.makeSnapshot(
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings(
ideBasePath: nil))
case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic:
case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .poe:
return nil
}
}
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBarCore/Logging/LogCategories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public enum LogCategories {
public static let openAIWeb = "openai-web"
public static let openAIWebview = "openai-webview"
public static let opencodeUsage = "opencode-usage"
public static let poeUsage = "poe-usage"
public static let providerDetection = "provider-detection"
public static let providers = "providers"
public static let sessionQuota = "sessionQuota"
Expand Down
56 changes: 56 additions & 0 deletions Sources/CodexBarCore/Providers/Poe/PoeModels.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Foundation

public struct PoeBalanceResponse: Decodable, Sendable {
public let currentPointBalance: Int

private enum CodingKeys: String, CodingKey {
case currentPointBalance = "current_point_balance"
}
}

public struct PoeUsageSnapshot: Sendable {
public let pointBalance: Int
public let updatedAt: Date

public init(pointBalance: Int, updatedAt: Date) {
self.pointBalance = pointBalance
self.updatedAt = updatedAt
}

public func toUsageSnapshot() -> UsageSnapshot {
let formatted = Self.formatPoints(self.pointBalance)

let rateWindow = RateWindow(
usedPercent: 0,
windowMinutes: nil,
resetsAt: nil,
resetDescription: formatted)

let identity = ProviderIdentitySnapshot(
providerID: .poe,
accountEmail: nil,
accountOrganization: nil,
loginMethod: nil)

return UsageSnapshot(
primary: rateWindow,
secondary: nil,
tertiary: nil,
providerCost: nil,
updatedAt: self.updatedAt,
identity: identity)
}

public static func formatPoints(_ points: Int) -> String {
switch points {
case 1_000_000_000...:
return String(format: "%.1fB pts", Double(points) / 1_000_000_000)
case 1_000_000...:
return String(format: "%.1fM pts", Double(points) / 1_000_000)
case 1_000...:
return String(format: "%.1fK pts", Double(points) / 1_000)
default:
return "\(points) pts"
}
}
}
66 changes: 66 additions & 0 deletions Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import CodexBarMacroSupport
import Foundation

@ProviderDescriptorRegistration
@ProviderDescriptorDefinition
public enum PoeProviderDescriptor {
static func makeDescriptor() -> ProviderDescriptor {
ProviderDescriptor(
id: .poe,
metadata: ProviderMetadata(
id: .poe,
displayName: "Poe",
sessionLabel: "Points",
weeklyLabel: "",
opusLabel: nil,
supportsOpus: false,
supportsCredits: false,
creditsHint: "",
toggleTitle: "Show Poe usage",
cliName: "poe",
defaultEnabled: false,
isPrimaryProvider: false,
usesAccountFallback: false,
browserCookieOrder: nil,
dashboardURL: "https://poe.com",
statusPageURL: nil),
branding: ProviderBranding(
iconStyle: .poe,
iconResourceName: "ProviderIcon-poe",
color: ProviderColor(red: 101 / 255, green: 78 / 255, blue: 163 / 255)),
tokenCost: ProviderTokenCostConfig(
supportsTokenCost: false,
noDataMessage: { "Poe cost summary is not available." }),
fetchPlan: ProviderFetchPlan(
sourceModes: [.auto, .api],
pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [PoeAPIFetchStrategy()] })),
cli: ProviderCLIConfig(
name: "poe",
versionDetector: nil))
}
}

struct PoeAPIFetchStrategy: ProviderFetchStrategy {
let id: String = "poe.api"
let kind: ProviderFetchKind = .apiToken

func isAvailable(_ context: ProviderFetchContext) async -> Bool {
Self.resolveToken(environment: context.env) != nil
}

func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
guard let apiKey = Self.resolveToken(environment: context.env) else {
throw PoeUsageError.missingCredentials
}
let usage = try await PoeUsageFetcher.fetchUsage(apiKey: apiKey)
return self.makeResult(
usage: usage.toUsageSnapshot(),
sourceLabel: "api")
}

func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { false }

private static func resolveToken(environment: [String: String]) -> String? {
ProviderTokenResolver.poeToken(environment: environment)
}
}
74 changes: 74 additions & 0 deletions Sources/CodexBarCore/Providers/Poe/PoeUsageFetcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public enum PoeUsageError: LocalizedError, Sendable {
case missingCredentials
case networkError(String)
case apiError(String)
case parseFailed(String)

public var errorDescription: String? {
switch self {
case .missingCredentials:
"Missing POE_API_KEY environment variable."
case let .networkError(message):
"Poe network error: \(message)"
case let .apiError(message):
"Poe API error: \(message)"
case let .parseFailed(message):
"Failed to parse Poe response: \(message)"
}
}
}

public struct PoeUsageFetcher: Sendable {
private static let log = CodexBarLog.logger(LogCategories.poeUsage)
private static let balanceURL = URL(string: "https://api.poe.com/usage/current_balance")!

public static func fetchUsage(apiKey: String) async throws -> PoeUsageSnapshot {
guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw PoeUsageError.missingCredentials
}

var request = URLRequest(url: self.balanceURL)
request.httpMethod = "GET"
request.timeoutInterval = 30
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")

let (data, response) = try await URLSession.shared.data(for: request)

guard let httpResponse = response as? HTTPURLResponse else {
throw PoeUsageError.networkError("Invalid response")
}

guard httpResponse.statusCode == 200 else {
let body = String(data: data, encoding: .utf8) ?? "HTTP \(httpResponse.statusCode)"
Self.log.error("Poe API returned \(httpResponse.statusCode): \(body)")
throw PoeUsageError.apiError(body)
}

if let jsonString = String(data: data, encoding: .utf8) {
Self.log.debug("Poe API response: \(jsonString)")
}

return try Self.parseBalance(from: data)
}

/// Internal method for testing response parsing.
public static func _parseBalanceForTesting(_ data: Data) throws -> PoeUsageSnapshot {
try self.parseBalance(from: data)
}

private static func parseBalance(from data: Data) throws -> PoeUsageSnapshot {
do {
let decoded = try JSONDecoder().decode(PoeBalanceResponse.self, from: data)
return PoeUsageSnapshot(pointBalance: decoded.currentPointBalance, updatedAt: Date())
} catch {
Self.log.error("Poe JSON decoding error: \(error.localizedDescription)")
throw PoeUsageError.parseFailed(error.localizedDescription)
}
}
}
1 change: 1 addition & 0 deletions Sources/CodexBarCore/Providers/ProviderDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ public enum ProviderDescriptorRegistry {
.kimik2: KimiK2ProviderDescriptor.descriptor,
.amp: AmpProviderDescriptor.descriptor,
.synthetic: SyntheticProviderDescriptor.descriptor,
.poe: PoeProviderDescriptor.descriptor,
]
private static let bootstrap: Void = {
for provider in UsageProvider.allCases {
Expand Down
10 changes: 10 additions & 0 deletions Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ public enum ProviderTokenResolver {
self.kimiK2Resolution(environment: environment)?.token
}

public static func poeToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
self.poeResolution(environment: environment)?.token
}

public static func zaiResolution(
environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
{
Expand Down Expand Up @@ -100,6 +104,12 @@ public enum ProviderTokenResolver {
self.resolveEnv(KimiK2SettingsReader.apiKey(environment: environment))
}

public static func poeResolution(
environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
{
self.resolveEnv(self.cleaned(environment["POE_API_KEY"]))
}

private static func cleaned(_ raw: String?) -> String? {
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBarCore/Providers/Providers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case kimik2
case amp
case synthetic
case poe
}

// swiftformat:enable sortDeclarations
Expand All @@ -44,6 +45,7 @@ public enum IconStyle: Sendable, CaseIterable {
case jetbrains
case amp
case synthetic
case poe
case combined
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ enum CostUsageScanner {
return CostUsageDailyReport(data: [], summary: nil)
case .synthetic:
return CostUsageDailyReport(data: [], summary: nil)
case .poe:
return CostUsageDailyReport(data: [], summary: nil)
}
}

Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBarWidget/CodexBarWidgetProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ enum ProviderChoice: String, AppEnum {
case .kimik2: return nil // Kimi K2 not yet supported in widgets
case .amp: return nil // Amp not yet supported in widgets
case .synthetic: return nil // Synthetic not yet supported in widgets
case .poe: return nil // Poe not yet supported in widgets
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBarWidget/CodexBarWidgetViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ private struct ProviderSwitchChip: View {
case .kimik2: "Kimi K2"
case .amp: "Amp"
case .synthetic: "Synthetic"
case .poe: "Poe"
}
}
}
Expand Down Expand Up @@ -605,6 +606,8 @@ enum WidgetColors {
Color(red: 220 / 255, green: 38 / 255, blue: 38 / 255) // Amp red
case .synthetic:
Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255) // Synthetic charcoal
case .poe:
Color(red: 101 / 255, green: 78 / 255, blue: 163 / 255) // Poe purple
}
}
}
Expand Down
49 changes: 49 additions & 0 deletions Tests/CodexBarTests/PoeModelsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import CodexBarCore
import Foundation
import Testing

@Suite
struct PoeModelsTests {
@Test
func formatPointsRawNumbers() {
#expect(PoeUsageSnapshot.formatPoints(0) == "0 pts")
#expect(PoeUsageSnapshot.formatPoints(1) == "1 pts")
#expect(PoeUsageSnapshot.formatPoints(500) == "500 pts")
#expect(PoeUsageSnapshot.formatPoints(999) == "999 pts")
}

@Test
func formatPointsThousands() {
#expect(PoeUsageSnapshot.formatPoints(1_000) == "1.0K pts")
#expect(PoeUsageSnapshot.formatPoints(1_500) == "1.5K pts")
#expect(PoeUsageSnapshot.formatPoints(10_000) == "10.0K pts")
#expect(PoeUsageSnapshot.formatPoints(999_999) == "1000.0K pts")
}

@Test
func formatPointsMillions() {
#expect(PoeUsageSnapshot.formatPoints(1_000_000) == "1.0M pts")
#expect(PoeUsageSnapshot.formatPoints(1_500_000) == "1.5M pts")
#expect(PoeUsageSnapshot.formatPoints(295_932_027) == "295.9M pts")
#expect(PoeUsageSnapshot.formatPoints(999_999_999) == "1000.0M pts")
}

@Test
func formatPointsBillions() {
#expect(PoeUsageSnapshot.formatPoints(1_000_000_000) == "1.0B pts")
#expect(PoeUsageSnapshot.formatPoints(1_500_000_000) == "1.5B pts")
#expect(PoeUsageSnapshot.formatPoints(10_000_000_000) == "10.0B pts")
}

@Test
func toUsageSnapshotCreatesValidSnapshot() {
let snapshot = PoeUsageSnapshot(pointBalance: 295_932_027, updatedAt: Date())
let usageSnapshot = snapshot.toUsageSnapshot()

#expect(usageSnapshot.primary?.resetDescription == "295.9M pts")
#expect(usageSnapshot.primary?.usedPercent == 0)
#expect(usageSnapshot.identity?.providerID == .poe)
#expect(usageSnapshot.secondary == nil)
#expect(usageSnapshot.tertiary == nil)
}
}
Loading