diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift index 40d871d50ad..6ece1d5e1d3 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift @@ -92,7 +92,7 @@ enum ProviderImplementationManifest { FutureAGIProviderImplementation(), PluginAPIKeyProviderImplementation(spec: DevPassProviderDescriptor.spec), PluginAPIKeyProviderImplementation(spec: AtlasCloudProviderDescriptor.spec), - PluginAPIKeyProviderImplementation(spec: VercelProviderDescriptor.spec), + VercelProviderImplementation(), PluginAPIKeyProviderImplementation(spec: LLMManProviderDescriptor.spec), PluginAPIKeyProviderImplementation(spec: XKiroProviderDescriptor.spec), PluginCookieProviderImplementation(spec: MuseAIProviderDescriptor.spec), diff --git a/Sources/CodexBar/Providers/Vercel/VercelProviderImplementation.swift b/Sources/CodexBar/Providers/Vercel/VercelProviderImplementation.swift new file mode 100644 index 00000000000..d48d2e9042d --- /dev/null +++ b/Sources/CodexBar/Providers/Vercel/VercelProviderImplementation.swift @@ -0,0 +1,110 @@ +import CodexBarCore +import Foundation +import SwiftUI + +struct VercelProviderImplementation: ProviderImplementation { + let id: UsageProvider = .vercel + var loadTeams: @Sendable ([String: String]) async throws -> [VercelCLITeam] = { + try await VercelCLIUsageFetcher().teams(environment: $0) + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + PluginAPIKeyProviderImplementation(spec: VercelProviderDescriptor.spec).observeSettings(settings) + _ = settings.providerConfig(for: .vercel)?.source + } + + @MainActor + func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode { + context.settings.providerConfig(for: .vercel)?.source ?? .auto + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + PluginAPIKeyProviderImplementation(spec: VercelProviderDescriptor.spec).settingsFields(context: context) + .filter { $0.id != "vercel-team-id" } + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let refresh: (String) async -> Void = { _ in + await context.store.refreshProvider(.vercel, allowDisabled: true) + } + let source = Binding( + get: { (context.settings.providerConfig(for: .vercel)?.source ?? .auto).rawValue }, + set: { value in + guard let mode = ProviderSourceMode(rawValue: value), [.auto, .api, .cli].contains(mode) else { return } + guard (context.settings.providerConfig(for: .vercel)?.source ?? .auto) != mode else { return } + context.settings.updateProviderConfig(provider: .vercel) { $0.source = mode == .auto ? nil : mode } + context.store.clearProviderState(.vercel) + }) + let team = Binding( + get: { context.settings[providerConfig: .vercel, field: .workspace] }, + set: { value in + guard context.settings[providerConfig: .vercel, field: .workspace] != value else { return } + context.settings[providerConfig: .vercel, field: .workspace] = value + context.store.clearProviderState(.vercel) + }) + var options = [ProviderSettingsPickerOption(id: "", title: "Auto")] + + context.store.vercelTeams.map { + ProviderSettingsPickerOption(id: $0.id, title: "\($0.name) (\($0.slug))") + } + if !team.wrappedValue.isEmpty, !options.contains(where: { $0.id == team.wrappedValue }) { + options.append(ProviderSettingsPickerOption(id: team.wrappedValue, title: team.wrappedValue)) + } + return [ + ProviderSettingsPickerDescriptor( + id: "vercel-source", + title: "Usage source", + subtitle: "", + binding: source, + options: [ + .init(id: "auto", title: "Auto"), + .init(id: "cli", title: "Vercel CLI"), + .init(id: "api", title: "API key"), + ], + isVisible: nil, + onChange: refresh), + ProviderSettingsPickerDescriptor( + id: "vercel-team", + title: "Team", + subtitle: "Uses the CLI's current team by default. Refresh to choose another team.", + binding: team, + options: options, + isVisible: { source.wrappedValue != "api" }, + isEnabled: { !context.store.vercelTeamsRefreshing }, + onChange: refresh, + trailingText: { context.statusText("vercel-teams") }, + trailingActions: [.init( + id: "vercel-refresh-teams", + title: "Refresh", + style: .bordered, + isVisible: nil, + perform: { await self.refreshTeams(context: context) })]), + ] + } + + @MainActor + private func refreshTeams(context: ProviderSettingsContext) async { + guard !context.store.vercelTeamsRefreshing else { return } + context.store.vercelTeamsRefreshing = true + defer { context.store.vercelTeamsRefreshing = false } + context.setStatusText("vercel-teams", L("Refreshing")) + let revision = context.settings.providerConfigRevision(for: .vercel) + do { + let teams = try await self.loadTeams(context.store.environmentBase) + guard !Task.isCancelled, context.settings.providerConfigRevision(for: .vercel) == revision else { + context.setStatusText("vercel-teams", nil) + return + } + context.store.vercelTeams = teams + context.setStatusText("vercel-teams", nil) + } catch { + context.setStatusText( + "vercel-teams", + Task.isCancelled || + context.settings.providerConfigRevision(for: .vercel) != revision ? nil : error + .localizedDescription) + } + } +} diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 6924d32cdb1..f69c0ab7200 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -2205,6 +2205,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "مخزنة في ~/.codexbar/config.json. أنشئ واحدًا على console.x.ai ضمن الإعدادات > مفاتيح الإدارة؛ لا يتم قبول مفاتيح الاستدلال API."; "xai-..." = "xai-..."; "Team ID" = "فريق ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "يستخدم فريق CLI الحالي افتراضيًا. حدّث القائمة لاختيار فريق آخر."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "مطلوب. يظهر في وحدة التحكم xAI URL وإعدادات الفريق."; "Sakana AI cost summary is not supported." = "ملخص تكلفة Sakana AI غير مدعوم."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "مخزنة في ~/.codexbar/config.json. انسخ رأس طلب وحدة التحكم Sakana AI Cookie."; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index a7388697721..7f6d9fa2f03 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -2205,6 +2205,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "Emmagatzemat a ~/.codexbar/config.json. Creeu-ne un a console.x.ai a Configuració > Claus de gestió; Les claus d'inferència API no s'accepten."; "xai-..." = "xai-..."; "Team ID" = "Equip ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "Per defecte fa servir l'equip actual del CLI. Actualitza la llista per triar-ne un altre."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "Obligatori. Es mostra a la consola xAI URL i a la configuració de l'equip."; "Sakana AI cost summary is not supported." = "El resum de costos Sakana AI no s'admet."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "Emmagatzemat a ~/.codexbar/config.json. Copieu la capçalera de sol·licitud de la consola Sakana AI Cookie."; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 33aee0005ef..e36c3dc4129 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -2203,6 +2203,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "Gespeichert in ~/.codexbar/config.json. Erstellen Sie einen unter console.x.ai unter Einstellungen > Verwaltungsschlüssel. Inferenzschlüssel API werden nicht akzeptiert."; "xai-..." = "xai-..."; "Team ID" = "Team ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "Verwendet standardmäßig das aktuelle CLI-Team. Aktualisiere die Liste, um ein anderes Team auszuwählen."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "Erforderlich. Wird in der xAI-Konsole URL und in den Teameinstellungen angezeigt."; "Sakana AI cost summary is not supported." = "Die Kostenübersicht Sakana AI wird nicht unterstützt."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "Gespeichert in ~/.codexbar/config.json. Kopieren Sie den Anforderungsheader der Sakana AI-Konsole Cookie."; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 9cb4eff2923..200b165d241 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -2205,6 +2205,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted."; "xai-..." = "xai-..."; "Team ID" = "Team ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "Uses the CLI's current team by default. Refresh to choose another team."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "Required. Shown in the xAI Console URL and team settings."; "Sakana AI cost summary is not supported." = "Sakana AI cost summary is not supported."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header."; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index b834436df4e..728133b1d13 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -2199,6 +2199,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "Almacenado en ~/.codexbar/config.json. Cree uno en console.x.ai en Configuración > Claves de administración; No se aceptan claves de inferencia API."; "xai-..." = "xai-..."; "Team ID" = "Equipo ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "Usa el equipo actual de la CLI de forma predeterminada. Actualiza la lista para elegir otro equipo."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "Requerido. Se muestra en la consola xAI URL y en la configuración del equipo."; "Sakana AI cost summary is not supported." = "No se admite el resumen de costos Sakana AI."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "Almacenado en ~/.codexbar/config.json. Copie el encabezado de solicitud Cookie de la consola Sakana AI."; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 79ae5fa89f4..ca54f695c3c 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -2205,6 +2205,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "ذخیره شده در ~/.codexbar/config.json. یکی را در console.x.ai در زیر تنظیمات > کلیدهای مدیریت ایجاد کنید. کلیدهای استنتاج API پذیرفته نمی شوند."; "xai-..." = "xai-..."; "Team ID" = "تیم ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "به‌طور پیش‌فرض از تیم فعلی CLI استفاده می‌کند. برای انتخاب تیم دیگر، فهرست را تازه‌سازی کنید."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "مورد نیاز. در کنسول xAI URL و تنظیمات تیم نشان داده شده است."; "Sakana AI cost summary is not supported." = "خلاصه هزینه Sakana AI پشتیبانی نمی شود."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "ذخیره شده در ~/.codexbar/config.json. هدر درخواست کنسول Sakana AI Cookie را کپی کنید."; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 201509fd274..6c8cd9f0117 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -2200,6 +2200,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "Stocké dans ~/.codexbar/config.json. Créez-en un sur console.x.ai sous Paramètres > Clés de gestion ; Les clés d'inférence API ne sont pas acceptées."; "xai-..." = "xai-..."; "Team ID" = "Équipe ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "Utilise par défaut l'équipe actuelle du CLI. Actualisez la liste pour choisir une autre équipe."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "Obligatoire. Affiché dans la console xAI URL et les paramètres d'équipe."; "Sakana AI cost summary is not supported." = "Le récapitulatif des coûts Sakana AI n’est pas pris en charge."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "Stocké dans ~/.codexbar/config.json. Copiez l'en-tête de requête Cookie de la console Sakana AI."; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 1c4660a6b1c..0410beac549 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -2200,6 +2200,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "Almacenado en ~/.codexbar/config.json. Crea un en console.x.ai en Configuración > Teclas de xestión; As chaves de inferencia API non se aceptan."; "xai-..." = "xai-..."; "Team ID" = "Equipo ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "Usa o equipo actual da CLI por defecto. Actualiza a lista para escoller outro equipo."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "Obrigatorio. Móstrase na consola xAI URL e na configuración do equipo."; "Sakana AI cost summary is not supported." = "O resumo de custos Sakana AI non é compatible."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "Almacenado en ~/.codexbar/config.json. Copie a cabeceira da solicitude da consola Sakana AI Cookie."; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index bd0678bbd83..579ee0e677e 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -2205,6 +2205,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "Disimpan di ~/.codexbar/config.json. Buat satu di console.x.ai di bawah Pengaturan > Kunci Manajemen; kesimpulan kunci API tidak diterima."; "xai-..." = "xai-..."; "Team ID" = "Tim ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "Secara default menggunakan tim CLI saat ini. Segarkan daftar untuk memilih tim lain."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "Diperlukan. Ditampilkan di Konsol xAI URL dan pengaturan tim."; "Sakana AI cost summary is not supported." = "Ringkasan biaya Sakana AI tidak didukung."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "Disimpan di ~/.codexbar/config.json. Salin header permintaan Sakana AI konsol Cookie."; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 92791acc331..5d5be530c4b 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -2205,6 +2205,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "Memorizzato in ~/.codexbar/config.json. Creane uno su console.x.ai in Impostazioni > Chiavi di gestione; le chiavi di inferenza API non sono accettate."; "xai-..." = "xai-..."; "Team ID" = "Squadra ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "Usa il team attuale della CLI per impostazione predefinita. Aggiorna l'elenco per scegliere un altro team."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "Obbligatorio. Visualizzato nella console xAI URL e nelle impostazioni del team."; "Sakana AI cost summary is not supported." = "Il riepilogo dei costi Sakana AI non è supportato."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "Memorizzato in ~/.codexbar/config.json. Copia l'intestazione della richiesta Cookie della console Sakana AI."; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 6b801273d34..e17fc23f30d 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -2201,6 +2201,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "~/.codexbar/config.json に保存されます。 [設定] > [管理キー] の console.x.ai で作成します。推論 API キーは受け入れられません。"; "xai-..." = "xai-..."; "Team ID" = "チームID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "既定では CLI の現在のチームを使用します。別のチームを選ぶには一覧を更新してください。"; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "必須。 xAI コンソール URL およびチーム設定に表示されます。"; "Sakana AI cost summary is not supported." = "Sakana AI コスト概要はサポートされていません。"; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "~/.codexbar/config.json に保存されます。 Sakana AI コンソールの Cookie リクエスト ヘッダーをコピーします。"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 9488593e106..e3572360314 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -2169,6 +2169,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "~/.codexbar/config.json에 저장됩니다. 설정 > 관리 키 아래 console.x.ai에서 하나를 생성합니다. 추론 API 키는 허용되지 않습니다."; "xai-..." = "xai-..."; "Team ID" = "팀 ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "기본적으로 CLI의 현재 팀을 사용합니다. 다른 팀을 선택하려면 새로고침하세요."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "필수입니다. xAI 콘솔 URL 및 팀 설정에 표시됩니다."; "Sakana AI cost summary is not supported." = "Sakana AI 비용 요약은 지원되지 않습니다."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "~/.codexbar/config.json에 저장됩니다. Sakana AI 콘솔 Cookie 요청 헤더를 복사합니다."; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index bf7951c59ce..44fd1627957 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -2200,6 +2200,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "Opgeslagen in ~/.codexbar/config.json. Maak er een aan op console.x.ai onder Instellingen > Beheersleutels; gevolgtrekking API-sleutels worden niet geaccepteerd."; "xai-..." = "xai-..."; "Team ID" = "Team ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "Gebruikt standaard het huidige CLI-team. Vernieuw de lijst om een ander team te kiezen."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "Vereist. Weergegeven in de xAI-console URL en teaminstellingen."; "Sakana AI cost summary is not supported." = "Sakana AI-kostenoverzicht wordt niet ondersteund."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "Opgeslagen in ~/.codexbar/config.json. Kopieer de Sakana AI-console Cookie-verzoekheader."; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index ba830d1b70a..4a266eb3baa 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -2204,6 +2204,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "Przechowywane w ~/.codexbar/config.json. Utwórz go pod adresem console.x.ai w obszarze Ustawienia > Klucze zarządzania; wnioskowanie Klucze API nie są akceptowane."; "xai-..." = "xai-..."; "Team ID" = "Zespół ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "Domyślnie używa bieżącego zespołu CLI. Odśwież listę, aby wybrać inny zespół."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "Wymagane. Wyświetlane w konsoli xAI URL i ustawieniach zespołu."; "Sakana AI cost summary is not supported." = "Podsumowanie kosztów Sakana AI nie jest obsługiwane."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "Przechowywane w ~/.codexbar/config.json. Skopiuj nagłówek żądania Cookie konsoli Sakana AI."; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 3fdf5d89a66..c21bb845398 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -2201,6 +2201,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "Armazenado em ~/.codexbar/config.json. Crie um em console.x.ai em Configurações > Chaves de gerenciamento; chaves de inferência API não são aceitas."; "xai-..." = "xai-..."; "Team ID" = "Equipe ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "Usa a equipe atual da CLI por padrão. Atualize a lista para escolher outra equipe."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "Obrigatório. Mostrado no console xAI URL e nas configurações da equipe."; "Sakana AI cost summary is not supported." = "O resumo de custos Sakana AI não é suportado."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "Armazenado em ~/.codexbar/config.json. Copie o cabeçalho de solicitação Cookie do console Sakana AI."; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 4462e936350..a23f8c5912f 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -2202,6 +2202,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "Хранится в ~/.codexbar/config.json. Создайте его по адресу console.x.ai в разделе «Настройки» > «Ключи управления»; Ключи вывода API не принимаются."; "xai-..." = "xai-..."; "Team ID" = "Команда ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "По умолчанию используется текущая команда CLI. Обновите список, чтобы выбрать другую команду."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "Обязательно. Отображается в консоли xAI URL и настройках команды."; "Sakana AI cost summary is not supported." = "Сводка затрат Sakana AI не поддерживается."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "Хранится в ~/.codexbar/config.json. Скопируйте заголовок запроса консоли Sakana AI Cookie."; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index b99e3b70c0a..217a35752db 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -2200,6 +2200,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "Lagrat i ~/.codexbar/config.json. Skapa en på console.x.ai under Inställningar > Hanteringsnycklar; inferens API-nycklar accepteras inte."; "xai-..." = "xai-..."; "Team ID" = "Team ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "Använder aktuellt CLI-team som standard. Uppdatera listan för att välja ett annat team."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "Krävs. Visas i xAI-konsolen URL och teaminställningar."; "Sakana AI cost summary is not supported." = "Sakana AI kostnadsöversikt stöds inte."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "Lagrat i ~/.codexbar/config.json. Kopiera Sakana AI-konsolens Cookie-förfrågningshuvud."; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 683f12bbe7f..31e63d1d86f 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -2205,6 +2205,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "เก็บไว้ใน ~/.codexbar/config.json สร้างอันหนึ่งที่ console.x.ai ภายใต้การตั้งค่า > คีย์การจัดการ ไม่ยอมรับคีย์การอนุมาน API"; "xai-..." = "xai-..."; "Team ID" = "ทีม ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "ใช้ทีมปัจจุบันของ CLI เป็นค่าเริ่มต้น รีเฟรชรายการเพื่อเลือกทีมอื่น"; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "จำเป็น แสดงในคอนโซล xAI URL และการตั้งค่าทีม"; "Sakana AI cost summary is not supported." = "ไม่รองรับการสรุปต้นทุน Sakana AI"; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "เก็บไว้ใน ~/.codexbar/config.json คัดลอกส่วนหัวคำขอคอนโซล Sakana AI Cookie"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index bf4fc74387c..40ff93dc5a9 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -2204,6 +2204,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "~/.codexbar/config.json konumunda saklanır. Ayarlar > Yönetim Anahtarları altında console.x.ai'de bir tane oluşturun; çıkarım API anahtarları kabul edilmez."; "xai-..." = "xai-..."; "Team ID" = "Takım ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "Varsayılan olarak CLI'daki mevcut takımı kullanır. Başka bir takım seçmek için listeyi yenileyin."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "Gerekli. xAI Konsolu URL ve ekip ayarlarında gösterilir."; "Sakana AI cost summary is not supported." = "Sakana AI maliyet özeti desteklenmiyor."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "~/.codexbar/config.json konumunda saklanır. Sakana AI konsolu Cookie istek başlığını kopyalayın."; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 410936dbda4..ce0d5490612 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -2201,6 +2201,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "Зберігається в ~/.codexbar/config.json. Створіть його на console.x.ai у меню «Параметри» > «Ключі керування»; висновок Ключі API не приймаються."; "xai-..." = "xai-..."; "Team ID" = "Команда ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "Типово використовується поточна команда CLI. Оновіть список, щоб вибрати іншу команду."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "Обов'язковий. Показано в налаштуваннях xAI Console URL і команди."; "Sakana AI cost summary is not supported." = "Підсумок вартості Sakana AI не підтримується."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "Зберігається в ~/.codexbar/config.json. Скопіюйте заголовок запиту Cookie консолі Sakana AI."; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index a9e4e9e837d..5ee204c47f3 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -2202,6 +2202,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "Được lưu trữ trong ~/.codexbar/config.json. Tạo một cái tại console.x.ai trong Cài đặt > Khóa quản lý; suy luận khóa API không được chấp nhận."; "xai-..." = "xai-..."; "Team ID" = "Đội ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "Mặc định dùng đội hiện tại của CLI. Làm mới danh sách để chọn đội khác."; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "Bắt buộc. Hiển thị trong Bảng điều khiển xAI URL và cài đặt nhóm."; "Sakana AI cost summary is not supported." = "Tóm tắt chi phí Sakana AI không được hỗ trợ."; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "Được lưu trữ trong ~/.codexbar/config.json. Sao chép tiêu đề yêu cầu Cookie của bảng điều khiển Sakana AI."; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 9e9ff84a525..1516ade0900 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -2196,6 +2196,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "存储在~/.codexbar/config.json中。在“设置”>“管理密钥”下的 console.x.ai 创建一个;推断 API 密钥不被接受。"; "xai-..." = "xai-..."; "Team ID" = "团队 ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "默认使用 CLI 当前的团队。刷新列表可选择其他团队。"; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "必填。显示在 xAI 控制台 URL 和团队设置中。"; "Sakana AI cost summary is not supported." = "暂不支持 Sakana AI 费用汇总。"; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "存储在~/.codexbar/config.json中。复制 Sakana AI 控制台 Cookie 请求标头。"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 397ab6282ee..550758c90ce 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -2183,6 +2183,8 @@ "Stored in ~/.codexbar/config.json. Create one at console.x.ai under Settings > Management Keys; inference API keys are not accepted." = "儲存在~/.codexbar/config.json中。在「設定」>「管理金鑰」下的 console.x.ai 建立一個;推斷 API 金鑰不被接受。"; "xai-..." = "xai-..."; "Team ID" = "團隊 ID"; +"Uses the CLI's current team by default. Refresh to choose another team." = "預設使用 CLI 目前的團隊。重新整理清單即可選擇其他團隊。"; +"Vercel CLI" = "Vercel CLI"; "Required. Shown in the xAI Console URL and team settings." = "必填。顯示在 xAI 控制台 URL 和團隊設定中。"; "Sakana AI cost summary is not supported." = "暫不支持 Sakana AI 費用匯總。"; "Stored in ~/.codexbar/config.json. Copy the Sakana AI console Cookie request header." = "儲存在~/.codexbar/config.json中。複製 Sakana AI 控制台 Cookie 請求標頭。"; diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 44877fea999..117754eaa2e 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -164,6 +164,8 @@ final class UsageStore { var codexAccountSnapshots: [CodexAccountUsageSnapshot] = [] var codexSettingsRefreshingAccountIDs: Set = [] var kiloScopeSnapshots: [KiloScopeSnapshot] = [] + var vercelTeams: [VercelCLITeam] = [] + var vercelTeamsRefreshing = false var claudeSwapAccountSnapshots: [ProviderAccountUsageSnapshot] = [] var claudeSwapLastRefreshAt: Date? var claudeSwapLastError: String? diff --git a/Sources/CodexBarCore/Providers/Vercel/VercelCLIUsageFetcher.swift b/Sources/CodexBarCore/Providers/Vercel/VercelCLIUsageFetcher.swift new file mode 100644 index 00000000000..f356b26f679 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Vercel/VercelCLIUsageFetcher.swift @@ -0,0 +1,314 @@ +import Foundation + +public struct VercelCLIUsageFetcher: Sendable { + var run: @Sendable ([String], [String: String], TimeInterval) async throws -> String = Self.runCommand + var clockNow: @Sendable () -> ContinuousClock.Instant = { ContinuousClock.now } + + public init() {} + + init( + run: @escaping @Sendable ([String], [String: String], TimeInterval) async throws -> String, + clockNow: @escaping @Sendable () -> ContinuousClock.Instant = { ContinuousClock.now }) + { + self.run = run + self.clockNow = clockNow + } + + func fetch(environment: [String: String], now: Date = Date()) async throws -> UsageSnapshot { + let deadline = self.clockNow().advanced(by: .seconds(30)) + let teamID: String + if let configured = SettingsValue.cleaned(environment["VERCEL_TEAM_ID"]) { + guard Self.isTeamID(configured) else { throw Self.invalidOutput } + teamID = configured + } else if let current = try await self.teams(environment: environment, deadline: deadline) + .first(where: \.current) + { + teamID = current.id + } else { + throw ProviderFetchClassifiedError( + kind: .missingCredential, + message: "Select a Vercel team in CodexBar or run 'vercel teams switch' to choose your CLI team.") + } + let user: UserResponse = try await self.read( + "/v2/user", teamID: teamID, environment: environment, deadline: deadline) + guard !user.user.id.isEmpty else { throw Self.invalidOutput } + var matches: [Budget] = [] + var cursor: String? + var cursors: Set = [] + for pageIndex in 0..<100 { + var endpoint = "/ai-gateway/budgets/list?teamId=\(teamID)&scopeType=user&limit=200" + if let cursor { + guard let encoded = cursor.addingPercentEncoding(withAllowedCharacters: .alphanumerics) else { + throw Self.invalidOutput + } + endpoint += "&cursor=\(encoded)" + } + let page: BudgetResponse = try await self.read( + endpoint, teamID: teamID, environment: environment, deadline: deadline) + matches += page.budgets.filter { + $0.scopeType == "user" && $0.scopeId == user.user.id && $0.active && !$0.archived + } + guard let next = page.nextCursor else { break } + guard !next.isEmpty, cursors.insert(next).inserted, pageIndex < 99 else { throw Self.invalidOutput } + cursor = next + } + let custom = matches.filter { $0.source != "default" } + let effective = custom.isEmpty ? matches : custom + guard effective.count <= 1 else { throw Self.invalidOutput } + let identity = ProviderIdentitySnapshot( + providerID: .vercel, + accountEmail: user.user.email, + accountOrganization: teamID, + loginMethod: "CLI", + accountID: user.user.id) + guard let budget = effective.first else { + return try UsageSnapshot( + primary: nil, + secondary: nil, + details: [ProviderDetailSection(title: "Personal budget", rows: [ + .makeRow(label: "Budget", value: "No personal budget configured"), + ])], + updatedAt: now, + identity: identity, + dataConfidence: .exact) + } + guard budget.limitAmount.isFinite, budget.limitAmount > 0, + budget.currentSpend.isFinite, budget.currentSpend >= 0 + else { throw Self.invalidOutput } + let percent = budget.currentSpend / budget.limitAmount * 100 + guard percent.isFinite else { throw Self.invalidOutput } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + calendar.firstWeekday = 2 + calendar.minimumDaysInFirstWeek = 4 + let interval = budget.refreshPeriod.component.flatMap { calendar.dateInterval(of: $0, for: now) } + let spent = UsageFormatter.usdString(budget.currentSpend) + let limit = UsageFormatter.usdString(budget.limitAmount) + let window = RateWindow( + usedPercent: percent, + windowMinutes: interval.map { Int($0.duration / 60) }, + resetsAt: interval?.end, + resetDescription: "\(spent) / \(limit)") + return try UsageSnapshot( + primary: window, + secondary: nil, + details: [ProviderDetailSection(title: "Personal budget", rows: [ + .makeRow(label: "Spent", value: spent), + .makeRow(label: "Limit", value: limit), + .makeRow( + label: "Remaining", + value: UsageFormatter.usdString(max(0, budget.limitAmount - budget.currentSpend))), + .makeRow(label: "Refresh", value: budget.refreshPeriod.rawValue), + ])], + updatedAt: now, + identity: identity, + dataConfidence: .exact) + } + + public func teams(environment: [String: String]) async throws -> [VercelCLITeam] { + try await self.teams(environment: environment, deadline: self.clockNow().advanced(by: .seconds(30))) + } + + private func teams( + environment: [String: String], deadline: ContinuousClock.Instant) async throws -> [VercelCLITeam] + { + var teams: [VercelCLITeam] = [] + var ids: Set = [] + var cursor: Int? + var cursors: Set = [] + for _ in 0..<20 { + var arguments = ["teams", "list", "--format", "json", "--non-interactive"] + if let cursor { arguments += ["--next", String(cursor)] } + let page: TeamsResponse = try await self.command(arguments, environment: environment, deadline: deadline) + for team in page.teams where team.id.hasPrefix("team_") { + guard Self.isTeamID(team.id), !team.name.isEmpty, !team.slug.isEmpty, + ids.insert(team.id).inserted + else { throw Self.invalidOutput } + teams.append(team) + } + cursor = page.pagination?.next + guard let next = cursor else { + guard teams.filter(\.current).count <= 1 else { throw Self.invalidOutput } + return teams + } + guard next >= 0, cursors.insert(next).inserted else { throw Self.invalidOutput } + } + throw Self.invalidOutput + } + + private static func isTeamID(_ value: String) -> Bool { + value.range(of: #"^team_[A-Za-z0-9]+$"#, options: .regularExpression) != nil + } + + private func read( + _ endpoint: String, + teamID: String, + environment: [String: String], + deadline: ContinuousClock.Instant) async throws -> Value + { + // CLI의 기본 팀이 URL의 teamId를 덮어쓰므로 호출 범위도 같은 팀으로 고정한다. + try await self.command( + ["api", endpoint, "--raw", "--non-interactive", "--scope", teamID], + environment: environment, + deadline: deadline) + } + + private func command( + _ arguments: [String], environment: [String: String], deadline: ContinuousClock.Instant) async throws -> Value + { + let timeout = try self.commandTimeout(deadline: deadline) + let output: String + do { + output = try await self.run(arguments, Self.commandEnvironment(environment), timeout) + } catch { + try Task.checkCancellation() + switch error { + case is CancellationError: + throw CancellationError() + case SubprocessRunnerError.binaryNotFound: + throw ProviderFetchClassifiedError( + kind: .missingCredential, + message: "Install the Vercel CLI and run 'vercel login' to read personal budgets.") + case SubprocessRunnerError.timedOut: + throw Self.timedOut + case SubprocessRunnerError.outputTooLarge: + throw ProviderFetchClassifiedError( + kind: .parseFailure, message: "Vercel CLI response exceeded the 1 MiB output limit.") + default: + throw ProviderFetchClassifiedError( + kind: .apiFailure, + message: "Vercel CLI could not read personal budgets. " + + "Check 'vercel login', team access, and CLI updates.") + } + } + _ = try self.commandTimeout(deadline: deadline) + guard let decoded = try? JSONDecoder().decode(Value.self, from: Data(output.utf8)) else { + throw Self.invalidOutput + } + _ = try self.commandTimeout(deadline: deadline) + return decoded + } + + private func commandTimeout(deadline: ContinuousClock.Instant) throws -> TimeInterval { + try Task.checkCancellation() + let remaining = self.clockNow().duration(to: deadline).components + let seconds = Double(remaining.seconds) + Double(remaining.attoseconds) / 1e18 + guard seconds > 0 else { throw Self.timedOut } + return min(15, seconds) + } + + private static let timedOut = ProviderFetchClassifiedError( + kind: .networkFailure, message: "Vercel CLI usage or team lookup timed out. Try refreshing again.") + + private static let invalidOutput = ProviderFetchClassifiedError( + kind: .parseFailure, message: "Vercel CLI returned an unrecognized usage or team response.") + + private static func runCommand( + _ arguments: [String], environment: [String: String], timeout: TimeInterval) async throws -> String + { + let path = PathBuilder.effectivePATH( + purposes: [.tty, .nodeTooling], env: environment, loginPATH: LoginShellPathCache.shared.current) + guard let binary = BinaryLocator.find( + "vercel", in: path.split(separator: ":").map(String.init), fileManager: .default) + else { + throw SubprocessRunnerError.binaryNotFound("vercel") + } + var childEnvironment = environment + childEnvironment["PATH"] = path + let result = try await SubprocessRunner.run( + binary: binary, + arguments: arguments, + environment: childEnvironment, + timeout: timeout, + maxOutputBytes: 1024 * 1024, + standardInput: FileHandle.nullDevice, + currentDirectoryURL: URL(fileURLWithPath: environment["HOME"] ?? NSHomeDirectory(), isDirectory: true), + label: "vercel-personal-budget") + return result.stdout + } + + private static func commandEnvironment(_ environment: [String: String]) -> [String: String] { + let allowed = Set([ + "PATH", + "HOME", "XDG_CONFIG_HOME", "APPDATA", "LANG", "LC_ALL", "LC_CTYPE", "TZ", + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", + "http_proxy", "https_proxy", "all_proxy", "no_proxy", + ]) + var childEnvironment = environment.filter { allowed.contains($0.key) } + // 이전 CLI의 SAML 재인증도 브라우저를 열지 않도록 CI 모드를 명시한다. + childEnvironment["CI"] = "1" + childEnvironment["NO_COLOR"] = "1" + childEnvironment["VERCEL_TELEMETRY_DISABLED"] = "1" + return childEnvironment + } + + private struct UserResponse: Decodable { + let user: User + struct User: Decodable { + let id: String + let email: String? + } + } + + private struct BudgetResponse: Decodable { + let budgets: [Budget] + let nextCursor: String? + } + + private struct TeamsResponse: Decodable { + let teams: [VercelCLITeam] + let pagination: Pagination? + struct Pagination: Decodable { + let next: Int? + } + } + + private struct Budget: Decodable { + let scopeType: String + let scopeId: String + let limitAmount: Double + let currentSpend: Double + let refreshPeriod: RefreshPeriod + let active: Bool + let archived: Bool + let source: String? + } + + private enum RefreshPeriod: String, Decodable { + case daily, weekly, monthly, none + + var component: Calendar.Component? { + switch self { + case .daily: .day + case .weekly: .weekOfYear + case .monthly: .month + case .none: nil + } + } + } +} + +struct VercelCLIFetchStrategy: ProviderFetchStrategy { + let id = "vercel.cli" + let kind: ProviderFetchKind = .cli + var fetcher = VercelCLIUsageFetcher() + + func isAvailable(_: ProviderFetchContext) async -> Bool { true } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + try await self.makeResult( + usage: self.fetcher.fetch(environment: context.env), sourceLabel: "cli") + } + + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto && SettingsValue.cleaned(context.env["VERCEL_TEAM_ID"]) == nil + && !(error is CancellationError) + } +} + +public struct VercelCLITeam: Decodable, Sendable, Equatable, Identifiable { + public let id: String + public let slug: String + public let name: String + public let current: Bool +} diff --git a/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift index 3e430bcbf07..11aeb309959 100644 --- a/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift @@ -1,23 +1,76 @@ import Foundation public enum VercelProviderDescriptor { - public static let descriptor = Self.spec.makeDescriptor() + public static let descriptor = Self.spec.makeDescriptor(fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api, .cli], + pipeline: ProviderFetchPipeline(resolveStrategies: { context in + switch context.sourceMode { + case .cli: + [VercelCLIFetchStrategy()] + case .auto where SettingsValue.cleaned(context.env["VERCEL_TEAM_ID"]) != nil: + [VercelCLIFetchStrategy()] + case .auto: + [VercelCLIFetchStrategy(), VercelAPIFetchStrategy()] + case .api: + [VercelAPIFetchStrategy()] + case .web, .oauth: + [] + } + }))) public static let spec = PluginProviderSpec( id: .vercel, displayName: "Vercel AI Gateway", - sessionLabel: "Balance", + sessionLabel: "Personal budget", weeklyLabel: "Balance", toggleTitle: "Show Vercel AI Gateway balance", balanceOnly: true, dashboardURL: "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway", color: .init(hex: 0xFFFFFF), confetti: [0xFFFFFF, 0xA3A3A3], + progressColorStyle: .label, noDataMessage: "Vercel AI Gateway cost history is not available.", + history: .unavailable, + burnDownWidgetSelectable: false, environmentKey: "AI_GATEWAY_API_KEY", missingCredentialMessage: { _ in "Set a Vercel AI Gateway API key in Settings or AI_GATEWAY_API_KEY." }, + config: ProviderConfigCapabilities(workspaceIDValidationOrder: 9), presentation: ProviderUsagePresentation(menuBarBalanceDetailLabels: ["Available balance"]), apiKeyField: .init( id: "vercel-api-key", title: "Vercel AI Gateway API key", - subtitle: "Saved in CodexBar's local config file. Or set AI_GATEWAY_API_KEY.")) + subtitle: "Saved in CodexBar's local config file. Or set AI_GATEWAY_API_KEY."), + workspaceField: .init( + environmentKey: "VERCEL_TEAM_ID", + field: .init( + id: "vercel-team-id", + title: "Team ID", + subtitle: "Uses the CLI's current team by default. Refresh to choose another team.", + placeholder: nil))) +} + +struct VercelAPIFetchStrategy: ProviderFetchStrategy { + var base = VercelProviderDescriptor.spec.makeStrategy() + var id: String { + self.base.id + } + + var kind: ProviderFetchKind { + self.base.kind + } + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + await self.base.isAvailable(context) + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + try await self.base.fetch(context) + } + + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + self.base.shouldFallback(on: error, context: context) + } + + func diagnostic(forPriorFailure _: Error) -> String? { + "Personal budget unavailable from Vercel CLI; showing the API key's team credits instead." + } } diff --git a/Tests/CodexBarTests/APIBalanceScreenshotRenderTests.swift b/Tests/CodexBarTests/APIBalanceScreenshotRenderTests.swift index 764059afc76..7d3c552b8f5 100644 --- a/Tests/CodexBarTests/APIBalanceScreenshotRenderTests.swift +++ b/Tests/CodexBarTests/APIBalanceScreenshotRenderTests.swift @@ -12,8 +12,12 @@ final class APIBalanceScreenshotRenderTests: XCTestCase { } let directory = URL(fileURLWithPath: path, isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - for provider in [UsageProvider.atlascloud, .vercel] { - let snapshot = try await APIBalancePluginTests.fetch(provider.rawValue, engine: .quickJS) + let snapshots: [(String, UsageProvider, UsageSnapshot)] = try await [ + ("atlascloud", .atlascloud, APIBalancePluginTests.fetch("atlascloud", engine: .quickJS)), + ("vercel", .vercel, APIBalancePluginTests.fetch("vercel", engine: .quickJS)), + ("vercel-personal-budget", .vercel, VercelCLIUsageTests.fetch([VercelCLIUsageTests.budget()])), + ] + for (name, provider, snapshot) in snapshots { let model = try UsageMenuCardView.Model.make(.init( provider: provider, metadata: XCTUnwrap(ProviderDefaults.metadata[provider]), @@ -33,15 +37,35 @@ final class APIBalanceScreenshotRenderTests: XCTestCase { hidePersonalInfo: true, usesLiveSubtitle: false, now: snapshot.updatedAt)) - let view = AnyView(UsageMenuCardView(model: model, width: 380) - .environment(\.locale, Locale(identifier: "en_US_POSIX")) - .environment(\.colorScheme, .light) - .environment(\.displayScale, 2) - .background(Color(nsColor: .windowBackgroundColor))) - let hosting = NSHostingView(rootView: view) - hosting.appearance = NSAppearance(named: .aqua) - try XCTUnwrap(MenuLayoutScreenshotRenderTests.pngDataWithWindow(hosting: hosting)) - .write(to: directory.appendingPathComponent("\(provider.rawValue)-synthetic.png")) + for dark in [false, true] { + let view = AnyView(UsageMenuCardView(model: model, width: 380) + .environment(\.locale, Locale(identifier: "en_US_POSIX")) + .environment(\.colorScheme, dark ? .dark : .light) + .environment(\.displayScale, 2) + .background(Color(nsColor: .windowBackgroundColor))) + let hosting = NSHostingView(rootView: view) + hosting.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + try XCTUnwrap(MenuLayoutScreenshotRenderTests.pngDataWithWindow(hosting: hosting)) + .write(to: directory.appendingPathComponent("\(name)-synthetic\(dark ? "-dark" : "").png")) + } } + let fixture = try ProviderSettingsDescriptorTests().makeSettingsFixture(suite: "VercelTeamSettingsScreenshot") + fixture.store.vercelTeams = try VercelTeamSettingsTests.teams() + fixture.settings[providerConfig: .vercel, field: .workspace] = "team_fixture" + let pickers = VercelProviderImplementation() + .settingsPickers(context: fixture.settingsContext(provider: .vercel)) + let settingsView = Form { + ForEach(pickers) { picker in + ProviderSettingsPickerRowView(picker: picker) + } + } + .formStyle(.grouped) + .frame(width: 620, height: 260) + .environment(\.locale, Locale(identifier: "en")) + .preferredColorScheme(.light) + let hosting = NSHostingView(rootView: settingsView) + hosting.appearance = NSAppearance(named: .aqua) + try XCTUnwrap(MenuLayoutScreenshotRenderTests.pngDataWithWindow(hosting: hosting)) + .write(to: directory.appendingPathComponent("vercel-team-settings.png")) } } diff --git a/Tests/CodexBarTests/BurnDownCapabilityTests.swift b/Tests/CodexBarTests/BurnDownCapabilityTests.swift index 9138f48db66..ba7776e0223 100644 --- a/Tests/CodexBarTests/BurnDownCapabilityTests.swift +++ b/Tests/CodexBarTests/BurnDownCapabilityTests.swift @@ -20,7 +20,8 @@ struct BurnDownCapabilityTests { @Test func `provider eligibility preserves supported catalog entries`() { for provider in UsageProvider.allCases { - let expected: [UsageProvider] = [.langdock, .xapi, .tinyapi, .exa].contains(provider) ? [] : [provider] + let expected: [UsageProvider] = [.langdock, .xapi, .tinyapi, .exa, .vercel] + .contains(provider) ? [] : [provider] for minutes in [90, 300, 1440, 10080, 43200] { let snapshot = Self.snapshot(provider: provider, primary: Self.window(minutes: minutes)) #expect(BurnProviderOptions.choices(in: snapshot).map(\.provider) == expected) diff --git a/Tests/CodexBarTests/ConfigValidationTests.swift b/Tests/CodexBarTests/ConfigValidationTests.swift index 7de0b46b52d..858ac7815c4 100644 --- a/Tests/CodexBarTests/ConfigValidationTests.swift +++ b/Tests/CodexBarTests/ConfigValidationTests.swift @@ -379,7 +379,7 @@ struct ConfigValidationTests { let issue = issues.first { $0.provider == .gemini && $0.code == "workspace_unused" } let expectedMessage = "workspaceID is set but only azureopenai, openai, opencode, opencodego, devin, deepgram, " + - "xai, gitkraken, and muse support workspaceID." + "xai, gitkraken, muse, and vercel support workspaceID." #expect(issue?.message == expectedMessage) } diff --git a/Tests/CodexBarTests/Fixtures/plugin-provider-specs.json b/Tests/CodexBarTests/Fixtures/plugin-provider-specs.json index 281eaa73500..f0507f47929 100644 --- a/Tests/CodexBarTests/Fixtures/plugin-provider-specs.json +++ b/Tests/CodexBarTests/Fixtures/plugin-provider-specs.json @@ -45,15 +45,15 @@ "availability": [true, true, true], "cliResolution": {"VERCEL": ["vercel"]}, "enterpriseHost": false, - "environmentAvailability": [[true, false], [true, true], [true, true], [true, true]], + "environmentAvailability": [[true, true], [true, true], [true, true], [true, true]], "fields": ["secure"], "id": "vercel", "projectToken": "", - "projections": {"AI_GATEWAY_API_KEY": "fixture-key"}, - "sourceModes": ["api", "auto"], - "strategies": ["vercel.js"], + "projections": {"AI_GATEWAY_API_KEY": "fixture-key", "VERCEL_TEAM_ID": "fixture-project"}, + "sourceModes": ["api", "auto", "cli"], + "strategies": ["vercel.cli", "vercel.js"], "supportsTokenCost": false, - "workspaceOrder": null + "workspaceOrder": 9 }, { "availability": [true, true, true], diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 024ae0e5d6b..d93ffa0355e 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -771,6 +771,7 @@ struct LocalizationLanguageCatalogTests { "Pro", "SuperGrok OAuth", "Token", + "Vercel CLI", "Zed cookie", "ZeroGPU", "__raycast_session=…; csrf_token=…", diff --git a/Tests/CodexBarTests/PluginProviderSpecTests.swift b/Tests/CodexBarTests/PluginProviderSpecTests.swift index 894d27456e7..3e448272a0e 100644 --- a/Tests/CodexBarTests/PluginProviderSpecTests.swift +++ b/Tests/CodexBarTests/PluginProviderSpecTests.swift @@ -142,13 +142,14 @@ struct PluginProviderSpecTests { let descriptor = ProviderDescriptorRegistry.descriptor(for: provider) let context = ProviderCutoverTestSupport.context(environment: [key: " 'fixture-key' "]) let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) - #expect(strategies.map(\.id) == ["\(provider.rawValue).js"]) - let strategy = try #require(strategies.first) + #expect(strategies.map(\.id) == (provider == .vercel + ? ["vercel.cli", "vercel.js"] : ["\(provider.rawValue).js"])) + let strategy = try #require(strategies.last) #expect(await strategy.isAvailable(context)) #expect(await !strategy.isAvailable(ProviderCutoverTestSupport.context( environment: ["OTHER_API_KEY": "fixture-key"]))) #expect(descriptor.credentials?.resolveToken(environment: context.env)?.token == "fixture-key") - #expect(descriptor.fetchPlan.sourceModes == [.auto, .api]) + #expect(descriptor.fetchPlan.sourceModes == (provider == .vercel ? [.auto, .api, .cli] : [.auto, .api])) } } diff --git a/Tests/CodexBarTests/ProviderCutoverTestSupport.swift b/Tests/CodexBarTests/ProviderCutoverTestSupport.swift index a0af05c9362..cbba38d751b 100644 --- a/Tests/CodexBarTests/ProviderCutoverTestSupport.swift +++ b/Tests/CodexBarTests/ProviderCutoverTestSupport.swift @@ -6,12 +6,13 @@ enum ProviderCutoverTestSupport { environment: [String: String] = [:], settings: ProviderSettingsSnapshot? = nil, runtime: ProviderRuntime = .app, + sourceMode: ProviderSourceMode = .auto, includeCredits: Bool = false, includeOptionalUsage: Bool = true) -> ProviderFetchContext { ProviderFetchContext( runtime: runtime, - sourceMode: .auto, + sourceMode: sourceMode, includeCredits: includeCredits, includeOptionalUsage: includeOptionalUsage, webTimeout: 15, diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 29da4d2e562..d7f8376a140 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -28,6 +28,16 @@ struct ProviderSettingsDescriptorTests { #expect(fields.map(\.kind) == [.secure]) fields[0].binding.wrappedValue = "fixture-key" #expect(fixture.settings.providerConfig(for: provider)?.apiKey == "fixture-key") + if provider == .vercel { + let team = try #require(implementation.settingsPickers(context: fixture.settingsContext(provider: .vercel)) + .first { $0.id == "vercel-team" }) + team.binding.wrappedValue = "team_fixture" + let config = try #require(fixture.settings.providerConfig(for: .vercel)) + #expect(config.workspaceID == "team_fixture") + #expect(VercelProviderDescriptor.descriptor.credentials?.applyConfig(base: [:], config: config)[ + "VERCEL_TEAM_ID", + ] == "team_fixture") + } } @Test diff --git a/Tests/CodexBarTests/VercelCLIUsageTests.swift b/Tests/CodexBarTests/VercelCLIUsageTests.swift new file mode 100644 index 00000000000..e47ebfa40c7 --- /dev/null +++ b/Tests/CodexBarTests/VercelCLIUsageTests.swift @@ -0,0 +1,651 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct VercelCLIUsageTests { + static let now = Date(timeIntervalSince1970: 1_792_339_200) + static let user = #"{"user":{"id":"user_fixture","email":"fixture@example.com"}}"# + static let teamsJSON = #""" + [{"id":"team_other","slug":"other","name":"Other","current":false}, + {"id":"team_fixture","slug":"fixture","name":"Fixture","current":true}] + """# + + static func budget( + user: String = "user_fixture", + spend: String = "12.4", + limit: String = "100", + period: String = "monthly", + source: String? = nil) -> String + { + """ + {"scopeType":"user","scopeId":"\(user)","currentSpend":\(spend),"limitAmount":\(limit), + "refreshPeriod":"\(period)","active":true,"archived":false\(source.map { ",\"source\":\"\($0)\"" } ?? "")} + """ + } + + static func fetch( + _ budgets: [String], + user: String = Self.user, + now: Date = Self.now, + teamID: String? = "team_fixture") async throws -> UsageSnapshot + { + let expectedTeam = teamID ?? "team_fixture" + let fetcher = VercelCLIUsageFetcher(run: { arguments, environment, _ in + #expect(environment["CI"] == "1") + #expect(environment["VERCEL_TELEMETRY_DISABLED"] == "1") + #expect(environment["AI_GATEWAY_API_KEY"] == nil) + #expect(environment["OTHER_SECRET"] == nil) + #expect(environment["CURSOR_TRACE_ID"] == nil) + #expect(environment["VERCEL_TOKEN"] == nil) + if arguments.first == "teams" { + #expect(teamID == nil) + #expect(arguments == ["teams", "list", "--format", "json", "--non-interactive"]) + return "{\"teams\":\(Self.teamsJSON),\"pagination\":{\"next\":null}}" + } + #expect(arguments[3] == "--non-interactive") + #expect(Array(arguments.suffix(2)) == ["--scope", expectedTeam]) + #expect(arguments.prefix(1) == ["api"]) + #expect(arguments[2] == "--raw") + switch arguments[1] { + case "/v2/user": return user + case "/ai-gateway/budgets/list?teamId=\(expectedTeam)&scopeType=user&limit=200": + return "{\"budgets\":[\(budgets.joined(separator: ","))]}" + default: + Issue.record("Unexpected endpoint") + return "{}" + } + }) + var environment = [ + "AI_GATEWAY_API_KEY": "fixture-key", + "OTHER_SECRET": "private-response", "CURSOR_TRACE_ID": "fixture-agent", "VERCEL_TOKEN": "fixture-token", + ] + environment["VERCEL_TEAM_ID"] = teamID + return try await fetcher.fetch(environment: environment, now: now) + } + + @Test + func `blank team uses the CLI current selection and explicit overrides skip discovery`() async throws { + let automatic = try await Self.fetch([Self.budget()], teamID: nil) + #expect(automatic.identity?.accountOrganization == "team_fixture") + #expect(automatic.primary?.usedPercent == 12.4) + let override = try await Self.fetch([Self.budget()], teamID: "team_override") + #expect(override.identity?.accountOrganization == "team_override") + } + + @Test + func `team discovery follows every page without switching the CLI team`() async throws { + let fetcher = VercelCLIUsageFetcher(run: { arguments, _, _ in + #expect(arguments.prefix(2) == ["teams", "list"]) + #expect(!arguments.contains("switch")) + #expect(!arguments.contains("--scope")) + if arguments.contains("--next") { + #expect(arguments.suffix(2) == ["--next", "10"]) + return #""" + {"teams":[{"id":"team_beta","slug":"beta","name":"Beta","current":true}], + "pagination":{"next":null}} + """# + } + return #""" + {"teams":[{"id":"team_alpha","slug":"alpha","name":"Alpha","current":false}], + "pagination":{"next":10}} + """# + }) + let teams = try await fetcher.teams(environment: [:]) + #expect(teams.map(\.id) == ["team_alpha", "team_beta"]) + #expect(teams.first(where: \.current)?.id == "team_beta") + } + + @Test + func `missing current teams are not replaced by the first membership`() async throws { + let fetcher = VercelCLIUsageFetcher(run: { arguments, _, _ in + #expect(arguments.first == "teams") + return "{\"teams\":\(Self.teamsJSON.replacingOccurrences(of: "true", with: "false"))}" + }) + do { + _ = try await fetcher.fetch(environment: [:]) + Issue.record("Expected a team selection request") + } catch let error as ProviderFetchClassifiedError { + #expect(error.kind == .missingCredential) + } + } + + @Test + func `malformed or incomplete team discovery fails instead of returning a partial list`() async throws { + for body in [ + "private-response", "{}", #"{"teams":[],"pagination":{"next":10}}"#, + "{\"teams\":\(Self.teamsJSON.replacingOccurrences(of: "false", with: "true"))}", + ] { + let fetcher = VercelCLIUsageFetcher(run: { _, _, _ in body }) + do { + _ = try await fetcher.teams(environment: [:]) + Issue.record("Expected invalid team response") + } catch let error as ProviderFetchClassifiedError { + #expect(error.kind == .parseFailure) + #expect(!error.message.contains("private-response")) + } + } + } + + @Test + func `CLI budgets belong to the logged in user within the explicitly selected team`() async throws { + let snapshot = try await Self.fetch([Self.budget(user: "other_user", spend: "99"), Self.budget()]) + #expect(snapshot.primary?.usedPercent == 12.4) + #expect(snapshot.primary?.resetDescription == "$12.40 / $100.00") + #expect(snapshot.primary?.windowMinutes == 31 * 24 * 60) + #expect(snapshot.primary?.resetsAt == ISO8601DateFormatter().date(from: "2026-11-01T00:00:00Z")) + #expect(snapshot.identity?.accountID == "user_fixture") + #expect(snapshot.identity?.accountEmail == "fixture@example.com") + #expect(snapshot.identity?.accountOrganization == "team_fixture") + #expect(snapshot.identity?.providerID == .vercel) + #expect(snapshot.identity?.loginMethod == "CLI") + #expect(snapshot.secondary == nil) + #expect(snapshot.providerCost == nil) + #expect(snapshot.details.first?.rows.map(\.value) == ["$12.40", "$100.00", "$87.60", "monthly"]) + } + + @Test + func `custom user budgets override inherited defaults`() async throws { + let inherited = Self.budget(spend: "20", limit: "50", source: "default") + let custom = Self.budget(spend: "30", limit: "200") + #expect(try await Self.fetch([inherited]).primary?.usedPercent == 40) + #expect(try await Self.fetch([inherited, custom]).primary?.usedPercent == 15) + } + + @Test(arguments: [false, true]) + func `budget pages stay below the output cap and select custom budgets across pages`( + inherited: Bool) async throws + { + let padding = String(repeating: "x", count: 3000) + let other = Self.budget(user: "other_user").dropLast() + ",\"name\":\"\(padding)\"}" + let others = Array(repeating: String(other), count: 199) + let cursor = "next&scopeType=team +%/#?" + let firstBudget = inherited ? Self.budget(source: "default") : String(other) + let first = try "{\"budgets\":[\(([firstBudget] + others).joined(separator: ","))]," + + "\"nextCursor\":\(String(data: JSONEncoder().encode(cursor), encoding: .utf8)!)}" + let last = "{\"budgets\":[\((others + [Self.budget(spend: "30", limit: "200")]).joined(separator: ","))]," + + "\"nextCursor\":null}" + let cap = 1024 * 1024 + #expect(first.utf8.count + last.utf8.count > cap) + let fetcher = VercelCLIUsageFetcher(run: { arguments, _, _ in + #expect(arguments.suffix(2) == ["--scope", "team_fixture"]) + if arguments[1] == "/v2/user" { return Self.user } + let query = try #require(URLComponents(string: arguments[1])?.queryItems) + #expect(query.first { $0.name == "teamId" }?.value == "team_fixture") + #expect(query.filter { $0.name == "scopeType" }.map(\.value) == ["user"]) + #expect(query.first { $0.name == "limit" }?.value == "200") + let next = query.first { $0.name == "cursor" }?.value + #expect(next == nil || next == cursor) + let output = next == nil ? first : last + guard output.utf8.count < cap else { throw SubprocessRunnerError.outputTooLarge("fixture") } + return output + }) + let snapshot = try await fetcher.fetch(environment: ["VERCEL_TEAM_ID": "team_fixture"], now: Self.now) + #expect(snapshot.primary?.usedPercent == 15) + } + + @Test + func `incomplete budget pagination never publishes a partial personal budget`() async throws { + for failure in ["repeat", "empty", "malformed", "command", "cancel", "ceiling"] { + let fetcher = VercelCLIUsageFetcher(run: { arguments, _, _ in + if arguments[1] == "/v2/user" { return Self.user } + let cursor = URLComponents(string: arguments[1])?.queryItems?.first { $0.name == "cursor" }?.value + if let cursor { + switch failure { + case "malformed": return "private-response" + case "command": throw SubprocessRunnerError.nonZeroExit(code: 1, stderr: "private-response") + case "cancel": throw CancellationError() + default: break + } + #expect(!cursor.isEmpty) + } + let next = failure == "empty" ? "" : failure == "ceiling" ? String(( + Int(cursor ?? "0") ?? 0) + 1) : "next" + return "{\"budgets\":[\(Self.budget())],\"nextCursor\":\"\(next)\"}" + }) + do { + _ = try await fetcher.fetch(environment: ["VERCEL_TEAM_ID": "team_fixture"]) + Issue.record("Incomplete pages must not return the first matching budget") + } catch is CancellationError { + #expect(failure == "cancel") + } catch let error as ProviderFetchClassifiedError { + #expect(error.kind == (failure == "command" ? .apiFailure : .parseFailure)) + #expect(!error.message.contains("private-response")) + } + } + } + + @Test + func `unlimited users never borrow another member budget or invent zero spend`() async throws { + for budgets in [ + [], + [Self.budget(user: "other_user")], + [Self.budget().replacingOccurrences(of: "\"active\":true", with: "\"active\":false")], + [Self.budget().replacingOccurrences(of: "\"archived\":false", with: "\"archived\":true")], + ] { + let snapshot = try await Self.fetch(budgets) + #expect(snapshot.primary == nil) + #expect(snapshot.details.first?.rows.first?.value == "No personal budget configured") + } + } + + @Test + func `UTC refresh periods handle week month and year boundaries`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-12-31T23:59:00Z")) + for (period, end, minutes) in [ + ("daily", "2027-01-01T00:00:00Z", 1440), + ("weekly", "2027-01-04T00:00:00Z", 10080), + ("monthly", "2027-01-01T00:00:00Z", 44640), + ("none", "", 0), + ] { + let window = try #require(try await Self.fetch([Self.budget(period: period)], now: now).primary) + #expect(window.resetsAt == ISO8601DateFormatter().date(from: end)) + #expect(window.windowMinutes == (minutes == 0 ? nil : minutes)) + } + } + + @Test + func `zero usage and budget overages are preserved`() async throws { + #expect(try await Self.fetch([Self.budget(spend: "0")]).primary?.usedPercent == 0) + let over = try await Self.fetch([Self.budget(spend: "125")]) + #expect(over.primary?.usedPercent == 125) + #expect(over.details.first?.rows[2].value == "$0.00") + } + + @Test + func `malformed and ambiguous budgets fail without echoing account data`() async throws { + for budgets in [ + ["{}"], [Self.budget(spend: "-1")], [Self.budget(limit: "0")], + [Self.budget(limit: "-1")], [Self.budget(spend: "true")], + [Self.budget(period: "private-response")], [Self.budget(), Self.budget()], + ] { + do { + _ = try await Self.fetch(budgets) + Issue.record("Expected invalid budget") + } catch let error as ProviderFetchClassifiedError { + #expect(error.kind == .parseFailure) + #expect(!error.message.contains("private-response")) + } + } + } + + @Test + func `invalid explicit team scopes are rejected before running a command`() async throws { + let fetcher = VercelCLIUsageFetcher(run: { _, _, _ in + Issue.record("Invalid team must not launch the CLI") + return "{}" + }) + for team in ["my-team", "team_fixture&scopeType=team", "team_fixture\nother"] { + let environment = ["VERCEL_TEAM_ID": team] + await #expect(throws: ProviderFetchClassifiedError.self) { + try await fetcher.fetch(environment: environment) + } + } + } + + @Test + func `CLI failures hide stderr and cancellation propagates`() async throws { + let fetcher = VercelCLIUsageFetcher(run: { _, _, _ in + throw SubprocessRunnerError.nonZeroExit(code: 1, stderr: "private-response") + }) + do { + _ = try await fetcher.fetch(environment: ["VERCEL_TEAM_ID": "team_fixture"]) + Issue.record("Expected CLI failure") + } catch let error as ProviderFetchClassifiedError { + #expect(error.kind == .apiFailure) + #expect(!error.message.contains("private-response")) + } + let cancelled = VercelCLIUsageFetcher(run: { _, _, _ in throw CancellationError() }) + await #expect(throws: CancellationError.self) { + try await cancelled.fetch(environment: ["VERCEL_TEAM_ID": "team_fixture"]) + } + } + + @Test + func `personal CLI configuration does not require an API key or warn about its team ID`() { + let config = CodexBarConfig(providers: [ProviderConfig( + id: .vercel, source: .cli, workspaceID: "team_fixture")]) + #expect(CodexBarConfigValidator.validate(config).filter { $0.provider == .vercel }.isEmpty) + } + + @Test + func `invalid users and malformed responses fail closed`() async throws { + for (user, response) in [ + ("{}", "{\"budgets\":[]}"), + (#"{"user":{"id":""}}"#, "{\"budgets\":[]}"), + (Self.user, "private-response"), + ] { + let fetcher = VercelCLIUsageFetcher(run: { arguments, _, _ in + arguments[1] == "/v2/user" ? user : response + }) + do { + _ = try await fetcher.fetch(environment: ["VERCEL_TEAM_ID": "team_fixture"]) + Issue.record("Expected invalid output") + } catch let error as ProviderFetchClassifiedError { + #expect(error.kind == .parseFailure) + #expect(!error.message.contains("private-response")) + } + } + } + + @Test + func `automatic mode discovers CLI teams while explicit sources retain their scope`() async { + let pipeline = VercelProviderDescriptor.descriptor.fetchPlan.pipeline + let api = await pipeline.resolveStrategies(ProviderCutoverTestSupport.context( + environment: ["AI_GATEWAY_API_KEY": "fixture-key"])) + #expect(api.map(\.id) == ["vercel.cli", "vercel.js"]) + let cli = await pipeline.resolveStrategies(ProviderCutoverTestSupport.context( + environment: ["AI_GATEWAY_API_KEY": "fixture-key", "VERCEL_TEAM_ID": "team_fixture"])) + #expect(cli.map(\.id) == ["vercel.cli"]) + let explicitAPI = await pipeline.resolveStrategies(ProviderCutoverTestSupport.context( + environment: ["VERCEL_TEAM_ID": "team_fixture"], sourceMode: .api)) + #expect(explicitAPI.map(\.id) == ["vercel.js"]) + let explicitCLI = await pipeline.resolveStrategies(ProviderCutoverTestSupport.context(sourceMode: .cli)) + #expect(explicitCLI.map(\.id) == ["vercel.cli"]) + #expect(!VercelCLIFetchStrategy().shouldFallback( + on: CancellationError(), context: ProviderCutoverTestSupport.context())) + let failure = ProviderFetchClassifiedError(kind: .apiFailure, message: "fixture") + #expect(VercelCLIFetchStrategy().shouldFallback( + on: failure, context: ProviderCutoverTestSupport.context())) + #expect(!VercelCLIFetchStrategy().shouldFallback( + on: failure, context: ProviderCutoverTestSupport.context(environment: ["VERCEL_TEAM_ID": "team_fixture"]))) + #expect(!VercelCLIFetchStrategy().shouldFallback( + on: failure, context: ProviderCutoverTestSupport.context(sourceMode: .cli))) + } +} + +struct VercelCLIDeadlineTests { + @Test(arguments: [false, true]) + func `team discovery user lookup and budget pages share one deadline`(late: Bool) async throws { + let clock = Clock() + let fetcher = VercelCLIUsageFetcher(run: { arguments, _, timeout in + let call = clock.record(timeout: timeout, elapsed: .seconds(clock.timeouts.count == 4 ? (late ? 3 : 1) : 7)) + if arguments.first == "teams" { + return "{\"teams\":\(call == 1 ? "[]" : VercelCLIUsageTests.teamsJSON)," + + "\"pagination\":{\"next\":\(call == 1 ? "10" : "null")}}" + } + if arguments[1] == "/v2/user" { return VercelCLIUsageTests.user } + #expect(arguments.suffix(2) == ["--scope", "team_fixture"]) + return call == 4 + ? "{\"budgets\":[\(VercelCLIUsageTests.budget())],\"nextCursor\":\"next\"}" + : #"{"budgets":[],"nextCursor":null}"# + }, clockNow: { clock.now }) + do { + let snapshot = try await fetcher.fetch(environment: [:], now: VercelCLIUsageTests.now) + #expect(!late) + #expect(snapshot.primary?.usedPercent == 12.4) + } catch let error as ProviderFetchClassifiedError { + #expect(late) + #expect(error.kind == .networkFailure) + #expect(error.message.contains("timed out")) + } + #expect(clock.timeouts == [15, 15, 15, 9, 2]) + } + + @Test + func `standalone team refresh rejects late pages without returning a partial inventory`() async throws { + let clock = Clock() + let fetcher = VercelCLIUsageFetcher(run: { _, _, timeout in + let call = clock.record(timeout: timeout, elapsed: .seconds(14)) + return """ + {"teams":[{"id":"team_\(call)","slug":"fixture","name":"Fixture","current":false}], + "pagination":{"next":\(call)}} + """ + }, clockNow: { clock.now }) + do { + _ = try await fetcher.teams(environment: [:]) + Issue.record("Expected the shared team deadline to reject incomplete discovery") + } catch let error as ProviderFetchClassifiedError { + #expect(error.kind == .networkFailure) + } + #expect(clock.timeouts == [15, 15, 2]) + } + + @Test + func `cancellation before lookup starts no commands`() async throws { + let fetcher = VercelCLIUsageFetcher(run: { _, _, _ in + Issue.record("Cancelled lookups must not start a command") + return "{}" + }) + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try await fetcher.fetch(environment: [:]) + } + await #expect(throws: CancellationError.self) { try await task.value } + } + + @Test + func `typed process failures remain actionable without exposing command data`() async throws { + for (failure, kind) in [ + (SubprocessRunnerError.timedOut("private-response"), ProviderFetchClassifiedError.Kind.networkFailure), + (.outputTooLarge("private-response"), .parseFailure), + (.binaryNotFound("private-response"), .missingCredential), + ] { + let fetcher = VercelCLIUsageFetcher(run: { _, _, _ in throw failure }) + do { + _ = try await fetcher.fetch(environment: ["VERCEL_TEAM_ID": "team_fixture"]) + Issue.record("Expected a classified process failure") + } catch let error as ProviderFetchClassifiedError { + #expect(error.kind == kind) + #expect(!error.message.contains("private-response")) + } + } + } + + private final class Clock: @unchecked Sendable { + private let lock = NSLock() + private var instant = ContinuousClock.now + private var recordedTimeouts: [TimeInterval] = [] + + var now: ContinuousClock.Instant { + self.lock.withLock { self.instant } + } + + var timeouts: [TimeInterval] { + self.lock.withLock { self.recordedTimeouts } + } + + func record(timeout: TimeInterval, elapsed: Duration) -> Int { + self.lock.withLock { + self.recordedTimeouts.append(timeout) + self.instant = self.instant.advanced(by: elapsed) + return self.recordedTimeouts.count + } + } + } +} + +struct VercelFallbackTests { + @Test( + arguments: [ProviderSourceMode.auto, .api, .cli], + [(false, false), (false, true), (true, false), (true, true)]) + func `only automatic unscoped failures fall back with a team credit diagnostic`( + source: ProviderSourceMode, selection: (explicitTeam: Bool, cancellation: Bool)) async throws + { + let (explicitTeam, cancellation) = selection + var environment = ["AI_GATEWAY_API_KEY": "fixture-key"] + if explicitTeam { environment["VERCEL_TEAM_ID"] = "team_fixture" } + let context = ProviderCutoverTestSupport.context(environment: environment, sourceMode: source) + let fallback = source == .auto && !explicitTeam && !cancellation + let api = VercelAPIFetchStrategy(base: VercelProviderDescriptor.spec.makeStrategy( + transport: ProviderHTTPTransportHandler { request in + #expect(source == .api || fallback) + #expect(request.url?.absoluteString == "https://ai-gateway.vercel.sh/v1/credits") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-key") + let response = try #require(HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])) + return (Data(#"{"balance":"95.50","total_used":"4.50"}"#.utf8), response) + })) + let fetcher = VercelCLIUsageFetcher(run: { _, _, _ in + if cancellation { throw CancellationError() } + throw SubprocessRunnerError.timedOut("private-response") + }) + let pipeline = ProviderFetchPipeline(resolveStrategies: { context in + let strategies = await VercelProviderDescriptor.descriptor.fetchPlan.pipeline.resolveStrategies(context) + return strategies.map { strategy -> any ProviderFetchStrategy in + #expect(strategy.id == "vercel.cli" || strategy is VercelAPIFetchStrategy) + return strategy.id == "vercel.cli" ? VercelCLIFetchStrategy(fetcher: fetcher) : api + } + }) + let outcome = await pipeline.fetch(context: context, provider: .vercel) + if source == .api || fallback { + let result = try outcome.result.get() + #expect(result.strategyID == "vercel.js") + #expect(result.sourceLabel == "api") + #expect(result.usage.details.first?.title == "Team credits") + #expect(result.usage.identity?.accountEmail == nil) + #expect((result.diagnostic != nil) == fallback) + if fallback { + #expect(result.diagnostic?.contains("team credits") == true) + #expect(result.diagnostic?.contains("private-response") == false) + } + #expect(outcome.attempts.map(\.strategyID) == (fallback ? ["vercel.cli", "vercel.js"] : ["vercel.js"])) + } else { + #expect(outcome.attempts.map(\.strategyID) == ["vercel.cli"]) + switch outcome.result { + case .success: Issue.record("Explicit scope and cancellation must not fall back") + case let .failure(error): + #expect((error is CancellationError) == cancellation) + } + } + } +} + +@MainActor +struct VercelTeamSettingsTests { + static func teams() throws -> [VercelCLITeam] { + try JSONDecoder().decode([VercelCLITeam].self, from: Data(VercelCLIUsageTests.teamsJSON.utf8)) + } + + @Test + func `teams load from CLI and selection stays in CodexBar configuration`() async throws { + let teams = try Self.teams() + let fixture = try ProviderSettingsDescriptorTests().makeSettingsFixture(suite: "VercelTeamSettingsTests") + let implementation = VercelProviderImplementation(loadTeams: { _ in teams }) + let context = fixture.settingsContext(provider: .vercel) + let initial = try #require(implementation.settingsPickers(context: context).first { $0.id == "vercel-team" }) + #expect(initial.binding.wrappedValue.isEmpty) + #expect(initial.options.map(\.id) == [""]) + let refresh = try #require(initial.trailingActions.first) + await refresh.perform() + let picker = try #require(implementation.settingsPickers(context: context).first { $0.id == "vercel-team" }) + #expect(picker.options.map(\.id) == ["", "team_other", "team_fixture"]) + picker.binding.wrappedValue = "team_other" + #expect(fixture.settings.providerConfig(for: .vercel)?.workspaceID == "team_other") + #expect(fixture.store.vercelTeams.first(where: \.current)?.id == "team_fixture") + picker.binding.wrappedValue = "" + #expect(fixture.settings.providerConfig(for: .vercel)?.workspaceID == nil) + #expect(implementation.settingsFields(context: context).map(\.kind) == [.secure]) + } + + @Test + func `switching teams retires the previous usage publication and clears its snapshot`() async throws { + let fixture = try ProviderSettingsDescriptorTests().makeSettingsFixture(suite: "VercelTeamSwitchTests") + let context = fixture.settingsContext(provider: .vercel) + let implementation = VercelProviderImplementation() + let picker = try #require(implementation.settingsPickers(context: context).first { $0.id == "vercel-team" }) + fixture.store.snapshots[.vercel] = try await VercelCLIUsageTests.fetch([VercelCLIUsageTests.budget()]) + let publication = fixture.store.providerPublicationRevision(for: .vercel) + let revision = fixture.settings.providerConfigRevision(for: .vercel) + picker.binding.wrappedValue = "" + #expect(fixture.store.snapshots[.vercel] != nil) + #expect(fixture.store.providerPublicationRevisionIsCurrent(publication, for: .vercel)) + picker.binding.wrappedValue = "team_other" + #expect(fixture.store.snapshots[.vercel] == nil) + #expect(fixture.settings.providerConfigRevision(for: .vercel) > revision) + #expect(!fixture.store.providerPublicationRevisionIsCurrent(publication, for: .vercel)) + } + + @Test(arguments: [false, true]) + func `team discovery finishing after a selection change cannot replace the new inventory`( + failure: Bool) async throws + { + let teams = try Self.teams() + let fixture = try ProviderSettingsDescriptorTests().makeSettingsFixture(suite: "VercelTeamDiscoveryRace") + let started = AsyncStream.makeStream() + let finish = AsyncStream.makeStream() + let implementation = VercelProviderImplementation(loadTeams: { _ in + started.continuation.yield(()) + for await _ in finish.stream { + if failure { throw ProviderFetchClassifiedError(kind: .apiFailure, message: "fixture") } + return teams + } + throw CancellationError() + }) + let context = fixture.settingsContext(provider: .vercel) + let picker = try #require(implementation.settingsPickers(context: context).first { $0.id == "vercel-team" }) + let action = try #require(picker.trailingActions.first) + let request = Task { await action.perform() } + defer { + started.continuation.finish() + finish.continuation.finish() + request.cancel() + } + var iterator = started.stream.makeAsyncIterator() + _ = await iterator.next() + picker.binding.wrappedValue = "team_other" + fixture.store.vercelTeams = [teams[0]] + finish.continuation.yield(()) + await request.value + #expect(fixture.store.vercelTeams == [teams[0]]) + #expect(!fixture.store.vercelTeamsRefreshing) + #expect(context.statusText("vercel-teams") == nil) + } + + @Test + func `API source is selectable without changing the stored team`() throws { + let fixture = try ProviderSettingsDescriptorTests().makeSettingsFixture(suite: "VercelSourceSettingsTests") + fixture.settings[providerConfig: .vercel, field: .workspace] = "team_fixture" + let context = fixture.settingsContext(provider: .vercel) + let implementation = VercelProviderImplementation() + let pickers = implementation.settingsPickers(context: context) + let source = try #require(pickers.first { $0.id == "vercel-source" }) + #expect(source.options.map(\.id) == ["auto", "cli", "api"]) + source.binding.wrappedValue = "api" + #expect(implementation.sourceMode(context: .init(provider: .vercel, settings: fixture.settings)) == .api) + #expect(fixture.settings.providerConfig(for: .vercel)?.workspaceID == "team_fixture") + #expect(pickers.first { $0.id == "vercel-team" }?.isVisible?() == false) + } + + @Test + func `personal budgets do not expose or append provider wide plan history across teams`() async throws { + let fixture = try ProviderSettingsDescriptorTests().makeSettingsFixture(suite: "VercelHistoryIsolation") + let store = fixture.store + fixture.settings.historicalTrackingEnabled = true + let legacy = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: VercelCLIUsageTests.now, usedPercent: 90), + ]) + let buckets = PlanUtilizationHistoryBuckets( + preferredAccountKey: "legacy-email", + unscoped: [legacy], + accounts: ["legacy-email": [legacy]]) + store._cancelPlanUtilizationHistoryLoadForTesting() + store.planUtilizationHistory[.vercel] = buckets + let recorder = WeeklyLimitResetEventRecorder(provider: .vercel, accountLabel: "fixture@example.com") + defer { recorder.invalidate() } + for (index, team) in ["team_fixture", "team_other", "team_fixture"].enumerated() { + let snapshot = try await VercelCLIUsageTests.fetch( + [VercelCLIUsageTests.budget(spend: index == 0 ? "90" : "0", period: "weekly")], + now: VercelCLIUsageTests.now.addingTimeInterval(Double(index) * 7 * 86400), + teamID: team) + store.snapshots[.vercel] = snapshot + await store.recordPlanUtilizationHistorySample( + provider: .vercel, + snapshot: snapshot, + now: snapshot.updatedAt) + #expect(store.planUtilizationHistory(for: .vercel).isEmpty) + #expect(store.planUtilizationHistorySelection(for: .vercel, snapshotOverride: snapshot).histories.isEmpty) + #expect(store.planUtilizationHistory[.vercel] == buckets) + } + #expect(!VercelProviderDescriptor.descriptor.history.supportsPlanUtilization) + #expect(!VercelProviderDescriptor.spec.burnDownWidgetSelectable) + #expect(store.weeklyLimitResetDetectorStates.isEmpty) + #expect(recorder.events.isEmpty) + } +} diff --git a/docs/plugin-conversion-matrix.md b/docs/plugin-conversion-matrix.md index 7278bc0600f..4f92f23b8e7 100644 --- a/docs/plugin-conversion-matrix.md +++ b/docs/plugin-conversion-matrix.md @@ -143,7 +143,7 @@ Abacus, Muse, LongCat, Replicate, and TypeSafe unchanged. | hyper | `cut-over` | QuickJS + JavaScriptCore | Signed-in dashboard GET with supported-browser/manual session preference and API-key fallback to a fixed credits GET; native HC balance, no invented quotas or resets. | | devpass | `cut-over` | QuickJS + JavaScriptCore | Documented bearer GET for billing-cycle and premium weekly credits plus separate all-time key spend; API-only. | | atlascloud | `cut-over` | QuickJS + JavaScriptCore | Public billing API bearer GET for the account-wide available USD balance; no invented quota or Coding Plan allowance. | -| vercel | `cut-over` | QuickJS + JavaScriptCore | Public credits API bearer GET for team USD balance and lifetime spend; no CLI discovery or metered reporting. | +| vercel | `cut-over` | QuickJS + JavaScriptCore | Public credits API bearer GET for team USD balance and lifetime spend; native CLI strategy discovers the current team and reads personal budgets through an existing Vercel login. App team selection never switches the CLI globally. No metered reporting. | | llmman | `cut-over` | QuickJS + JavaScriptCore | Configured loopback/private-network daemon origin with an optional bearer key; `/llmman/node` memory and model summaries, best-effort version. | | xkiro | `cut-over` | QuickJS + JavaScriptCore | Documented, unmetered bearer GET for daily free-token counters; separate from paid spend and wallet balances. | | aixy | `cut-over` | QuickJS + JavaScriptCore | Plugin-first key-scoped usage and overlapping budgets via bearer GET; both engines preserve reservations and zero-versus-unavailable spend. | diff --git a/docs/providers.md b/docs/providers.md index b00261134ee..e931b0abca3 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -113,7 +113,7 @@ complete when the available scan window covers fewer days. | [v0](v0.md) | API key and optional project scope from settings/env → bundled plugin for Platform API billing and rate limits (`api`). | | [DevPass](devpass.md) | Regular LLM Gateway API key → bundled plugin for plan credits, premium weekly usage, and all-time key spend (`api`). | | [Atlas Cloud](atlascloud.md) | Standard API key → account-wide available USD balance (`api`). | -| [Vercel AI Gateway](vercel.md) | AI Gateway API key → team-wide USD balance and lifetime spend (`api`). | +| [Vercel AI Gateway](vercel.md) | Existing Vercel CLI login → personal budget for the current or selected team (`cli`); AI Gateway API key → team USD balance and lifetime spend (`api`). | | Windsurf | Web session bundle from browser localStorage (`web`) → local SQLite cache (`local`). | | Ollama | API key verifies Cloud API access (`api`); browser cookies expose current credit balance/details or legacy quota windows (`web`). | | [llmman](llmman.md) | Local `llmman serve` node report, optional API key → bundled plugin for loaded-model memory and store summary (`api`). | diff --git a/docs/screenshots/vercel-personal-budget-dark.png b/docs/screenshots/vercel-personal-budget-dark.png new file mode 100644 index 00000000000..7546695c458 Binary files /dev/null and b/docs/screenshots/vercel-personal-budget-dark.png differ diff --git a/docs/screenshots/vercel-personal-budget.png b/docs/screenshots/vercel-personal-budget.png new file mode 100644 index 00000000000..167d448df39 Binary files /dev/null and b/docs/screenshots/vercel-personal-budget.png differ diff --git a/docs/screenshots/vercel-team-settings.png b/docs/screenshots/vercel-team-settings.png new file mode 100644 index 00000000000..5c360462fe0 Binary files /dev/null and b/docs/screenshots/vercel-team-settings.png differ diff --git a/docs/vercel.md b/docs/vercel.md index 028817989a7..d6dd93397f2 100644 --- a/docs/vercel.md +++ b/docs/vercel.md @@ -1,5 +1,5 @@ --- -summary: "Vercel AI Gateway credit balance and lifetime spend through its public API." +summary: "Vercel AI Gateway team credits through its API, or personal budgets through an existing CLI login." read_when: - Setting up or modifying the Vercel AI Gateway provider --- @@ -10,16 +10,83 @@ Enable **Vercel AI Gateway** in Settings → Providers and enter an AI Gateway A `AI_GATEWAY_API_KEY` for the CLI. The key is saved in CodexBar's local config file when entered in Settings. Use `codexbar usage --provider vercel --source api` to fetch it. +## Personal budget with Vercel CLI + +No additional access token or team ID needs to be copied into CodexBar. Install Vercel CLI and sign in +with `vercel login`. Automatic mode discovers the CLI's current team and reads your personal budget. +The Gateway API key is not required for CLI budgets. + +To use another team, open Settings → Providers → Vercel AI Gateway, click **Refresh** beside **Team**, +and choose a team by name. The choice is saved only in CodexBar; it does not switch the CLI's global +team. Choose **Auto** to follow the CLI's current team again. **Usage source** also lets you select +**Vercel CLI** explicitly, or **API key** to keep the original team-credit view. + +For the CodexBar CLI, use `codexbar usage --provider vercel --source cli`; no team ID is needed when +the Vercel CLI already has a current team. `VERCEL_TEAM_ID` remains an optional explicit override. +Explicit `--source api` always reads the Gateway key's team credits, even with a team override. + +CodexBar runs read-only `vercel api` commands with JSON output: + +- `vercel teams list --format json` discovers memberships and the current CLI team. Pagination is + followed for up to 20 pages; incomplete, repeated, or ambiguous results fail instead of selecting + the first team. This command is also used by the settings team's **Refresh** action. +- `/v2/user` identifies the signed-in CLI user. +- `/ai-gateway/budgets/list?teamId=...&scopeType=user` supplies that team's personal budgets. + +The budget request uses `limit=200` and follows `nextCursor` with the same team scope on every page. +This keeps large teams' responses within the CLI's 1 MiB output limit. Up to 100 pages are read; +repeated cursors, incomplete traversal, and failed pages fail the refresh without publishing a partial budget. +Team discovery, user identification, and all budget pages share a 30-second monotonic deadline. +Each command gets at most 15 seconds, capped by the remaining time. The settings team's **Refresh** +action has its own 30-second deadline. Responses arriving after the deadline are rejected. + +The personal budget is matched by user ID, never by the first list entry or a display name. A custom +budget takes precedence over an inherited default. The card shows spend, limit, remaining personal +allowance, and the refresh cadence. Daily, weekly, and monthly budgets reset at UTC boundaries; +weekly budgets reset on Monday. A cumulative (`none`) budget has no reset date. + +Vercel does not participate in generic plan-utilization history or burn-down widgets. Personal budgets +belong to a user within a team; a provider-wide history bucket cannot represent that ownership safely. +Existing history is preserved on disk, but is neither displayed nor extended by this provider. + +When no active personal budget exists, CodexBar says so without inventing a usage percentage or spend. +In automatic mode without an explicit team override, a failed CLI fetch can fall back to the configured +Gateway key's team-credit view. Explicit CLI mode and explicit team choices surface failures instead. +When fallback succeeds, a diagnostic explains that team credits are being shown because the CLI +personal budget was unavailable. Timeout and oversized-response errors are distinguished without +exposing raw CLI output. +The CLI source describes the signed-in user in the selected team; it does not infer the owner of a Gateway key. +It works with CLI versions that support `vercel api --raw --non-interactive`, even without the newer +`vercel ai-gateway` subcommand. Every team role except Contributor can read budgets. + +Only keys with **User** spend attribution count toward this personal budget. A key created by a person +can still have **Team** attribution. Team and key budgets, and the team's credit balance, can stop requests +before the personal allowance runs out. BYOK spend does not count toward these budgets. + +Synthetic personal-budget examples in light and dark appearances: + +![Personal budget in light appearance](screenshots/vercel-personal-budget.png) +![Personal budget in dark appearance](screenshots/vercel-personal-budget-dark.png) + +Synthetic source and team settings: + +![Source and team settings](screenshots/vercel-team-settings.png) + +## Team credits with an API key + The bundled JavaScript plugin reads `GET https://ai-gateway.vercel.sh/v1/credits` with bearer authentication. It displays the key's **team-wide remaining USD balance** and **lifetime USD spend**, from the documented `balance` and `total_used` decimal strings. Zero and negative balances are preserved. Missing or malformed values fail the refresh; they do not become zero. The shared HTTP host bounds requests and retries transient failures. -This endpoint has no spending limit or reset date, so the provider shows amounts without a percentage bar, -reset countdown, or billing-period estimate. It does not read Vercel CLI credentials, switch teams, inspect -Keychain, or query the metered Custom Reporting API. Budgets, per-key quotas, and detailed usage reporting -remain outside this balance-only integration. Select the appropriate team's API key to change scope. +This endpoint has no spending limit or reset date, so this source shows amounts without a percentage bar, +reset countdown, or billing-period estimate. The API-key source does not use Vercel CLI credentials. +Select the appropriate team's API key to change its scope. Neither source imports browser cookies, +reads Keychain directly, changes budgets, makes inference requests, or uses the metered Custom Reporting API. Sources: [REST credit API](https://vercel.com/docs/ai-gateway/sdks-and-apis/rest-api#check-credit-balance), [usage semantics](https://vercel.com/docs/ai-gateway/observability-and-spend/usage), and [reporting pricing](https://vercel.com/docs/ai-gateway/observability-and-spend/custom-reporting#pricing). +Personal budgets: [budget semantics](https://vercel.com/docs/ai-gateway/observability-and-spend/budgets), +[list budgets API](https://vercel.com/docs/rest-api/ai-gateway/list-ai-gateway-budgets), and +[spend attribution](https://vercel.com/docs/ai-gateway/authentication-and-byok/api-keys#spend-attribution).