Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
53 changes: 48 additions & 5 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -586,13 +586,56 @@ private struct UsageMenuCardUsageContentView: View {
let showBottomDivider: Bool
@Environment(\.menuItemHighlighted) private var isHighlighted

/// Doubao ships two subscriptions (Coding Plan + Agent Plan) whose windows
/// share the same period labels. Rendering them as a flat list is confusing,
/// so split by the "doubao-agent-" id prefix and surface two group headers.
private var doubaoSplitMetrics: (coding: [UsageMenuCardView.Model.Metric],
agent: [UsageMenuCardView.Model.Metric])?
{
guard self.model.provider == .doubao else { return nil }
let agent = self.model.metrics.filter { $0.id.hasPrefix("doubao-agent-") }
guard !agent.isEmpty else { return nil }
let coding = self.model.metrics.filter { !$0.id.hasPrefix("doubao-agent-") }
return (coding, agent)
}

@ViewBuilder
private func groupHeader(_ title: String) -> some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.textCase(.uppercase)
}

var body: some View {
VStack(alignment: .leading, spacing: 12) {
ForEach(self.model.metrics, id: \.id) { metric in
MetricRow(
metric: metric,
title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric),
progressColor: self.model.progressColor)
if let split = self.doubaoSplitMetrics {
if !split.coding.isEmpty {
self.groupHeader("Coding Plan")
ForEach(split.coding, id: \.id) { metric in
MetricRow(
metric: metric,
title: UsageMenuCardView
.popupMetricTitle(provider: self.model.provider, metric: metric),
progressColor: self.model.progressColor)
}
}
Divider()
self.groupHeader("Agent Plan")
ForEach(split.agent, id: \.id) { metric in
MetricRow(
metric: metric,
title: UsageMenuCardView
.popupMetricTitle(provider: self.model.provider, metric: metric),
progressColor: self.model.progressColor)
}
} else {
ForEach(self.model.metrics, id: \.id) { metric in
MetricRow(
metric: metric,
title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric),
progressColor: self.model.progressColor)
}
}
if let resetCredits = self.model.codexResetCredits {
if !self.model.metrics.isEmpty {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ struct DoubaoProviderImplementation: ProviderImplementation {
ProviderSettingsFieldDescriptor(
id: "doubao-api-token",
title: "API key / Access key ID",
subtitle: "Use a Volcengine access key ID with the secret field for Coding Plan usage, "
+ "or leave the secret blank to use an Ark API key.",
subtitle: "Install and authenticate 'arkcli' for Coding/Agent Plan usage (preferred), "
+ "or use an Ark API key for rate-limit probing.",
kind: .secure,
placeholder: "ark-... or AKLT...",
binding: context.stringBinding(\.doubaoAPIToken),
Expand All @@ -40,7 +40,7 @@ struct DoubaoProviderImplementation: ProviderImplementation {
ProviderSettingsFieldDescriptor(
id: "doubao-secret-access-key",
title: "Secret access key",
subtitle: "Volcengine secret access key for the signed Coding Plan usage API.",
subtitle: "Optional. Only needed if arkcli is unavailable and you use Volcengine AK/SK signing.",
kind: .secure,
placeholder: "",
binding: context.stringBinding(\.doubaoSecretAccessKey),
Expand Down
8 changes: 7 additions & 1 deletion Sources/CodexBar/Resources/ProviderIcon-doubao.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -54,45 +54,41 @@ public enum DoubaoProviderDescriptor {
struct DoubaoAPIFetchStrategy: ProviderFetchStrategy {
let id: String = "doubao.api"
let kind: ProviderFetchKind = .apiToken
private let codingPlanUsageLoader: @Sendable (DoubaoCodingPlanCredentials) async throws -> DoubaoUsageSnapshot
private let cliUsageLoader: @Sendable () async throws -> DoubaoUsageSnapshot
private let arkUsageLoader: @Sendable (String) async throws -> DoubaoUsageSnapshot

init(
codingPlanUsageLoader: @escaping @Sendable (DoubaoCodingPlanCredentials) async throws
-> DoubaoUsageSnapshot = { credentials in
try await DoubaoUsageFetcher.fetchCodingPlanUsage(credentials: credentials)
},
cliUsageLoader: @escaping @Sendable () async throws -> DoubaoUsageSnapshot = {
try await DoubaoUsageFetcher.fetchCodingPlanUsage()
},
arkUsageLoader: @escaping @Sendable (String) async throws -> DoubaoUsageSnapshot = { apiKey in
try await DoubaoUsageFetcher.fetchUsage(apiKey: apiKey)
})
{
self.codingPlanUsageLoader = codingPlanUsageLoader
self.cliUsageLoader = cliUsageLoader
self.arkUsageLoader = arkUsageLoader
}

func isAvailable(_ context: ProviderFetchContext) async -> Bool {
DoubaoSettingsReader.codingPlanCredentials(environment: context.env) != nil ||
DoubaoAPIFetchStrategy.arkcliInstalled(environment: context.env) ||
ProviderTokenResolver.doubaoToken(environment: context.env) != nil
}

func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
let apiKey = ProviderTokenResolver.doubaoToken(environment: context.env)
if let credentials = DoubaoSettingsReader.codingPlanCredentials(environment: context.env) {
do {
let usage = try await self.codingPlanUsageLoader(credentials)
return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api")
} catch {
if Self.isCancellation(error) {
throw error
}
guard let apiKey else {
throw error
}
let usage = try await self.arkUsageLoader(apiKey)
return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api")
// 1) Try arkcli CLI (SSO-based, no credentials needed).
// The loader throws quickly if arkcli is not installed.
do {
let usage = try await self.cliUsageLoader()
return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "cli")

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 Honor explicit API source before running arkcli

When Doubao is configured with source api, this strategy still runs arkcli first and returns its SSO plan data on success, so users who explicitly selected API key probing—especially because arkcli is logged into a different account or they only want Ark rate-limit headers—never reach the API-key fallback despite .api being an advertised source mode.

Useful? React with 👍 / 👎.

} catch {
if Self.isCancellation(error) {
throw error
}
// Fall through to API key probe
}

// 2) Fall back to Ark API key probe (rate-limit headers)
let apiKey = ProviderTokenResolver.doubaoToken(environment: context.env)
guard let apiKey else {
throw DoubaoUsageError.missingCredentials
}
Expand All @@ -107,4 +103,14 @@ struct DoubaoAPIFetchStrategy: ProviderFetchStrategy {
private static func isCancellation(_ error: Error) -> Bool {
error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled
}

private static func arkcliInstalled(environment: [String: String]) -> Bool {
if let envPath = environment["ARKCLI_PATH"],
FileManager.default.isExecutableFile(atPath: envPath)
{
return true
}
let candidates = ["/usr/local/bin/arkcli", "/opt/homebrew/bin/arkcli"]
return candidates.contains { FileManager.default.isExecutableFile(atPath: $0) }
}
}
Loading