diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift
index 503b282f45..beb101f6ab 100644
--- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift
+++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift
@@ -678,9 +678,12 @@ extension UsageMenuCardView.Model {
} else {
L("Unavailable")
}
+ let title = input.provider == .doubao && namedWindow.id.contains("-team-")
+ ? "\(L(namedWindow.title)) (\(L("Team")))"
+ : L(namedWindow.title)
return Metric(
id: namedWindow.id,
- title: namedWindow.title,
+ title: title,
percent: Self.clamped(
input.usageBarsShowUsed
? namedWindow.window.usedPercent
diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift
index 51b49f6c0f..5e8840021c 100644
--- a/Sources/CodexBar/MenuCardView.swift
+++ b/Sources/CodexBar/MenuCardView.swift
@@ -612,13 +612,50 @@ private struct UsageMenuCardUsageContentView: View {
let showBottomDivider: Bool
@Environment(\.menuItemHighlighted) private var isHighlighted
+ /// Doubao ships Coding Plan and Agent Plan subscriptions, each with personal
+ /// and team editions whose windows share period labels. Split the two plan
+ /// families here; team rows keep distinct ids and disclose their edition.
+ 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)
+ }
+
+ private func groupHeader(_ title: String) -> some View {
+ Text(L(title))
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
+ .textCase(.uppercase)
+ }
+
+ private func metricRows(_ metrics: [UsageMenuCardView.Model.Metric]) -> some View {
+ ForEach(metrics, id: \.id) { metric in
+ MetricRow(
+ metric: metric,
+ title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric),
+ progressColor: self.model.progressColor)
+ }
+ }
+
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")
+ self.metricRows(split.coding)
+ }
+ if !split.coding.isEmpty {
+ Divider()
+ }
+ self.groupHeader("Agent Plan")
+ self.metricRows(split.agent)
+ } else {
+ self.metricRows(self.model.metrics)
}
if let resetCredits = self.model.codexResetCredits {
if !self.model.metrics.isEmpty {
diff --git a/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift b/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift
index 182ff06cc3..81f1fde4f7 100644
--- a/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift
+++ b/Sources/CodexBar/Providers/Doubao/DoubaoProviderImplementation.swift
@@ -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: "Without configured API credentials, install and authenticate 'arkcli' for "
+ + "Coding/Agent Plan usage. Existing API credentials remain authoritative.",
kind: .secure,
placeholder: "ark-... or AKLT...",
binding: context.stringBinding(\.doubaoAPIToken),
@@ -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),
diff --git a/Sources/CodexBar/Resources/ProviderIcon-doubao.svg b/Sources/CodexBar/Resources/ProviderIcon-doubao.svg
index 9c20430a1c..c5205ce6ff 100644
--- a/Sources/CodexBar/Resources/ProviderIcon-doubao.svg
+++ b/Sources/CodexBar/Resources/ProviderIcon-doubao.svg
@@ -1 +1,7 @@
-
+
diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings
index 077c848338..0c87808371 100644
--- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings
@@ -1267,6 +1267,9 @@
"By subscription" = "حسب الاشتراك";
"No model-level history" = "لا يوجد سجل على مستوى النموذج";
"Daily estimated spend" = "الإنفاق اليومي التقديري";
+"Coding Plan" = "خطة البرمجة";
+"Agent Plan" = "خطة الوكيل";
+"Team" = "فريق";
/* Menu bar layout editor */
"menu_bar_layout_title" = "التخطيط";
diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings
index 7b9ee4fa16..36289fd751 100644
--- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings
@@ -1266,6 +1266,9 @@
"By subscription" = "Per subscripció";
"No model-level history" = "Sense historial per model";
"Daily estimated spend" = "Despesa diària estimada";
+"Coding Plan" = "Pla de programació";
+"Agent Plan" = "Pla d'agent";
+"Team" = "Equip";
/* Menu bar layout editor */
"menu_bar_layout_title" = "Disposició";
diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings
index 1e8980d48e..a58da4b4b4 100644
--- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings
@@ -1264,6 +1264,9 @@
"By subscription" = "Nach Abonnement";
"No model-level history" = "Kein Verlauf auf Modellebene";
"Daily estimated spend" = "Geschätzte tägliche Ausgaben";
+"Coding Plan" = "Coding-Plan";
+"Agent Plan" = "Agentenplan";
+"Team" = "Team";
/* Menu bar layout editor */
"menu_bar_layout_title" = "Layout";
diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings
index 51cd036ce1..0ff38c2a2d 100644
--- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings
@@ -1268,6 +1268,9 @@
"By subscription" = "By subscription";
"No model-level history" = "No model-level history";
"Daily estimated spend" = "Daily estimated spend";
+"Coding Plan" = "Coding Plan";
+"Agent Plan" = "Agent Plan";
+"Team" = "Team";
/* Menu bar layout editor */
"menu_bar_layout_title" = "Layout";
diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings
index 200465bc90..26ed403f17 100644
--- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings
@@ -1262,6 +1262,9 @@
"By subscription" = "Por suscripción";
"No model-level history" = "No hay historial por modelo";
"Daily estimated spend" = "Gasto diario estimado";
+"Coding Plan" = "Plan de programación";
+"Agent Plan" = "Plan de agente";
+"Team" = "Equipo";
/* Menu bar layout editor */
"menu_bar_layout_title" = "Disposición";
diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings
index 868cd57a9b..7355b94926 100644
--- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings
@@ -1267,6 +1267,9 @@
"By subscription" = "بر اساس اشتراک";
"No model-level history" = "تاریخچهای در سطح مدل وجود ندارد";
"Daily estimated spend" = "برآورد هزینه روزانه";
+"Coding Plan" = "طرح کدنویسی";
+"Agent Plan" = "طرح عامل";
+"Team" = "تیم";
/* Menu bar layout editor */
"menu_bar_layout_title" = "چیدمان";
diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings
index 81569412da..a3b8532fff 100644
--- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings
@@ -1263,6 +1263,9 @@
"By subscription" = "Par abonnement";
"No model-level history" = "Aucun historique au niveau des modèles";
"Daily estimated spend" = "Dépenses quotidiennes estimées";
+"Coding Plan" = "Plan de codage";
+"Agent Plan" = "Plan d'agent";
+"Team" = "Équipe";
/* Menu bar layout editor */
"menu_bar_layout_title" = "Disposition";
diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings
index 678bd8f357..df59bf01f7 100644
--- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings
@@ -1263,6 +1263,9 @@
"By subscription" = "Por subscrición";
"No model-level history" = "Sen historial por modelo";
"Daily estimated spend" = "Gasto diario estimado";
+"Coding Plan" = "Plan de programación";
+"Agent Plan" = "Plan de axente";
+"Team" = "Equipo";
/* Menu bar layout editor */
"menu_bar_layout_title" = "Disposición";
diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings
index 4352458b4d..b8bf4281b3 100644
--- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings
@@ -1267,6 +1267,9 @@
"By subscription" = "Berdasarkan langganan";
"No model-level history" = "Tidak ada riwayat tingkat model";
"Daily estimated spend" = "Perkiraan pengeluaran harian";
+"Coding Plan" = "Paket Coding";
+"Agent Plan" = "Paket Agen";
+"Team" = "Tim";
/* Menu bar layout editor */
"menu_bar_layout_title" = "Tata letak";
diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings
index db8f5e3cff..30946bf309 100644
--- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings
@@ -1267,6 +1267,9 @@
"By subscription" = "Per abbonamento";
"No model-level history" = "Nessuna cronologia a livello di modello";
"Daily estimated spend" = "Spesa giornaliera stimata";
+"Coding Plan" = "Piano di codifica";
+"Agent Plan" = "Piano agente";
+"Team" = "Squadra";
/* Menu bar layout editor */
"menu_bar_layout_title" = "Disposizione";
diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings
index c3702a7a50..2eb5ccb2cf 100644
--- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings
@@ -1264,6 +1264,9 @@
"By subscription" = "サブスクリプション別";
"No model-level history" = "モデル別の履歴はありません";
"Daily estimated spend" = "日別推定支出";
+"Coding Plan" = "コーディングプラン";
+"Agent Plan" = "エージェントプラン";
+"Team" = "チーム";
/* Menu bar layout editor */
"menu_bar_layout_title" = "レイアウト";
diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings
index 62f5491d94..f2ad26e0d6 100644
--- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings
@@ -1231,6 +1231,9 @@
"By subscription" = "구독별";
"No model-level history" = "모델별 내역이 없습니다";
"Daily estimated spend" = "일별 예상 지출";
+"Coding Plan" = "코딩 요금제";
+"Agent Plan" = "에이전트 요금제";
+"Team" = "팀";
/* Menu bar layout editor */
"menu_bar_layout_title" = "레이아웃";
diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings
index b00d75f5e5..f8907e0f0f 100644
--- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings
@@ -1263,6 +1263,9 @@
"By subscription" = "Per abonnement";
"No model-level history" = "Geen geschiedenis op modelniveau";
"Daily estimated spend" = "Geschatte dagelijkse uitgaven";
+"Coding Plan" = "Codeerplan";
+"Agent Plan" = "Agentplan";
+"Team" = "Team";
/* Menu bar layout editor */
"menu_bar_layout_title" = "Indeling";
diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings
index 8a4d9d82fe..6d94faa9d7 100644
--- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings
@@ -1267,6 +1267,9 @@
"By subscription" = "Według subskrypcji";
"No model-level history" = "Brak historii na poziomie modeli";
"Daily estimated spend" = "Szacowane dzienne wydatki";
+"Coding Plan" = "Plan kodowania";
+"Agent Plan" = "Plan agenta";
+"Team" = "Zespół";
/* Menu bar layout editor */
"menu_bar_layout_title" = "Układ";
diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings
index 247538bd87..94c837bea8 100644
--- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings
@@ -1264,6 +1264,9 @@
"By subscription" = "Por assinatura";
"No model-level history" = "Sem histórico por modelo";
"Daily estimated spend" = "Gasto diário estimado";
+"Coding Plan" = "Plano de codificação";
+"Agent Plan" = "Plano de agente";
+"Team" = "Equipe";
/* Menu bar layout editor */
"menu_bar_layout_title" = "Layout";
diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings
index 62b4512f2a..3d40ae59e8 100644
--- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings
@@ -1265,6 +1265,9 @@
"By subscription" = "По подпискам";
"No model-level history" = "Нет истории по моделям";
"Daily estimated spend" = "Предполагаемые ежедневные расходы";
+"Coding Plan" = "План программирования";
+"Agent Plan" = "План агента";
+"Team" = "Команда";
/* Menu bar layout editor */
"menu_bar_layout_title" = "Компоновка";
diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings
index 62ccbf16a5..1af0127ae7 100644
--- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings
@@ -1262,6 +1262,9 @@
"By subscription" = "Per abonnemang";
"No model-level history" = "Ingen historik på modellnivå";
"Daily estimated spend" = "Uppskattade dagliga utgifter";
+"Coding Plan" = "Kodningsplan";
+"Agent Plan" = "Agentplan";
+"Team" = "Team";
/* Menu bar layout editor */
"menu_bar_layout_title" = "Layout";
diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings
index 350ad14475..2192e0d551 100644
--- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings
@@ -1267,6 +1267,9 @@
"By subscription" = "แยกตามการสมัครสมาชิก";
"No model-level history" = "ไม่มีประวัติระดับโมเดล";
"Daily estimated spend" = "ค่าใช้จ่ายรายวันโดยประมาณ";
+"Coding Plan" = "แผนการเขียนโค้ด";
+"Agent Plan" = "แผนเอเจนต์";
+"Team" = "ทีม";
/* Menu bar layout editor */
"menu_bar_layout_title" = "เค้าโครง";
diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings
index fdd3351321..79c79aeb4c 100644
--- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings
@@ -1265,6 +1265,9 @@
"By subscription" = "Aboneliğe göre";
"No model-level history" = "Model düzeyinde geçmiş yok";
"Daily estimated spend" = "Günlük tahmini harcama";
+"Coding Plan" = "Kodlama Planı";
+"Agent Plan" = "Ajan Planı";
+"Team" = "Ekip";
/* Menu bar layout editor */
"menu_bar_layout_title" = "Düzen";
diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings
index ecbd28f227..27f0939d77 100644
--- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings
@@ -1263,6 +1263,9 @@
"By subscription" = "За підписками";
"No model-level history" = "Немає історії за моделями";
"Daily estimated spend" = "Орієнтовні щоденні витрати";
+"Coding Plan" = "План кодування";
+"Agent Plan" = "План агента";
+"Team" = "Команда";
/* Menu bar layout editor */
"menu_bar_layout_title" = "Компонування";
diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings
index f07c1ecdd8..2ba6d241b8 100644
--- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings
@@ -1264,6 +1264,9 @@
"By subscription" = "Theo gói đăng ký";
"No model-level history" = "Không có lịch sử theo mô hình";
"Daily estimated spend" = "Chi tiêu ước tính hằng ngày";
+"Coding Plan" = "Gói lập trình";
+"Agent Plan" = "Gói tác nhân";
+"Team" = "Nhóm";
/* Menu bar layout editor */
"menu_bar_layout_title" = "Bố cục";
diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings
index b5b51641b2..a9343596b5 100644
--- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings
@@ -1239,6 +1239,9 @@
"By subscription" = "按订阅";
"No model-level history" = "暂无模型级历史";
"Daily estimated spend" = "每日估算支出";
+"Coding Plan" = "编程套餐";
+"Agent Plan" = "智能体套餐";
+"Team" = "团队";
/* Menu bar layout editor */
"menu_bar_layout_title" = "布局";
diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings
index 43dc011550..71ba16a0c7 100644
--- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings
+++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings
@@ -1294,6 +1294,9 @@
"By subscription" = "依訂閱";
"No model-level history" = "尚無模型層級歷史";
"Daily estimated spend" = "每日預估支出";
+"Coding Plan" = "程式設計方案";
+"Agent Plan" = "智慧體方案";
+"Team" = "團隊";
/* Menu bar layout editor */
"menu_bar_layout_title" = "佈局";
diff --git a/Sources/CodexBarCore/Hooks/HookRunner.swift b/Sources/CodexBarCore/Hooks/HookRunner.swift
index 359ac38ca9..c318fd237d 100644
--- a/Sources/CodexBarCore/Hooks/HookRunner.swift
+++ b/Sources/CodexBarCore/Hooks/HookRunner.swift
@@ -101,6 +101,7 @@ public enum HookRunner {
case .binaryNotFound: return "executable not found"
case .launchFailed: return "launch failed"
case .timedOut: return "timed out"
+ case .outputTooLarge: return "output too large"
case let .nonZeroExit(code, _): return "exit \(code)"
}
}
diff --git a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift
index 058befb693..229fa74227 100644
--- a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift
+++ b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift
@@ -11,6 +11,7 @@ public enum SubprocessRunnerError: LocalizedError, Sendable {
case binaryNotFound(String)
case launchFailed(String)
case timedOut(String)
+ case outputTooLarge(String)
case nonZeroExit(code: Int32, stderr: String)
public var errorDescription: String? {
@@ -21,6 +22,8 @@ public enum SubprocessRunnerError: LocalizedError, Sendable {
return "Failed to launch process: \(details)"
case let .timedOut(label):
return "Command timed out: \(label)"
+ case let .outputTooLarge(label):
+ return "Command produced too much output: \(label)"
case let .nonZeroExit(code, stderr):
let trimmed = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
@@ -176,6 +179,9 @@ public enum SubprocessRunner {
arguments: [String],
environment: [String: String],
timeout: TimeInterval,
+ // Preserve the legacy bounded-prefix capture when omitted. Structured-output callers can opt into
+ // fail-closed rejection by supplying an explicit limit.
+ maxOutputBytes: Int? = nil,
standardInput: Any? = nil,
currentDirectoryURL: URL? = nil,
acceptsNonZeroExit: Bool = false,
@@ -202,8 +208,12 @@ public enum SubprocessRunner {
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.standardInput = standardInput
- let stdoutCapture = ProcessPipeCapture(pipe: stdoutPipe)
- let stderrCapture = ProcessPipeCapture(pipe: stderrPipe)
+ let normalizedMaxOutputBytes = maxOutputBytes.map { max(0, $0) }
+ let captureMaxBytes = normalizedMaxOutputBytes.map { limit in
+ limit == Int.max ? Int.max : limit + 1
+ } ?? ProcessPipeCapture.defaultMaxBytes
+ let stdoutCapture = ProcessPipeCapture(pipe: stdoutPipe, maxBytes: captureMaxBytes)
+ let stderrCapture = ProcessPipeCapture(pipe: stderrPipe, maxBytes: captureMaxBytes)
let termination = ProcessTermination()
process.terminationHandler = { process in
@@ -276,8 +286,18 @@ public enum SubprocessRunner {
async let stdoutData = stdoutCapture.finish(timeout: .seconds(1))
async let stderrData = stderrCapture.finish(timeout: .seconds(1))
- let stdout = await ProcessPipeCapture.decodeUTF8(stdoutData)
- let stderr = await ProcessPipeCapture.decodeUTF8(stderrData)
+ let capturedStdout = await stdoutData
+ let capturedStderr = await stderrData
+ if let normalizedMaxOutputBytes,
+ capturedStdout.count > normalizedMaxOutputBytes || capturedStderr.count > normalizedMaxOutputBytes
+ {
+ self.log.warning(
+ "Subprocess output exceeded memory limit",
+ metadata: ["label": label, "binary": binaryName])
+ throw SubprocessRunnerError.outputTooLarge(label)
+ }
+ let stdout = ProcessPipeCapture.decodeUTF8(capturedStdout)
+ let stderr = ProcessPipeCapture.decodeUTF8(capturedStderr)
if exitCode != 0, !acceptsNonZeroExit {
let duration = Date().timeIntervalSince(start)
diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift
index e7ae0eff95..5310e2767d 100644
--- a/Sources/CodexBarCore/PathEnvironment.swift
+++ b/Sources/CodexBarCore/PathEnvironment.swift
@@ -76,6 +76,31 @@ public enum BinaryLocator {
home: home)
}
+ public static func resolveArkcliBinary(
+ env: [String: String] = ProcessInfo.processInfo.environment,
+ loginPATH: [String]? = LoginShellPathCache.shared.current,
+ commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV,
+ aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator
+ .resolveAlias,
+ fileManager: FileManager = .default,
+ home: String = NSHomeDirectory()) -> String?
+ {
+ self.resolveBinary(
+ name: "arkcli",
+ overrideKey: "ARKCLI_PATH",
+ env: env,
+ loginPATH: loginPATH,
+ commandV: commandV,
+ aliasResolver: aliasResolver,
+ wellKnownPaths: [
+ "\(home)/.local/bin/arkcli",
+ "/opt/homebrew/bin/arkcli",
+ "/usr/local/bin/arkcli",
+ ],
+ fileManager: fileManager,
+ home: home)
+ }
+
/// Well-known installation paths for the Claude CLI binary.
/// Covers Anthropic's native installer (`~/.local/bin`), the `claude migrate-installer`
/// self-updating location (`~/.claude/local`), the legacy per-user installer
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
index 7c16d36a56..b17a39aeb1 100644
--- a/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoProviderDescriptor.swift
@@ -45,25 +45,81 @@ public enum DoubaoProviderDescriptor {
supportsTokenCost: false,
noDataMessage: { "Doubao cost summary is not available." }),
fetchPlan: ProviderFetchPlan(
- sourceModes: [.auto, .api],
- pipeline: ProviderFetchPipeline(resolveStrategies: { _ in
- [DoubaoAPIFetchStrategy()]
- })),
+ sourceModes: [.auto, .cli, .api],
+ pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)),
cli: ProviderCLIConfig(
name: "doubao",
aliases: ["volcengine", "ark", "bytedance"],
versionDetector: nil))
}
+
+ static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] {
+ switch context.sourceMode {
+ case .auto:
+ // Persisted credentials identify a specific account. Do not let an ambient arkcli
+ // session silently replace it with another account in auto mode.
+ if self.hasConfiguredAPICredentials(environment: context.env) {
+ [DoubaoAPIFetchStrategy()]
+ } else {
+ [DoubaoCLIFetchStrategy()]
+ }
+ case .cli:
+ // Explicit CLI source: arkcli only, no API fallback.
+ [DoubaoCLIFetchStrategy()]
+ case .api:
+ // Explicit API source: AK/SK signed or API key probe only, no SSO fallback.
+ [DoubaoAPIFetchStrategy()]
+ case .web, .oauth:
+ []
+ }
+ }
+
+ private static func hasConfiguredAPICredentials(environment: [String: String]) -> Bool {
+ DoubaoSettingsReader.codingPlanCredentials(environment: environment) != nil ||
+ ProviderTokenResolver.doubaoToken(environment: environment) != nil
+ }
+}
+
+// MARK: - CLI strategy (arkcli SSO)
+
+struct DoubaoCLIFetchStrategy: ProviderFetchStrategy {
+ let id: String = "doubao.cli"
+ let kind: ProviderFetchKind = .cli
+ private let cliUsageLoader: @Sendable ([String: String]) async throws -> DoubaoUsageSnapshot
+
+ init(
+ cliUsageLoader: @escaping @Sendable ([String: String]) async throws -> DoubaoUsageSnapshot = { environment in
+ try await DoubaoUsageFetcher.fetchCodingPlanUsage(environment: environment)
+ })
+ {
+ self.cliUsageLoader = cliUsageLoader
+ }
+
+ func isAvailable(_: ProviderFetchContext) async -> Bool {
+ // Keep the strategy available so missing CLI and login failures surface as actionable errors.
+ true
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ let usage = try await self.cliUsageLoader(context.env)
+ return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "cli")
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
}
+// MARK: - API strategy (AK/SK signed + Ark API key probe)
+
struct DoubaoAPIFetchStrategy: ProviderFetchStrategy {
let id: String = "doubao.api"
let kind: ProviderFetchKind = .apiToken
- private let codingPlanUsageLoader: @Sendable (DoubaoCodingPlanCredentials) async throws -> DoubaoUsageSnapshot
+ private let signedUsageLoader: @Sendable (DoubaoCodingPlanCredentials) async throws -> DoubaoUsageSnapshot
private let arkUsageLoader: @Sendable (String) async throws -> DoubaoUsageSnapshot
init(
- codingPlanUsageLoader: @escaping @Sendable (DoubaoCodingPlanCredentials) async throws
+ signedUsageLoader: @escaping @Sendable (DoubaoCodingPlanCredentials) async throws
-> DoubaoUsageSnapshot = { credentials in
try await DoubaoUsageFetcher.fetchCodingPlanUsage(credentials: credentials)
},
@@ -71,41 +127,47 @@ struct DoubaoAPIFetchStrategy: ProviderFetchStrategy {
try await DoubaoUsageFetcher.fetchUsage(apiKey: apiKey)
})
{
- self.codingPlanUsageLoader = codingPlanUsageLoader
+ self.signedUsageLoader = signedUsageLoader
self.arkUsageLoader = arkUsageLoader
}
func isAvailable(_ context: ProviderFetchContext) async -> Bool {
- DoubaoSettingsReader.codingPlanCredentials(environment: context.env) != nil ||
+ // Explicit API mode always runs so a missing key surfaces an error.
+ // Auto mode only tries API when credentials are resolvable.
+ context.sourceMode == .api ||
+ DoubaoSettingsReader.codingPlanCredentials(environment: context.env) != nil ||
ProviderTokenResolver.doubaoToken(environment: context.env) != nil
}
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
let apiKey = ProviderTokenResolver.doubaoToken(environment: context.env)
+ var signedError: Error?
+
+ // 1) Try AK/SK signed Coding Plan usage (legacy Volcengine API).
if let credentials = DoubaoSettingsReader.codingPlanCredentials(environment: context.env) {
do {
- let usage = try await self.codingPlanUsageLoader(credentials)
+ let usage = try await self.signedUsageLoader(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")
+ // Preserve the signed error so it surfaces when there is no API key to fall back to.
+ signedError = error
}
}
+ // 2) Fall back to Ark API key probe (rate-limit headers).
guard let apiKey else {
- throw DoubaoUsageError.missingCredentials
+ // If the signed request failed, surface that error instead of a generic "missing key".
+ throw signedError ?? DoubaoUsageError.missingCredentials
}
let usage = try await self.arkUsageLoader(apiKey)
return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api")
}
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ // API strategy never falls back to CLI; explicit API mode stays strict.
false
}
diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
index cc5b7a06df..53d6a213ed 100644
--- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift
@@ -67,9 +67,34 @@ public struct DoubaoUsageSnapshot: Sendable {
primary: primary,
secondary: nil,
tertiary: nil,
+ extraRateWindows: nil,
+ kiroUsage: nil,
+ ampUsage: nil,
providerCost: nil,
+ zaiUsage: nil,
+ minimaxUsage: nil,
+ deepseekUsage: nil,
+ mimoUsage: nil,
+ openRouterUsage: nil,
+ sakanaPayAsYouGo: nil,
+ clawRouterUsage: nil,
+ sub2APIUsage: nil,
+ wayfinderUsage: nil,
+ openAIAPIUsage: nil,
+ codexResetCredits: nil,
+ claudeAdminAPIUsage: nil,
+ mistralUsage: nil,
+ deepgramUsage: nil,
+ poeUsage: nil,
+ cursorRequests: nil,
+ commandCodeSubscriptionEnrichmentUnavailable: false,
+ commandCodeHasSubscriptionPlan: false,
+ commandCodeMonthlyGrantDepleted: false,
+ subscriptionExpiresAt: nil,
+ subscriptionRenewsAt: nil,
updatedAt: self.updatedAt,
- identity: identity)
+ identity: identity,
+ dataConfidence: .unknown)
}
}
@@ -97,9 +122,47 @@ public struct DoubaoCodingPlanUsage: Sendable, Equatable {
}
public func toUsageSnapshot(updatedAt: Date) -> UsageSnapshot {
- let primary = self.rateWindow(levels: ["session", "5-hour", "five_hour"], minutes: 5 * 60)
- let secondary = self.rateWindow(levels: ["weekly", "week"], minutes: 7 * 24 * 60)
- let tertiary = self.rateWindow(levels: ["monthly", "month"], minutes: 30 * 24 * 60)
+ let codingPrimary = self.rateWindow(levels: ["session", "5-hour", "five_hour", "5h"], minutes: 5 * 60)
+ let codingSecondary = self.rateWindow(levels: ["weekly", "week"], minutes: 7 * 24 * 60)
+ let codingTertiary = self.rateWindow(levels: ["monthly", "month"], minutes: 30 * 24 * 60)
+
+ var extraRateWindows: [NamedRateWindow] = []
+ for plan in [
+ (levelPrefix: "agent_", idPrefix: "doubao-agent"),
+ (levelPrefix: "coding_team_", idPrefix: "doubao-coding-team"),
+ (levelPrefix: "agent_team_", idPrefix: "doubao-agent-team"),
+ ] {
+ let primary = self.rateWindow(
+ levels: [
+ "\(plan.levelPrefix)session",
+ "\(plan.levelPrefix)5-hour",
+ "\(plan.levelPrefix)five_hour",
+ "\(plan.levelPrefix)5h",
+ ],
+ minutes: 5 * 60)
+ let secondary = self.rateWindow(
+ levels: ["\(plan.levelPrefix)weekly", "\(plan.levelPrefix)week"],
+ minutes: 7 * 24 * 60)
+ let tertiary = self.rateWindow(
+ levels: ["\(plan.levelPrefix)monthly", "\(plan.levelPrefix)month"],
+ minutes: 30 * 24 * 60)
+
+ if let primary {
+ extraRateWindows.append(NamedRateWindow(
+ id: "\(plan.idPrefix)-session", title: "5-hour", window: primary))
+ }
+ if let secondary {
+ extraRateWindows.append(NamedRateWindow(
+ id: "\(plan.idPrefix)-weekly", title: "Weekly", window: secondary))
+ }
+ if let tertiary {
+ extraRateWindows.append(NamedRateWindow(
+ id: "\(plan.idPrefix)-monthly", title: "Monthly", window: tertiary))
+ }
+ }
+
+ let finalExtraWindows = extraRateWindows.isEmpty ? nil : extraRateWindows
+
let identity = ProviderIdentitySnapshot(
providerID: .doubao,
accountEmail: nil,
@@ -107,12 +170,37 @@ public struct DoubaoCodingPlanUsage: Sendable, Equatable {
loginMethod: self.status)
return UsageSnapshot(
- primary: primary,
- secondary: secondary,
- tertiary: tertiary,
+ primary: codingPrimary,
+ secondary: codingSecondary,
+ tertiary: codingTertiary,
+ extraRateWindows: finalExtraWindows,
+ kiroUsage: nil,
+ ampUsage: nil,
providerCost: nil,
+ zaiUsage: nil,
+ minimaxUsage: nil,
+ deepseekUsage: nil,
+ mimoUsage: nil,
+ openRouterUsage: nil,
+ sakanaPayAsYouGo: nil,
+ clawRouterUsage: nil,
+ sub2APIUsage: nil,
+ wayfinderUsage: nil,
+ openAIAPIUsage: nil,
+ codexResetCredits: nil,
+ claudeAdminAPIUsage: nil,
+ mistralUsage: nil,
+ deepgramUsage: nil,
+ poeUsage: nil,
+ cursorRequests: nil,
+ commandCodeSubscriptionEnrichmentUnavailable: false,
+ commandCodeHasSubscriptionPlan: false,
+ commandCodeMonthlyGrantDepleted: false,
+ subscriptionExpiresAt: nil,
+ subscriptionRenewsAt: nil,
updatedAt: self.updateTime ?? updatedAt,
- identity: identity)
+ identity: identity,
+ dataConfidence: .unknown)
}
private func rateWindow(levels: Set, minutes: Int) -> RateWindow? {
@@ -133,6 +221,13 @@ public enum DoubaoUsageError: LocalizedError, Sendable {
case networkError(String)
case apiError(Int, String)
case parseFailed(String)
+ case arkcliNotFound
+ case arkcliAuthenticationRequired
+ case arkcliTimedOut
+ case arkcliOutputTooLarge
+ case arkcliFailed(Int32, String)
+ case incompletePlanUsage(String)
+ case noPlanUsage(String?)
public var errorDescription: String? {
switch self {
@@ -144,6 +239,24 @@ public enum DoubaoUsageError: LocalizedError, Sendable {
"Doubao API error (\(code)): \(message)"
case let .parseFailed(message):
"Failed to parse Doubao response: \(message)"
+ case .arkcliNotFound:
+ "arkcli was not found. Install arkcli, run 'arkcli auth login', or configure Doubao API credentials."
+ case .arkcliAuthenticationRequired:
+ "arkcli is not signed in. Run 'arkcli auth login' and refresh Doubao usage."
+ case .arkcliTimedOut:
+ "arkcli usage timed out. Check arkcli authentication and try again."
+ case .arkcliOutputTooLarge:
+ "arkcli returned too much output. Update arkcli and try again."
+ case let .arkcliFailed(code, message):
+ "arkcli usage failed (\(code)): \(message)"
+ case let .incompletePlanUsage(message):
+ "arkcli returned incomplete Coding or Agent Plan usage: \(message)"
+ case let .noPlanUsage(message):
+ if let message, !message.isEmpty {
+ "arkcli returned no usable Coding or Agent Plan usage: \(message)"
+ } else {
+ "arkcli returned no active Coding or Agent Plan usage."
+ }
}
}
}
@@ -154,6 +267,9 @@ public struct DoubaoUsageFetcher: Sendable {
private static let codingPlanAPIURL = URL(
string: "https://open.volcengineapi.com/?Action=GetCodingPlanUsage&Version=2024-01-01")!
+ /// Closure that runs `arkcli usage plan` and returns raw stdout.
+ public typealias ArkcliRunner = @Sendable () async throws -> Data
+
/// Models to probe, ordered by likelihood. We try multiple models because
/// different key types may not have access to every model.
private static let probeModels = [
@@ -272,6 +388,188 @@ public struct DoubaoUsageFetcher: Sendable {
return Date(timeIntervalSince1970: timestamp)
}
+ public static func fetchCodingPlanUsage(
+ runArkcli: ArkcliRunner? = nil,
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ date: Date = Date()) async throws -> DoubaoUsageSnapshot
+ {
+ let stdoutData: Data = if let runArkcli {
+ try await runArkcli()
+ } else {
+ try await Self.runArkcliUsagePlan(environment: environment)
+ }
+
+ let usage = try Self.decodeArkcliUsage(from: stdoutData, date: date)
+
+ return DoubaoUsageSnapshot(
+ remainingRequests: 0,
+ limitRequests: 0,
+ resetTime: nil,
+ updatedAt: usage.updateTime ?? date,
+ apiKeyValid: true,
+ codingPlanUsage: usage)
+ }
+
+ static func decodeArkcliUsage(from data: Data, date: Date = Date()) throws -> DoubaoCodingPlanUsage {
+ let response: ArkcliUsageResponse
+ do {
+ response = try JSONDecoder().decode(ArkcliUsageResponse.self, from: data)
+ } catch {
+ throw DoubaoUsageError.parseFailed(error.localizedDescription)
+ }
+
+ var allQuotas: [DoubaoCodingPlanUsage.Quota] = []
+ var updateTime: Date?
+ let authMethod = response.viewer?.authMethod?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if authMethod?.lowercased() == "none" {
+ throw DoubaoUsageError.arkcliAuthenticationRequired
+ }
+ let supportedProducts = Set([
+ "agent-plan",
+ "coding-plan",
+ "agent-plan-team",
+ "coding-plan-team",
+ ])
+ if let incompleteSubscription = response.items.first(where: {
+ supportedProducts.contains($0.product.lowercased())
+ && $0.subscribed != false
+ && $0.periods?.isEmpty != false
+ }) {
+ let error = incompleteSubscription.error?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ let message = error.flatMap { $0.isEmpty ? nil : Self.compactText($0) }
+ ?? "\(incompleteSubscription.product.lowercased()) has no usage periods"
+ throw DoubaoUsageError.incompletePlanUsage(message)
+ }
+
+ for item in response.items {
+ let product = item.product.lowercased()
+ let levelPrefix: String? = switch product {
+ case "agent-plan": "agent_"
+ case "coding-plan": ""
+ case "agent-plan-team": "agent_team_"
+ case "coding-plan-team": "coding_team_"
+ default: nil
+ }
+ guard let levelPrefix else { continue }
+ guard item.subscribed != false else { continue }
+ let periods = item.periods ?? []
+ if !periods.isEmpty, let updatedAt = item.updatedAt, updatedAt > 0 {
+ // arkcli has shipped `updated_at` as both epoch milliseconds and
+ // epoch seconds across versions/plans; detect the unit by
+ // magnitude so a seconds payload isn't divided into 1970 and a
+ // milliseconds payload isn't multiplied into the far future.
+ // 1e11 seconds ≈ year 5138, well past any real "seconds" value,
+ // and 1e11 milliseconds ≈ 1973, well before any real "ms" value.
+ let seconds = updatedAt >= 1e11 ? updatedAt / 1000 : updatedAt
+ let candidate = Date(timeIntervalSince1970: seconds)
+ if updateTime.map({ candidate > $0 }) ?? true {
+ updateTime = candidate
+ }
+ }
+ // A per-bucket failure is reported as an item with no `periods`
+ // (often an `error` field). Keep `periods` optional so one failed
+ // product bucket does not reject the entire stdout and hide the
+ // otherwise valid subscribed plan usage.
+ for period in periods {
+ let level = levelPrefix + period.label
+ let resetTime = period.resetAt?.date
+ allQuotas.append(DoubaoCodingPlanUsage.Quota(
+ level: level,
+ percent: period.percent,
+ resetTime: resetTime))
+ }
+ }
+
+ guard !allQuotas.isEmpty else {
+ let itemError = response.items.lazy
+ .filter { supportedProducts.contains($0.product.lowercased()) }
+ .compactMap(\.error)
+ .first { !$0.isEmpty }
+ throw DoubaoUsageError.noPlanUsage(itemError.map { Self.compactText($0) })
+ }
+
+ return DoubaoCodingPlanUsage(status: authMethod, updateTime: updateTime, quotas: allQuotas)
+ }
+
+ static func runArkcliUsagePlan(
+ environment: [String: String],
+ loginPATH: [String]? = LoginShellPathCache.shared.current) async throws -> Data
+ {
+ guard let arkcliPath = BinaryLocator.resolveArkcliBinary(env: environment, loginPATH: loginPATH) else {
+ throw DoubaoUsageError.arkcliNotFound
+ }
+
+ var commandEnvironment = environment
+ commandEnvironment["PATH"] = PathBuilder.effectivePATH(
+ purposes: [.tty, .nodeTooling],
+ env: environment,
+ loginPATH: loginPATH)
+
+ do {
+ let result = try await SubprocessRunner.run(
+ binary: arkcliPath,
+ arguments: ["usage", "plan", "--format", "json"],
+ environment: commandEnvironment,
+ timeout: 15,
+ maxOutputBytes: 256 * 1024,
+ label: "doubao arkcli usage plan")
+ var output = BoundedOutputBuffer(maxBytes: 256 * 1024)
+ guard output.append(Data(result.stdout.utf8)) else {
+ throw DoubaoUsageError.arkcliOutputTooLarge
+ }
+ return output.data
+ } catch SubprocessRunnerError.timedOut {
+ throw DoubaoUsageError.arkcliTimedOut
+ } catch SubprocessRunnerError.outputTooLarge {
+ throw DoubaoUsageError.arkcliOutputTooLarge
+ } catch let SubprocessRunnerError.nonZeroExit(code, stderr) {
+ let message = Self.compactText(stderr)
+ if Self.isArkcliAuthenticationError(message) {
+ throw DoubaoUsageError.arkcliAuthenticationRequired
+ }
+ throw DoubaoUsageError.arkcliFailed(code, message.isEmpty ? "unknown error" : message)
+ } catch is CancellationError {
+ throw CancellationError()
+ } catch let error as DoubaoUsageError {
+ throw error
+ } catch {
+ throw DoubaoUsageError.networkError("Failed to launch arkcli: \(error.localizedDescription)")
+ }
+ }
+
+ private static func isArkcliAuthenticationError(_ message: String) -> Bool {
+ let normalized = message.lowercased()
+ return [
+ "not logged in",
+ "not authenticated",
+ "authentication required",
+ "login required",
+ "please login",
+ "please log in",
+ ].contains(where: normalized.contains)
+ }
+
+ private static func parseISO8601(_ value: String) -> Date? {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = formatter.date(from: trimmed) {
+ return date
+ }
+
+ let fallback = ISO8601DateFormatter()
+ fallback.formatOptions = [.withInternetDateTime]
+ if let date = fallback.date(from: trimmed) {
+ return date
+ }
+
+ return nil
+ }
+
private static func confirmAmbiguousZeroRemaining(
initial: ProbeResult,
apiKey: String,
@@ -393,7 +691,9 @@ public struct DoubaoUsageFetcher: Sendable {
}
private static func stringHeader(_ headers: [AnyHashable: Any], _ name: String) -> String? {
- if let value = headers[name] as? String { return value }
+ if let value = headers[name] as? String {
+ return value
+ }
for (key, val) in headers {
if let keyStr = key as? String,
keyStr.caseInsensitiveCompare(name) == .orderedSame,
@@ -426,14 +726,20 @@ public struct DoubaoUsageFetcher: Sendable {
private static func parseResetTime(_ value: String) -> Date? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmed.isEmpty { return nil }
+ if trimmed.isEmpty {
+ return nil
+ }
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- if let date = isoFormatter.date(from: trimmed) { return date }
+ if let date = isoFormatter.date(from: trimmed) {
+ return date
+ }
let isoFallback = ISO8601DateFormatter()
isoFallback.formatOptions = [.withInternetDateTime]
- if let date = isoFallback.date(from: trimmed) { return date }
+ if let date = isoFallback.date(from: trimmed) {
+ return date
+ }
var seconds: TimeInterval = 0
let pattern = /(\d+)([dhms])/
@@ -493,12 +799,16 @@ public struct DoubaoUsageFetcher: Sendable {
let message = error["message"] as? String
{
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
- if !trimmed.isEmpty { return Self.compactText(trimmed) }
+ if !trimmed.isEmpty {
+ return Self.compactText(trimmed)
+ }
}
if let message = json["message"] as? String {
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
- if !trimmed.isEmpty { return Self.compactText(trimmed) }
+ if !trimmed.isEmpty {
+ return Self.compactText(trimmed)
+ }
}
return "HTTP \(statusCode) (\(data.count) bytes)."
@@ -509,11 +819,83 @@ public struct DoubaoUsageFetcher: Sendable {
.components(separatedBy: .newlines)
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
- if collapsed.count <= maxLength { return collapsed }
+ if collapsed.count <= maxLength {
+ return collapsed
+ }
let limitIndex = collapsed.index(collapsed.startIndex, offsetBy: maxLength)
return "\(collapsed[.. 0 else { return nil }
+ let seconds = value >= 1e11 ? value / 1000 : value
+ return Date(timeIntervalSince1970: seconds)
+ }
+ }
+ }
+
+ // MARK: - Volcengine signed API response
+
private struct CodingPlanUsageResponse: Decodable {
let result: ResultPayload
diff --git a/Tests/CodexBarTests/ArkcliBinaryLocatorTests.swift b/Tests/CodexBarTests/ArkcliBinaryLocatorTests.swift
new file mode 100644
index 0000000000..5a918d4b1e
--- /dev/null
+++ b/Tests/CodexBarTests/ArkcliBinaryLocatorTests.swift
@@ -0,0 +1,53 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+struct ArkcliBinaryLocatorTests {
+ @Test
+ func `explicit executable override avoids shell lookup`() {
+ let path = "/trusted/bin/arkcli"
+ let fileManager = ArkcliFileManager(executables: [path])
+ var shellLookupCalled = false
+ let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in
+ shellLookupCalled = true
+ return "/untrusted/arkcli"
+ }
+
+ let resolved = BinaryLocator.resolveArkcliBinary(
+ env: ["ARKCLI_PATH": path],
+ loginPATH: nil,
+ commandV: commandV,
+ fileManager: fileManager,
+ home: "/home/test")
+
+ #expect(resolved == path)
+ #expect(!shellLookupCalled)
+ }
+
+ @Test
+ func `path lookup accepts only the arkcli executable name`() {
+ let fileManager = ArkcliFileManager(executables: ["/tools/bin/not-arkcli"])
+ let resolved = BinaryLocator.resolveArkcliBinary(
+ env: ["PATH": "/tools/bin"],
+ loginPATH: nil,
+ commandV: { _, _, _, _ in nil },
+ aliasResolver: { _, _, _, _, _ in nil },
+ fileManager: fileManager,
+ home: "/home/test")
+
+ #expect(resolved == nil)
+ }
+}
+
+private final class ArkcliFileManager: FileManager {
+ private let executables: Set
+
+ init(executables: Set) {
+ self.executables = executables
+ super.init()
+ }
+
+ override func isExecutableFile(atPath path: String) -> Bool {
+ self.executables.contains(path)
+ }
+}
diff --git a/Tests/CodexBarTests/DoubaoMenuCardModelTests.swift b/Tests/CodexBarTests/DoubaoMenuCardModelTests.swift
index 99c0049ad3..cccc0c1ab0 100644
--- a/Tests/CodexBarTests/DoubaoMenuCardModelTests.swift
+++ b/Tests/CodexBarTests/DoubaoMenuCardModelTests.swift
@@ -4,6 +4,52 @@ import Testing
@testable import CodexBar
struct DoubaoMenuCardModelTests {
+ @Test
+ @MainActor
+ func `team plan metric title discloses its edition`() throws {
+ let now = Date(timeIntervalSince1970: 1_742_771_200)
+ let snapshot = UsageSnapshot(
+ primary: nil,
+ secondary: nil,
+ tertiary: nil,
+ extraRateWindows: [
+ NamedRateWindow(
+ id: "doubao-coding-team-session",
+ title: "5-hour",
+ window: RateWindow(
+ usedPercent: 25,
+ windowMinutes: 300,
+ resetsAt: nil,
+ resetDescription: nil)),
+ ],
+ updatedAt: now,
+ identity: nil)
+ let metadata = try #require(ProviderDefaults.metadata[.doubao])
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .doubao,
+ metadata: metadata,
+ snapshot: snapshot,
+ credits: nil,
+ creditsError: nil,
+ dashboard: nil,
+ dashboardError: nil,
+ tokenSnapshot: nil,
+ tokenError: nil,
+ account: AccountInfo(email: nil, plan: nil),
+ isRefreshing: false,
+ lastError: nil,
+ usageBarsShowUsed: false,
+ resetTimeDisplayStyle: .countdown,
+ tokenCostUsageEnabled: false,
+ showOptionalCreditsAndExtraUsage: true,
+ hidePersonalInfo: false,
+ now: now))
+
+ let metric = try #require(model.metrics.first)
+ #expect(metric.id == "doubao-coding-team-session")
+ #expect(UsageMenuCardView.popupMetricTitle(provider: .doubao, metric: metric) == "5-hour (Team)")
+ }
+
@Test
func `coding plan monthly quota shows deficit and run out details`() throws {
let now = Date(timeIntervalSince1970: 10_368_000) // 1970-05-01T00:00:00Z
diff --git a/Tests/CodexBarTests/DoubaoProviderTests.swift b/Tests/CodexBarTests/DoubaoProviderTests.swift
index ab753fe31f..dccf046895 100644
--- a/Tests/CodexBarTests/DoubaoProviderTests.swift
+++ b/Tests/CodexBarTests/DoubaoProviderTests.swift
@@ -79,18 +79,125 @@ struct DoubaoProviderTests {
#expect(DoubaoProviderDescriptor.primaryLabel(window: unavailableWindow) == nil)
}
+ // MARK: - CLI strategy tests
+
+ @Test
+ func `cli strategy returns usage from arkcli`() async throws {
+ let expectedDate = Date(timeIntervalSince1970: 42)
+ let context = Self.makeContext(
+ sourceMode: .cli,
+ environment: ["ARKCLI_PATH": "/trusted/arkcli"])
+ let strategy = DoubaoCLIFetchStrategy(
+ cliUsageLoader: { environment in
+ #expect(environment["ARKCLI_PATH"] == "/trusted/arkcli")
+ return DoubaoUsageSnapshot(
+ remainingRequests: 0,
+ limitRequests: 0,
+ resetTime: nil,
+ updatedAt: expectedDate,
+ apiKeyValid: true,
+ codingPlanUsage: DoubaoCodingPlanUsage(
+ status: "subscribed",
+ updateTime: expectedDate,
+ quotas: [
+ DoubaoCodingPlanUsage.Quota(level: "session", percent: 42.0, resetTime: nil),
+ ]))
+ })
+
+ let result = try await strategy.fetch(context)
+
+ #expect(result.sourceLabel == "cli")
+ #expect(result.strategyID == "doubao.cli")
+ #expect(result.strategyKind == .cli)
+ #expect(result.usage.primary?.usedPercent == 42.0)
+ }
+
+ @Test
+ func `cli strategy does not cross authentication sources on failure`() {
+ let context = Self.makeContext(sourceMode: .auto)
+ let strategy = DoubaoCLIFetchStrategy(
+ cliUsageLoader: { _ in
+ throw DoubaoProviderTestError.signedFailed
+ })
+
+ #expect(strategy.shouldFallback(on: DoubaoProviderTestError.signedFailed, context: context) == false)
+ }
+
+ @Test
+ func `cli strategy does not fall back in explicit cli mode`() {
+ let context = Self.makeContext(sourceMode: .cli)
+ let strategy = DoubaoCLIFetchStrategy(
+ cliUsageLoader: { _ in
+ throw DoubaoProviderTestError.signedFailed
+ })
+
+ #expect(strategy.shouldFallback(on: DoubaoProviderTestError.signedFailed, context: context) == false)
+ }
+
+ @Test
+ func `cli cancellation does not fall back to api`() {
+ let context = Self.makeContext(sourceMode: .auto)
+ let strategy = DoubaoCLIFetchStrategy(
+ cliUsageLoader: { _ in
+ throw CancellationError()
+ })
+
+ #expect(strategy.shouldFallback(on: CancellationError(), context: context) == false)
+ }
+
+ // MARK: - API strategy tests
+
+ @Test
+ func `api strategy uses ak/sk signed credentials when available`() async throws {
+ let expectedDate = Date(timeIntervalSince1970: 99)
+ let context = Self.makeContext(
+ sourceMode: .api,
+ environment: [
+ DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLTtest",
+ DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "secret123",
+ ])
+ let strategy = DoubaoAPIFetchStrategy(
+ signedUsageLoader: { credentials in
+ #expect(credentials.accessKeyID == "AKLTtest")
+ #expect(credentials.secretAccessKey == "secret123")
+ return DoubaoUsageSnapshot(
+ remainingRequests: 0,
+ limitRequests: 0,
+ resetTime: nil,
+ updatedAt: expectedDate,
+ apiKeyValid: true,
+ codingPlanUsage: DoubaoCodingPlanUsage(
+ status: "subscribed",
+ updateTime: expectedDate,
+ quotas: [
+ DoubaoCodingPlanUsage.Quota(level: "session", percent: 15.0, resetTime: nil),
+ ]))
+ },
+ arkUsageLoader: { _ in
+ Issue.record("Ark probe should not run when signed credentials succeed")
+ throw DoubaoProviderTestError.arkShouldNotRun
+ })
+
+ let result = try await strategy.fetch(context)
+
+ #expect(result.sourceLabel == "api")
+ #expect(result.strategyID == "doubao.api")
+ #expect(result.strategyKind == .apiToken)
+ #expect(result.usage.primary?.usedPercent == 15.0)
+ }
+
@Test
- func `signed credential failure falls back to ark API key`() async throws {
+ func `api strategy falls back to ark key probe when signed credentials fail`() async throws {
let expectedDate = Date(timeIntervalSince1970: 42)
- let context = Self.makeContext(environment: [
- DoubaoSettingsReader.apiKeyEnvironmentKeys[0]: "ark-env",
- DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLT-env",
- DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "sk-env",
- ])
+ let context = Self.makeContext(
+ sourceMode: .api,
+ environment: [
+ DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLTtest",
+ DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "secret123",
+ DoubaoSettingsReader.apiKeyEnvironmentKeys[0]: "ark-env",
+ ])
let strategy = DoubaoAPIFetchStrategy(
- codingPlanUsageLoader: { credentials in
- #expect(credentials.accessKeyID == "AKLT-env")
- #expect(credentials.secretAccessKey == "sk-env")
+ signedUsageLoader: { _ in
throw DoubaoProviderTestError.signedFailed
},
arkUsageLoader: { apiKey in
@@ -106,21 +213,63 @@ struct DoubaoProviderTests {
let result = try await strategy.fetch(context)
#expect(result.sourceLabel == "api")
- #expect(result.strategyID == "doubao.api")
- #expect(result.usage.updatedAt == expectedDate)
#expect(result.usage.primary?.usedPercent == 30)
#expect(DoubaoProviderDescriptor.primaryLabel(window: result.usage.primary) == "Requests")
}
@Test
- func `signed credential cancellation does not fall back to ark API key`() async {
- let context = Self.makeContext(environment: [
- DoubaoSettingsReader.apiKeyEnvironmentKeys[0]: "ark-env",
- DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLT-env",
- DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "sk-env",
- ])
+ func `api strategy does not fall back to cli on failure`() {
+ let context = Self.makeContext(sourceMode: .api)
let strategy = DoubaoAPIFetchStrategy(
- codingPlanUsageLoader: { _ in
+ signedUsageLoader: { _ in
+ throw DoubaoProviderTestError.signedFailed
+ },
+ arkUsageLoader: { _ in
+ throw DoubaoProviderTestError.signedFailed
+ })
+
+ #expect(strategy.shouldFallback(on: DoubaoProviderTestError.signedFailed, context: context) == false)
+ }
+
+ @Test
+ func `api strategy uses ark key probe when no ak/sk credentials`() async throws {
+ let expectedDate = Date(timeIntervalSince1970: 42)
+ let context = Self.makeContext(
+ sourceMode: .api,
+ environment: [
+ DoubaoSettingsReader.apiKeyEnvironmentKeys[0]: "ark-env",
+ ])
+ let strategy = DoubaoAPIFetchStrategy(
+ signedUsageLoader: { _ in
+ Issue.record("Signed loader should not run without AK/SK credentials")
+ throw DoubaoProviderTestError.signedFailed
+ },
+ arkUsageLoader: { apiKey in
+ #expect(apiKey == "ark-env")
+ return DoubaoUsageSnapshot(
+ remainingRequests: 7,
+ limitRequests: 10,
+ resetTime: expectedDate,
+ updatedAt: expectedDate,
+ apiKeyValid: true)
+ })
+
+ let result = try await strategy.fetch(context)
+
+ #expect(result.sourceLabel == "api")
+ #expect(result.usage.primary?.usedPercent == 30)
+ }
+
+ @Test
+ func `api strategy cancellation does not fall back to ark key`() async {
+ let context = Self.makeContext(
+ sourceMode: .api,
+ environment: [
+ DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLTtest",
+ DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "secret123",
+ ])
+ let strategy = DoubaoAPIFetchStrategy(
+ signedUsageLoader: { _ in
throw CancellationError()
},
arkUsageLoader: { _ in
@@ -133,11 +282,89 @@ struct DoubaoProviderTests {
}
}
- private static func makeContext(environment: [String: String]) -> ProviderFetchContext {
+ @Test
+ func `api strategy surfaces signed error when no api key available`() async {
+ // AK/SK credentials present but signed request fails, and no Ark API key
+ // is configured. The signed error (not a generic "missing key") should surface.
+ let context = Self.makeContext(
+ sourceMode: .api,
+ environment: [
+ DoubaoSettingsReader.accessKeyIDEnvironmentKeys[0]: "AKLTtest",
+ DoubaoSettingsReader.secretAccessKeyEnvironmentKeys[0]: "secret123",
+ ])
+ let strategy = DoubaoAPIFetchStrategy(
+ signedUsageLoader: { _ in
+ throw DoubaoUsageError.apiError(403, "SignatureExpired")
+ },
+ arkUsageLoader: { _ in
+ Issue.record("Ark probe should not run when no API key is configured")
+ throw DoubaoProviderTestError.arkShouldNotRun
+ })
+
+ await #expect {
+ try await strategy.fetch(context)
+ } throws: { error in
+ guard case let DoubaoUsageError.apiError(code, _) = error else { return false }
+ return code == 403
+ }
+ }
+
+ // MARK: - resolveStrategies routing tests
+
+ @Test
+ func `auto mode uses cli when api credentials are absent`() async {
+ let context = Self.makeContext(sourceMode: .auto)
+ let strategies = await DoubaoProviderDescriptor.resolveStrategies(context: context)
+
+ #expect(strategies.count == 1)
+ #expect(strategies[0].id == "doubao.cli")
+ #expect(strategies[0].kind == .cli)
+ }
+
+ @Test
+ func `auto mode preserves configured api account over ambient cli`() async {
+ let context = Self.makeContext(
+ sourceMode: .auto,
+ environment: [
+ DoubaoSettingsReader.apiKeyEnvironmentKeys[0]: "ark-configured-account",
+ "ARKCLI_PATH": "/ambient/other-account/arkcli",
+ ])
+ let strategies = await DoubaoProviderDescriptor.resolveStrategies(context: context)
+
+ #expect(strategies.count == 1)
+ #expect(strategies[0].id == "doubao.api")
+ #expect(strategies[0].kind == .apiToken)
+ }
+
+ @Test
+ func `explicit cli mode returns only cli strategy`() async {
+ let context = Self.makeContext(sourceMode: .cli)
+ let strategies = await DoubaoProviderDescriptor.resolveStrategies(context: context)
+
+ #expect(strategies.count == 1)
+ #expect(strategies[0].id == "doubao.cli")
+ #expect(strategies[0].kind == .cli)
+ }
+
+ @Test
+ func `explicit api mode returns only api strategy`() async {
+ let context = Self.makeContext(sourceMode: .api)
+ let strategies = await DoubaoProviderDescriptor.resolveStrategies(context: context)
+
+ #expect(strategies.count == 1)
+ #expect(strategies[0].id == "doubao.api")
+ #expect(strategies[0].kind == .apiToken)
+ }
+
+ private static func makeContext(
+ sourceMode: ProviderSourceMode = .api,
+ environment: [String: String] = [:])
+ -> ProviderFetchContext
+ {
let browserDetection = BrowserDetection(cacheTTL: 0)
return ProviderFetchContext(
runtime: .app,
- sourceMode: .api,
+ sourceMode: sourceMode,
includeCredits: false,
webTimeout: 1,
webDebugDumpHTML: false,
diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
index dab7fa8c80..4524d25939 100644
--- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
+++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift
@@ -243,6 +243,632 @@ struct DoubaoUsageFetcherTests {
}
}
+ @Test
+ func `arkcli response maps coding plan and agent plan windows`() throws {
+ let data = Data(
+ """
+ {
+ "viewer": {
+ "auth_method": "sso",
+ "profile": "agent-plan_cn-beijing_personal"
+ },
+ "items": [
+ {
+ "product": "agent-plan",
+ "subscribed": true,
+ "periods": [
+ {"label": "5h", "total": 2000, "percent": 0},
+ {
+ "label": "weekly", "used": 2009.33, "total": 7000, "percent": 28.7,
+ "reset_at": "2026-07-20T00:00:00+08:00"
+ },
+ {
+ "label": "monthly", "used": 2009.33, "total": 20000, "percent": 10.05,
+ "reset_at": "2026-08-14T23:59:59+08:00"
+ }
+ ]
+ },
+ {
+ "product": "coding-plan",
+ "subscribed": true,
+ "periods": [
+ {"label": "session", "percent": 7.48, "reset_at": "2026-07-16T19:12:07+08:00"},
+ {"label": "weekly", "percent": 2.71, "reset_at": "2026-07-20T00:00:00+08:00"},
+ {"label": "monthly", "percent": 1.36, "reset_at": "2026-08-15T23:59:59+08:00"}
+ ],
+ "updated_at": 1784191193000
+ }
+ ]
+ }
+ """.utf8)
+
+ let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot(
+ updatedAt: Date(timeIntervalSince1970: 0))
+
+ // Coding plan should be primary/secondary/tertiary
+ #expect(usage.primary?.usedPercent == 7.48)
+ #expect(usage.primary?.windowMinutes == 300)
+ #expect(usage.secondary?.usedPercent == 2.71)
+ #expect(usage.secondary?.windowMinutes == 10080)
+ #expect(usage.tertiary?.usedPercent == 1.36)
+ #expect(usage.tertiary?.windowMinutes == 43200)
+
+ // Agent plan should appear as extra rate windows
+ let agentWindows = usage.extraRateWindows ?? []
+ #expect(agentWindows.count == 3)
+ #expect(agentWindows[0].title == "5-hour")
+ #expect(agentWindows[0].window.usedPercent == 0)
+ #expect(agentWindows[1].title == "Weekly")
+ #expect(agentWindows[1].window.usedPercent == 28.7)
+ #expect(agentWindows[2].title == "Monthly")
+ #expect(agentWindows[2].window.usedPercent == 10.05)
+
+ // Update time from coding-plan's updated_at
+ #expect(usage.updatedAt == Date(timeIntervalSince1970: 1_784_191_193))
+ #expect(usage.identity?.providerID == .doubao)
+ #expect(usage.identity?.loginMethod == "sso")
+ }
+
+ @Test
+ func `arkcli response handles missing reset_at fields`() throws {
+ let data = Data(
+ """
+ {
+ "items": [
+ {
+ "product": "coding-plan",
+ "periods": [
+ {"label": "session", "percent": 12.5},
+ {"label": "weekly", "percent": 24.0, "reset_at": "2026-07-20T00:00:00+08:00"}
+ ]
+ }
+ ]
+ }
+ """.utf8)
+
+ let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot(
+ updatedAt: Date(timeIntervalSince1970: 42))
+
+ #expect(usage.primary?.usedPercent == 12.5)
+ #expect(usage.primary?.resetsAt == nil)
+ #expect(usage.secondary?.usedPercent == 24.0)
+ #expect(usage.secondary?.resetsAt != nil)
+ }
+
+ @Test
+ func `arkcli response with only agent plan preserves agent window identity`() throws {
+ let data = Data(
+ """
+ {
+ "items": [
+ {
+ "product": "agent-plan",
+ "subscribed": true,
+ "periods": [
+ {"label": "5h", "total": 2000, "percent": 5.0, "reset_at": "2026-07-16T19:12:07+08:00"},
+ {"label": "weekly", "percent": 15.0, "reset_at": "2026-07-20T00:00:00+08:00"},
+ {"label": "monthly", "percent": 25.0, "reset_at": "2026-08-15T23:59:59+08:00"}
+ ]
+ }
+ ]
+ }
+ """.utf8)
+
+ let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot(
+ updatedAt: Date(timeIntervalSince1970: 0))
+
+ #expect(usage.primary == nil)
+ let agentWindows = try #require(usage.extraRateWindows)
+ #expect(agentWindows.map(\.id) == [
+ "doubao-agent-session",
+ "doubao-agent-weekly",
+ "doubao-agent-monthly",
+ ])
+ #expect(agentWindows.map(\.window.usedPercent) == [5.0, 15.0, 25.0])
+ }
+
+ @Test
+ func `arkcli team-only plans preserve product identities`() throws {
+ let data = Data(
+ """
+ {
+ "items": [
+ {
+ "product": "agent-plan-team",
+ "edition": "team",
+ "subscribed": true,
+ "periods": [
+ {"label": "5h", "percent": 5.0},
+ {"label": "weekly", "percent": 15.0}
+ ]
+ },
+ {
+ "product": "coding-plan-team",
+ "edition": "team",
+ "subscribed": true,
+ "periods": [
+ {"label": "session", "percent": 7.48},
+ {"label": "monthly", "percent": 25.0}
+ ]
+ }
+ ]
+ }
+ """.utf8)
+
+ let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot(
+ updatedAt: Date(timeIntervalSince1970: 0))
+
+ #expect(usage.primary == nil)
+ let windows = try #require(usage.extraRateWindows)
+ #expect(windows.map(\.id) == [
+ "doubao-coding-team-session",
+ "doubao-coding-team-monthly",
+ "doubao-agent-team-session",
+ "doubao-agent-team-weekly",
+ ])
+ #expect(windows.map(\.window.usedPercent) == [7.48, 25.0, 5.0, 15.0])
+ }
+
+ @Test
+ func `arkcli mixed personal and team plans keep every bucket`() throws {
+ let data = Data(
+ """
+ {
+ "items": [
+ {"product":"coding-plan","periods":[{"label":"session","percent":1}]},
+ {"product":"coding-plan-team","periods":[{"label":"session","percent":2}]},
+ {"product":"agent-plan","periods":[{"label":"5h","percent":3}]},
+ {"product":"agent-plan-team","periods":[{"label":"5h","percent":4}]}
+ ]
+ }
+ """.utf8)
+
+ let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot(
+ updatedAt: Date(timeIntervalSince1970: 0))
+
+ #expect(usage.primary?.usedPercent == 1)
+ let windows = try #require(usage.extraRateWindows)
+ #expect(windows.map(\.id) == [
+ "doubao-agent-session",
+ "doubao-coding-team-session",
+ "doubao-agent-team-session",
+ ])
+ #expect(windows.map(\.window.usedPercent) == [3, 2, 4])
+ }
+
+ @Test
+ func `arkcli response with an error-only item still decodes valid buckets`() throws {
+ let data = Data(
+ """
+ {
+ "items": [
+ {
+ "product": "coding-plan",
+ "error": "failed to query usage",
+ "subscribed": false
+ },
+ {
+ "product": "agent-plan",
+ "subscribed": true,
+ "periods": [
+ {"label": "5h", "percent": 5.0, "reset_at": "2026-07-16T19:12:07+08:00"}
+ ]
+ }
+ ]
+ }
+ """.utf8)
+
+ let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot(
+ updatedAt: Date(timeIntervalSince1970: 0))
+
+ // The error-only coding item is skipped; the agent item still decodes.
+ #expect(usage.primary == nil)
+ #expect(usage.extraRateWindows?.first?.window.usedPercent == 5.0)
+ #expect(usage.identity?.loginMethod == nil)
+ }
+
+ @Test
+ func `arkcli explicitly unsubscribed bucket does not contribute stale periods`() throws {
+ let data = Data(
+ """
+ {"items":[
+ {
+ "product":"coding-plan", "subscribed":false, "updated_at":1784199993,
+ "periods":[{"label":"session","percent":99}]
+ },
+ {
+ "product":"agent-plan", "subscribed":true, "updated_at":1784191193,
+ "periods":[{"label":"5h","percent":5}]
+ }
+ ]}
+ """.utf8)
+
+ let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot(
+ updatedAt: Date(timeIntervalSince1970: 0))
+
+ #expect(usage.primary == nil)
+ #expect(usage.extraRateWindows?.first?.window.usedPercent == 5)
+ #expect(usage.updatedAt == Date(timeIntervalSince1970: 1_784_191_193))
+ }
+
+ @Test
+ func `arkcli subscribed bucket failure does not silently return partial usage`() {
+ let data = Data(
+ """
+ {
+ "items": [
+ {
+ "product": "coding-plan",
+ "subscribed": true,
+ "periods": [{"label": "session", "percent": 5}]
+ },
+ {
+ "product": "agent-plan-team",
+ "subscribed": true,
+ "error": "no seat bound to caller"
+ }
+ ]
+ }
+ """.utf8)
+
+ #expect {
+ _ = try DoubaoUsageFetcher.decodeArkcliUsage(from: data)
+ } throws: { error in
+ guard case let DoubaoUsageError.incompletePlanUsage(message) = error else { return false }
+ return message == "no seat bound to caller"
+ }
+ }
+
+ @Test
+ func `arkcli active empty bucket without error does not silently return partial usage`() {
+ let data = Data(
+ """
+ {
+ "items": [
+ {
+ "product": "coding-plan",
+ "periods": [{"label": "session", "percent": 5}]
+ },
+ {
+ "product": "agent-plan",
+ "subscribed": true,
+ "periods": []
+ }
+ ]
+ }
+ """.utf8)
+
+ #expect {
+ _ = try DoubaoUsageFetcher.decodeArkcliUsage(from: data)
+ } throws: { error in
+ guard case let DoubaoUsageError.incompletePlanUsage(message) = error else { return false }
+ return message == "agent-plan has no usage periods"
+ }
+ }
+
+ @Test
+ func `arkcli viewer with no authentication requires login`() {
+ let data = Data(
+ """
+ {
+ "viewer": {"auth_method": "none"},
+ "items": [
+ {"product": "coding-plan", "periods": [{"label": "session", "percent": 5}]}
+ ]
+ }
+ """.utf8)
+
+ #expect {
+ _ = try DoubaoUsageFetcher.decodeArkcliUsage(from: data)
+ } throws: { error in
+ guard case DoubaoUsageError.arkcliAuthenticationRequired = error else { return false }
+ return true
+ }
+ }
+
+ @Test
+ func `arkcli response with only a failed bucket surfaces its error`() {
+ let data = Data(
+ """
+ {
+ "items": [
+ {
+ "product": "coding-plan",
+ "error": "failed to query usage",
+ "subscribed": false
+ }
+ ]
+ }
+ """.utf8)
+
+ #expect {
+ _ = try DoubaoUsageFetcher.decodeArkcliUsage(from: data)
+ } throws: { error in
+ guard case let DoubaoUsageError.noPlanUsage(message) = error else { return false }
+ return message == "failed to query usage"
+ }
+ }
+
+ @Test
+ func `arkcli response with no plan items is not treated as valid usage`() {
+ #expect {
+ _ = try DoubaoUsageFetcher.decodeArkcliUsage(from: Data(#"{"items":[]}"#.utf8))
+ } throws: { error in
+ guard case DoubaoUsageError.noPlanUsage(nil) = error else { return false }
+ return true
+ }
+ }
+
+ @Test
+ func `arkcli response ignores unrelated product buckets`() {
+ let data = Data(
+ """
+ {
+ "items": [
+ {
+ "product": "unrelated-plan",
+ "periods": [{"label": "session", "percent": 99}]
+ }
+ ]
+ }
+ """.utf8)
+
+ #expect {
+ _ = try DoubaoUsageFetcher.decodeArkcliUsage(from: data)
+ } throws: { error in
+ guard case DoubaoUsageError.noPlanUsage(nil) = error else { return false }
+ return true
+ }
+ }
+
+ @Test
+ func `arkcli unrelated product failure does not poison valid plan usage`() throws {
+ let data = Data(
+ """
+ {"items":[
+ {
+ "product":"future-plan", "subscribed":true,
+ "error":"future product unavailable"
+ },
+ {
+ "product":"coding-plan", "subscribed":true,
+ "periods":[{"label":"session","percent":7}]
+ }
+ ]}
+ """.utf8)
+
+ let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot(
+ updatedAt: Date(timeIntervalSince1970: 0))
+
+ #expect(usage.primary?.usedPercent == 7)
+ }
+
+ @Test
+ func `arkcli response accepts updated_at in seconds`() throws {
+ // Real arkcli output (0.1.x) emits `updated_at` in epoch seconds, not
+ // milliseconds. Verify the auto-detection picks the right unit so the
+ // menu doesn't show a 1970 timestamp.
+ let data = Data(
+ """
+ {
+ "items": [
+ {
+ "product": "coding-plan",
+ "subscribed": true,
+ "periods": [
+ {"label": "session", "percent": 27.3, "reset_at": "2026-07-17T19:22:45+08:00"}
+ ],
+ "updated_at": 1784270829
+ }
+ ]
+ }
+ """.utf8)
+
+ let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot(
+ updatedAt: Date(timeIntervalSince1970: 0))
+
+ #expect(usage.updatedAt == Date(timeIntervalSince1970: 1_784_270_829))
+ }
+
+ @Test
+ func `arkcli response accepts numeric reset timestamps and sentinels`() throws {
+ let data = Data(
+ """
+ {
+ "items": [
+ {
+ "product": "coding-plan",
+ "periods": [
+ {"label": "session", "percent": 10, "reset_at": 1784192000},
+ {"label": "weekly", "percent": 20, "reset_at": 1784534400000},
+ {"label": "monthly", "percent": 30, "reset_at": -1}
+ ]
+ }
+ ]
+ }
+ """.utf8)
+
+ let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data).toUsageSnapshot(
+ updatedAt: Date(timeIntervalSince1970: 0))
+
+ #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_784_192_000))
+ #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_784_534_400))
+ #expect(usage.tertiary?.resetsAt == nil)
+ }
+
+ @Test
+ func `arkcli fetch via injected runner returns parsed snapshot`() async throws {
+ let jsonData = Data(
+ """
+ {
+ "items": [
+ {
+ "product": "coding-plan",
+ "subscribed": true,
+ "periods": [
+ {"label": "session", "percent": 42.0, "reset_at": "2026-07-16T19:12:07+08:00"}
+ ],
+ "updated_at": 1784191193000
+ }
+ ]
+ }
+ """.utf8)
+
+ let snapshot = try await DoubaoUsageFetcher.fetchCodingPlanUsage(
+ runArkcli: { jsonData },
+ date: Date(timeIntervalSince1970: 0))
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.primary?.usedPercent == 42.0)
+ #expect(usage.primary?.windowMinutes == 300)
+ #expect(usage.updatedAt == Date(timeIntervalSince1970: 1_784_191_193))
+ }
+
+ @Test
+ func `arkcli aggregate freshness uses newest contributing bucket`() throws {
+ let olderFirst = Data(
+ """
+ {"items":[
+ {
+ "product":"coding-plan", "updated_at":1784191193,
+ "periods":[{"label":"session","percent":1}]
+ },
+ {
+ "product":"agent-plan", "updated_at":1784191293000,
+ "periods":[{"label":"5h","percent":2}]
+ }
+ ]}
+ """.utf8)
+ let newerFirst = Data(
+ """
+ {"items":[
+ {
+ "product":"agent-plan", "updated_at":1784191293000,
+ "periods":[{"label":"5h","percent":2}]
+ },
+ {
+ "product":"coding-plan", "updated_at":1784191193,
+ "periods":[{"label":"session","percent":1}]
+ }
+ ]}
+ """.utf8)
+
+ let expected = Date(timeIntervalSince1970: 1_784_191_293)
+ #expect(try DoubaoUsageFetcher.decodeArkcliUsage(from: olderFirst).updateTime == expected)
+ #expect(try DoubaoUsageFetcher.decodeArkcliUsage(from: newerFirst).updateTime == expected)
+ }
+
+ @Test
+ func `arkcli subprocess explicitly requests JSON output`() async throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("codexbar-arkcli-arguments-\(UUID().uuidString)", isDirectory: true)
+ let executable = root.appendingPathComponent("arkcli")
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+ try """
+ #!/bin/sh
+ if [ "$*" != "usage plan --format json" ]; then
+ printf '%s\n' "unexpected arguments: $*" >&2
+ exit 2
+ fi
+ printf '%s\n' '{"items":[{"product":"coding-plan","periods":[{"label":"session","percent":42}]}]}'
+ """.write(to: executable, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
+
+ let snapshot = try await DoubaoUsageFetcher.fetchCodingPlanUsage(
+ environment: ["ARKCLI_PATH": executable.path])
+
+ #expect(snapshot.codingPlanUsage?.quotas.first?.percent == 42)
+ }
+
+ @Test
+ func `arkcli subprocess uses discovery path for node interpreter`() async throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("codexbar-arkcli-node-path-\(UUID().uuidString)", isDirectory: true)
+ let executable = root.appendingPathComponent("arkcli")
+ let node = root.appendingPathComponent("node")
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+ try "#!/usr/bin/env node\n".write(to: executable, atomically: true, encoding: .utf8)
+ try """
+ #!/bin/sh
+ printf '%s\n' '{"items":[{"product":"coding-plan","periods":[{"label":"session","percent":42}]}]}'
+ """.write(to: node, atomically: true, encoding: .utf8)
+ for path in [executable.path, node.path] {
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path)
+ }
+
+ let data = try await DoubaoUsageFetcher.runArkcliUsagePlan(
+ environment: ["PATH": "/usr/bin:/bin"],
+ loginPATH: [root.path])
+ let usage = try DoubaoUsageFetcher.decodeArkcliUsage(from: data)
+
+ #expect(usage.quotas.first?.percent == 42)
+ }
+
+ @Test
+ func `arkcli fetch surfaces parse error for invalid JSON`() async {
+ await #expect {
+ _ = try await DoubaoUsageFetcher.fetchCodingPlanUsage(
+ runArkcli: { Data("not json".utf8) })
+ } throws: { error in
+ guard case DoubaoUsageError.parseFailed = error else { return false }
+ return true
+ }
+ }
+
+ @Test
+ func `arkcli nonzero login error surfaces authentication guidance`() async throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("codexbar-arkcli-login-\(UUID().uuidString)", isDirectory: true)
+ let executable = root.appendingPathComponent("arkcli")
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+ try """
+ #!/bin/sh
+ printf '%s\n' 'not logged in; run arkcli auth login' >&2
+ exit 1
+ """.write(to: executable, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
+
+ await #expect {
+ _ = try await DoubaoUsageFetcher.fetchCodingPlanUsage(
+ environment: ["ARKCLI_PATH": executable.path])
+ } throws: { error in
+ guard case DoubaoUsageError.arkcliAuthenticationRequired = error else { return false }
+ return error.localizedDescription.contains("arkcli auth login")
+ }
+ }
+
+ @Test
+ func `arkcli oversized stdout fails closed before JSON parsing`() async throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("codexbar-arkcli-output-\(UUID().uuidString)", isDirectory: true)
+ let executable = root.appendingPathComponent("arkcli")
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+ try """
+ #!/bin/sh
+ /usr/bin/head -c 300000 /dev/zero
+ """.write(to: executable, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
+
+ await #expect {
+ _ = try await DoubaoUsageFetcher.fetchCodingPlanUsage(
+ environment: ["ARKCLI_PATH": executable.path])
+ } throws: { error in
+ guard case DoubaoUsageError.arkcliOutputTooLarge = error else { return false }
+ return true
+ }
+ }
+
+ @Test
+ func `missing arkcli error gives setup guidance`() {
+ let message = DoubaoUsageError.arkcliNotFound.localizedDescription
+ #expect(message.contains("Install arkcli"))
+ #expect(message.contains("arkcli auth login"))
+ }
+
@Test
func `repeated successful zero remaining responses omit unknown request limit`() async throws {
let transport = DoubaoScriptedTransport(results: [
diff --git a/Tests/CodexBarTests/SubprocessRunnerTests.swift b/Tests/CodexBarTests/SubprocessRunnerTests.swift
index ae85d8bec2..4eb9184479 100644
--- a/Tests/CodexBarTests/SubprocessRunnerTests.swift
+++ b/Tests/CodexBarTests/SubprocessRunnerTests.swift
@@ -35,6 +35,28 @@ struct SubprocessRunnerTests {
#expect(result.stderr.isEmpty)
}
+ @Test
+ func `rejects oversized output when strict limit is configured`() async throws {
+ do {
+ _ = try await SubprocessRunner.run(
+ binary: "/usr/bin/python3",
+ arguments: ["-c", "print('x' * 10_000)"],
+ environment: ProcessInfo.processInfo.environment,
+ timeout: 5,
+ maxOutputBytes: 1024,
+ label: "python strict output limit")
+ Issue.record("Expected strict output limit failure")
+ } catch let error as SubprocessRunnerError {
+ guard case let .outputTooLarge(label) = error else {
+ Issue.record("Expected outputTooLarge, got \(error)")
+ return
+ }
+ #expect(label == "python strict output limit")
+ } catch {
+ Issue.record("Expected SubprocessRunnerError, got \(error)")
+ }
+ }
+
@Test
func `preserves captured prefix when limit splits three byte scalar`() async throws {
let asciiCount = ProcessPipeCapture.defaultMaxBytes - 1
diff --git a/docs/doubao.md b/docs/doubao.md
index 40c7cfa2d4..aeef8310d4 100644
--- a/docs/doubao.md
+++ b/docs/doubao.md
@@ -1,5 +1,5 @@
---
-summary: "Doubao provider notes: API-key auth and Volcengine Ark request-limit tracking."
+summary: "Doubao provider notes: arkcli plan usage, API-key auth, and Volcengine Ark request limits."
read_when:
- Adding or modifying the Doubao provider
- Debugging Doubao API-key setup
@@ -8,15 +8,21 @@ read_when:
# Doubao Provider
-Doubao tracks Volcengine Ark request-limit headers by probing the chat-completions endpoint with a configured API key.
+Doubao reads Coding Plan and Agent Plan quota windows from the official `arkcli` CLI. Existing Volcengine AK/SK credentials and Ark API-key request-limit probes remain supported.
## Setup
1. Enable **Doubao** in Settings → Providers.
-2. Paste an API key in the provider settings, or set `ARK_API_KEY`, `VOLCENGINE_API_KEY`, or `DOUBAO_API_KEY`.
-3. Refresh provider usage.
+2. Install `arkcli`, then run `arkcli auth login`.
+3. Refresh provider usage. CodexBar resolves `arkcli` through `ARKCLI_PATH`, the login-shell/host `PATH`, and standard install locations.
+
+To keep using API credentials instead, paste an API key or AK/SK pair in provider settings. Environment variables `ARK_API_KEY`, `VOLCENGINE_API_KEY`, and `DOUBAO_API_KEY` remain supported.
## Behavior
-- Endpoint: `POST https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions`
+- Auto mode honors configured API credentials first so an ambient arkcli SSO session cannot silently switch accounts. Without configured credentials, it uses `arkcli usage plan --format json`.
+- CLI mode uses only `arkcli`; API mode uses only configured AK/SK or Ark API-key credentials.
+- `arkcli` output provides distinct personal and team Coding Plan and Agent Plan 5-hour, weekly, and monthly windows when those subscriptions are present.
+- Ark API-key endpoint: `POST https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions`
- Probe models: `doubao-seed-2.0-code`, `doubao-1.5-pro-32k`, `doubao-lite-32k`
- Reads `x-ratelimit-remaining-requests`, `x-ratelimit-limit-requests`, and `x-ratelimit-reset-requests` when returned.
- If the key is valid but rate-limit headers are missing, CodexBar shows the key as active and links to the dashboard for details.
+- Agent Plan bearer keys for `/api/plan/v3/chat/completions` are not part of the arkcli usage path; see issue #1835.