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 @@ -Doubao + + Doubao + + + + + 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.