From 22829a97045aec04a7d6bbcdd951f22592592c3f Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Sun, 18 Oct 2026 00:33:06 -0700 Subject: [PATCH 1/6] Add Vercel CLI personal budget usage Co-Authored-By: Claude Code Claude-Session: https://claude.ai/code/session_01GYiF6FrNLHsyqZXK1MeD8t --- .../Resources/ar.lproj/Localizable.strings | 1 + .../Resources/ca.lproj/Localizable.strings | 1 + .../Resources/de.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/es.lproj/Localizable.strings | 1 + .../Resources/fa.lproj/Localizable.strings | 1 + .../Resources/fr.lproj/Localizable.strings | 1 + .../Resources/gl.lproj/Localizable.strings | 1 + .../Resources/id.lproj/Localizable.strings | 1 + .../Resources/it.lproj/Localizable.strings | 1 + .../Resources/ja.lproj/Localizable.strings | 1 + .../Resources/ko.lproj/Localizable.strings | 1 + .../Resources/nl.lproj/Localizable.strings | 1 + .../Resources/pl.lproj/Localizable.strings | 1 + .../Resources/pt-BR.lproj/Localizable.strings | 1 + .../Resources/ru.lproj/Localizable.strings | 1 + .../Resources/sv.lproj/Localizable.strings | 1 + .../Resources/th.lproj/Localizable.strings | 1 + .../Resources/tr.lproj/Localizable.strings | 1 + .../Resources/uk.lproj/Localizable.strings | 1 + .../Resources/vi.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 1 + .../Vercel/VercelCLIUsageFetcher.swift | 192 ++++++++++++++++ .../Vercel/VercelProviderDescriptor.swift | 29 ++- .../APIBalanceScreenshotRenderTests.swift | 28 ++- .../CodexBarTests/ConfigValidationTests.swift | 2 +- .../Fixtures/plugin-provider-specs.json | 8 +- .../PluginProviderSpecTests.swift | 2 +- .../ProviderCutoverTestSupport.swift | 3 +- .../ProviderSettingsDescriptorTests.swift | 13 +- Tests/CodexBarTests/VercelCLIUsageTests.swift | 209 ++++++++++++++++++ docs/plugin-conversion-matrix.md | 2 +- docs/providers.md | 2 +- .../vercel-personal-budget-dark.png | Bin 0 -> 41901 bytes docs/screenshots/vercel-personal-budget.png | Bin 0 -> 41264 bytes docs/vercel.md | 50 ++++- 37 files changed, 533 insertions(+), 30 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Vercel/VercelCLIUsageFetcher.swift create mode 100644 Tests/CodexBarTests/VercelCLIUsageTests.swift create mode 100644 docs/screenshots/vercel-personal-budget-dark.png create mode 100644 docs/screenshots/vercel-personal-budget.png diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 6924d32cdb1..9f4e2f7e983 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -2205,6 +2205,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "اختياري. أدخل معرّف الفريق لعرض ميزانيتك الشخصية باستخدام 'vercel login'. لا يلزم رمز إضافي."; "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..5fb0f7a9382 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -2205,6 +2205,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Opcional. Introdueix un ID d'equip per mostrar el teu pressupost personal amb 'vercel login'. No cal cap testimoni addicional."; "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..988c27d1b8a 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -2203,6 +2203,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Optional. Gib eine Team-ID ein, um dein persönliches Budget über 'vercel login' anzuzeigen. Kein zusätzliches Token erforderlich."; "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..bd94455221e 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -2205,6 +2205,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required."; "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..453723fdf26 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -2199,6 +2199,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Opcional. Introduce un ID de equipo para mostrar tu presupuesto personal usando 'vercel login'. No se necesita ningún token adicional."; "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..9fd3103d45f 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -2205,6 +2205,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "اختیاری. شناسهٔ تیم را وارد کنید تا بودجهٔ شخصی شما با استفاده از 'vercel login' نمایش داده شود. نیازی به توکن اضافی نیست."; "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..fe6805aead2 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -2200,6 +2200,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Facultatif. Saisissez un ID d'équipe pour afficher votre budget personnel via 'vercel login'. Aucun jeton supplémentaire requis."; "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..04f96e9767f 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -2200,6 +2200,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Opcional. Introduce un ID de equipo para mostrar o teu orzamento persoal con 'vercel login'. Non se precisa ningún token adicional."; "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..1ea080d32e2 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -2205,6 +2205,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Opsional. Masukkan ID tim untuk menampilkan anggaran pribadi melalui 'vercel login'. Tidak perlu token tambahan."; "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..c561519accd 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -2205,6 +2205,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Facoltativo. Inserisci un ID del team per mostrare il tuo budget personale tramite 'vercel login'. Non serve un token aggiuntivo."; "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..0d5c699ce8f 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -2201,6 +2201,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "任意。チームIDを入力すると、'vercel login' のログイン情報で個人予算を表示します。追加のトークンは不要です。"; "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..f655882e882 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -2169,6 +2169,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "선택 사항입니다. 팀 ID를 입력하면 'vercel login'의 로그인 정보로 개인 예산을 표시합니다. 추가 토큰은 필요하지 않습니다."; "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..47809468384 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -2200,6 +2200,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Optioneel. Voer een team-ID in om je persoonlijke budget via 'vercel login' te tonen. Geen extra token nodig."; "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..a4e225895bc 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -2204,6 +2204,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Opcjonalnie. Wpisz ID zespołu, aby wyświetlić budżet osobisty przez 'vercel login'. Dodatkowy token nie jest wymagany."; "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..d3c8427c0ef 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -2201,6 +2201,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Opcional. Informe um ID de equipe para exibir seu orçamento pessoal usando 'vercel login'. Nenhum token adicional é necessário."; "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..8ca86cfea21 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -2202,6 +2202,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Необязательно. Укажите ID команды, чтобы показать личный бюджет через 'vercel login'. Дополнительный токен не нужен."; "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..badd07cdcd1 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -2200,6 +2200,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Valfritt. Ange ett team-ID för att visa din personliga budget via 'vercel login'. Ingen extra token krävs."; "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..5d006b76b09 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -2205,6 +2205,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "ไม่บังคับ ระบุ ID ทีมเพื่อแสดงงบประมาณส่วนบุคคลผ่าน 'vercel login' โดยไม่ต้องใช้โทเค็นเพิ่มเติม"; "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..aa93a274f3c 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -2204,6 +2204,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "İsteğe bağlı. Kişisel bütçenizi 'vercel login' ile göstermek için takım kimliğini girin. Ek token gerekmez."; "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..c5ccf0ea713 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -2201,6 +2201,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Необов’язково. Вкажіть ID команди, щоб показати особистий бюджет через 'vercel login'. Додатковий токен не потрібен."; "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..a4595d66dfb 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -2202,6 +2202,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Tùy chọn. Nhập ID đội để hiển thị ngân sách cá nhân qua 'vercel login'. Không cần token bổ sung."; "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..6db1a8ee436 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -2196,6 +2196,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "可选。输入团队 ID,即可使用 'vercel login' 的登录信息显示个人预算。无需额外令牌。"; "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..997a113d564 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -2183,6 +2183,7 @@ "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"; +"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "選填。輸入團隊 ID,即可使用 'vercel login' 的登入資訊顯示個人預算。無需額外權杖。"; "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/CodexBarCore/Providers/Vercel/VercelCLIUsageFetcher.swift b/Sources/CodexBarCore/Providers/Vercel/VercelCLIUsageFetcher.swift new file mode 100644 index 00000000000..18a72b2210a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Vercel/VercelCLIUsageFetcher.swift @@ -0,0 +1,192 @@ +import Foundation + +struct VercelCLIUsageFetcher: Sendable { + var run: @Sendable ([String], [String: String]) async throws -> String = Self.runCommand + + func fetch(environment: [String: String], now: Date = Date()) async throws -> UsageSnapshot { + guard let teamID = SettingsValue.cleaned(environment["VERCEL_TEAM_ID"]), + teamID.range(of: #"^team_[A-Za-z0-9]+$"#, options: .regularExpression) != nil + else { + throw ProviderFetchClassifiedError( + kind: .missingCredential, message: "Set a Vercel team ID (team_...) to read your personal budget.") + } + let user: UserResponse = try await self.read("/v2/user", teamID: teamID, environment: environment) + guard !user.user.id.isEmpty else { throw Self.invalidOutput } + let response: BudgetResponse = try await self.read( + "/ai-gateway/budgets/list?teamId=\(teamID)&scopeType=user", teamID: teamID, environment: environment) + guard response.nextCursor == nil else { throw Self.invalidOutput } + let matches = response.budgets.filter { + $0.scopeType == "user" && $0.scopeId == user.user.id && $0.active && !$0.archived + } + 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) + } + + private func read( + _ endpoint: String, teamID: String, environment: [String: String]) async throws -> Value + { + let output: String + do { + // CLI의 기본 팀이 URL의 teamId를 덮어쓰므로 호출 범위도 같은 팀으로 고정한다. + output = try await self.run( + ["api", endpoint, "--raw", "--non-interactive", "--scope", teamID], + Self.commandEnvironment(environment)) + } catch is CancellationError { + throw CancellationError() + } catch SubprocessRunnerError.binaryNotFound { + throw ProviderFetchClassifiedError( + kind: .missingCredential, + message: "Install the Vercel CLI and run 'vercel login' to read personal budgets.") + } catch { + throw ProviderFetchClassifiedError( + kind: .apiFailure, + message: "Vercel CLI could not read personal budgets. " + + "Check 'vercel login', team access, and CLI updates.") + } + try Task.checkCancellation() + guard let decoded = try? JSONDecoder().decode(Value.self, from: Data(output.utf8)) else { + throw Self.invalidOutput + } + return decoded + } + + private static let invalidOutput = ProviderFetchClassifiedError( + kind: .parseFailure, message: "Vercel CLI returned an unrecognized personal budget response.") + + private static func runCommand(_ arguments: [String], environment: [String: String]) 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: 15, + 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 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 + + func isAvailable(_: ProviderFetchContext) async -> Bool { true } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + try await self.makeResult( + usage: VercelCLIUsageFetcher().fetch(environment: context.env), sourceLabel: "cli") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { false } +} diff --git a/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift index 3e430bcbf07..ae9862ad322 100644 --- a/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift @@ -1,23 +1,46 @@ 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, .api: + [Self.spec.makeStrategy()] + 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.", 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: "Optional. Set a team ID to show your personal budget using 'vercel login'. " + + "No extra token required.", + placeholder: nil))) } diff --git a/Tests/CodexBarTests/APIBalanceScreenshotRenderTests.swift b/Tests/CodexBarTests/APIBalanceScreenshotRenderTests.swift index 764059afc76..ebca867f429 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,17 @@ 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")) + } } } } 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..6512e7437ae 100644 --- a/Tests/CodexBarTests/Fixtures/plugin-provider-specs.json +++ b/Tests/CodexBarTests/Fixtures/plugin-provider-specs.json @@ -46,14 +46,14 @@ "cliResolution": {"VERCEL": ["vercel"]}, "enterpriseHost": false, "environmentAvailability": [[true, false], [true, true], [true, true], [true, true]], - "fields": ["secure"], + "fields": ["secure", "plain"], "id": "vercel", "projectToken": "", - "projections": {"AI_GATEWAY_API_KEY": "fixture-key"}, - "sourceModes": ["api", "auto"], + "projections": {"AI_GATEWAY_API_KEY": "fixture-key", "VERCEL_TEAM_ID": "fixture-project"}, + "sourceModes": ["api", "auto", "cli"], "strategies": ["vercel.js"], "supportsTokenCost": false, - "workspaceOrder": null + "workspaceOrder": 9 }, { "availability": [true, true, true], diff --git a/Tests/CodexBarTests/PluginProviderSpecTests.swift b/Tests/CodexBarTests/PluginProviderSpecTests.swift index 894d27456e7..4e2370e22ac 100644 --- a/Tests/CodexBarTests/PluginProviderSpecTests.swift +++ b/Tests/CodexBarTests/PluginProviderSpecTests.swift @@ -148,7 +148,7 @@ struct PluginProviderSpecTests { #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..f44692f87e7 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -24,10 +24,19 @@ struct ProviderSettingsDescriptorTests { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-\(provider.rawValue)") let implementation = try #require(ProviderCatalog.implementation(for: provider)) let fields = implementation.settingsFields(context: fixture.settingsContext(provider: provider)) - #expect(fields.map(\.id) == ["\(provider.rawValue)-api-key"]) - #expect(fields.map(\.kind) == [.secure]) + #expect(fields.map(\.id) == (provider == .vercel + ? ["vercel-api-key", "vercel-team-id"] : ["atlascloud-api-key"])) + #expect(fields.map(\.kind) == (provider == .vercel ? [.secure, .plain] : [.secure])) fields[0].binding.wrappedValue = "fixture-key" #expect(fixture.settings.providerConfig(for: provider)?.apiKey == "fixture-key") + if provider == .vercel { + fields[1].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..ae589a43ed5 --- /dev/null +++ b/Tests/CodexBarTests/VercelCLIUsageTests.swift @@ -0,0 +1,209 @@ +import Foundation +import Testing +@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 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) async throws -> UsageSnapshot + { + let fetcher = VercelCLIUsageFetcher(run: { arguments, environment in + #expect(arguments[3] == "--non-interactive") + #expect(Array(arguments.suffix(2)) == ["--scope", "team_fixture"]) + #expect(arguments.prefix(1) == ["api"]) + #expect(arguments[2] == "--raw") + #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) + switch arguments[1] { + case "/v2/user": return user + case "/ai-gateway/budgets/list?teamId=team_fixture&scopeType=user": + return "{\"budgets\":[\(budgets.joined(separator: ","))]}" + default: + Issue.record("Unexpected endpoint") + return "{}" + } + }) + return try await fetcher.fetch(environment: [ + "VERCEL_TEAM_ID": "team_fixture", "AI_GATEWAY_API_KEY": "fixture-key", + "OTHER_SECRET": "private-response", "CURSOR_TRACE_ID": "fixture-agent", "VERCEL_TOKEN": "fixture-token", + ], now: now) + } + + @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 + 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 `team scope is required before running any 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 incomplete responses fail closed`() async throws { + for (user, response) in [ + ("{}", "{\"budgets\":[]}"), + (#"{"user":{"id":""}}"#, "{\"budgets\":[]}"), + (Self.user, "private-response"), + (Self.user, #"{"budgets":[],"nextCursor":"more"}"#), + ] { + 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 `personal budgets are opt in and API key balances retain their source`() 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.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())) + } +} diff --git a/docs/plugin-conversion-matrix.md b/docs/plugin-conversion-matrix.md index 7278bc0600f..74b46934b15 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; opt-in native CLI strategy reads personal budgets through an existing Vercel login. 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..faaf211ecaf 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) | AI Gateway API key → team USD balance and lifetime spend (`api`); existing Vercel CLI login + team ID → personal budget (`cli`). | | 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 0000000000000000000000000000000000000000..7546695c45833046efee3893dcd274d6357c9f68 GIT binary patch literal 41901 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 literal 0 HcmV?d00001 diff --git a/docs/screenshots/vercel-personal-budget.png b/docs/screenshots/vercel-personal-budget.png new file mode 100644 index 0000000000000000000000000000000000000000..167d448df395ab45abb08650b9556d421a217c14 GIT binary patch literal 41264 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 literal 0 HcmV?d00001 diff --git a/docs/vercel.md b/docs/vercel.md index 028817989a7..7b75946ccda 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,56 @@ 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 needs to be copied into CodexBar. Install Vercel CLI and sign in with +`vercel login`, then enter the target **Team ID** (`team_...`) in the provider's settings. In automatic +mode, setting a team ID selects the personal-budget source instead of the API-key balance source. +Leave it blank to keep the existing API-key behavior. The Gateway API key is not required for CLI budgets. + +For the CodexBar CLI, set `VERCEL_TEAM_ID` and use `codexbar usage --provider vercel --source cli`. +Explicit `--source api` always reads the Gateway key's team credits, even when a team ID is configured. + +CodexBar runs read-only `vercel api` commands with JSON output: + +- `/v2/user` identifies the signed-in CLI user. +- `/ai-gateway/budgets/list?teamId=...&scopeType=user` supplies that team's personal budgets. + +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. + +When no active personal budget exists, CodexBar says so without inventing a usage percentage or spend. +CLI login, permission, and parsing failures remain visible instead of falling back to unrelated team credits. +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) + +## 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). From 5a16ba920c7b0c90b72a9c1d60badfd1bf27aac4 Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Sun, 18 Oct 2026 01:35:30 -0700 Subject: [PATCH 2/6] Discover and select Vercel teams Reuse the CLI current team and offer an app-local team picker. Invalidate prior usage on source or team changes and verify the unpaged budget-list contract with a large synthetic response. Co-Authored-By: Claude Code Claude-Session: https://claude.ai/code/session_01GYiF6FrNLHsyqZXK1MeD8t --- .../ProviderImplementationManifest.swift | 2 +- .../Vercel/VercelProviderImplementation.swift | 110 +++++++++ .../Resources/ar.lproj/Localizable.strings | 3 +- .../Resources/ca.lproj/Localizable.strings | 3 +- .../Resources/de.lproj/Localizable.strings | 3 +- .../Resources/en.lproj/Localizable.strings | 3 +- .../Resources/es.lproj/Localizable.strings | 3 +- .../Resources/fa.lproj/Localizable.strings | 3 +- .../Resources/fr.lproj/Localizable.strings | 3 +- .../Resources/gl.lproj/Localizable.strings | 3 +- .../Resources/id.lproj/Localizable.strings | 3 +- .../Resources/it.lproj/Localizable.strings | 3 +- .../Resources/ja.lproj/Localizable.strings | 3 +- .../Resources/ko.lproj/Localizable.strings | 3 +- .../Resources/nl.lproj/Localizable.strings | 3 +- .../Resources/pl.lproj/Localizable.strings | 3 +- .../Resources/pt-BR.lproj/Localizable.strings | 3 +- .../Resources/ru.lproj/Localizable.strings | 3 +- .../Resources/sv.lproj/Localizable.strings | 3 +- .../Resources/th.lproj/Localizable.strings | 3 +- .../Resources/tr.lproj/Localizable.strings | 3 +- .../Resources/uk.lproj/Localizable.strings | 3 +- .../Resources/vi.lproj/Localizable.strings | 3 +- .../zh-Hans.lproj/Localizable.strings | 3 +- .../zh-Hant.lproj/Localizable.strings | 3 +- Sources/CodexBar/UsageStore.swift | 2 + .../Vercel/VercelCLIUsageFetcher.swift | 89 +++++-- .../Vercel/VercelProviderDescriptor.swift | 7 +- .../APIBalanceScreenshotRenderTests.swift | 18 ++ .../Fixtures/plugin-provider-specs.json | 6 +- .../PluginProviderSpecTests.swift | 5 +- .../ProviderSettingsDescriptorTests.swift | 9 +- Tests/CodexBarTests/VercelCLIUsageTests.swift | 220 ++++++++++++++++-- docs/plugin-conversion-matrix.md | 2 +- docs/providers.md | 2 +- docs/screenshots/vercel-team-settings.png | Bin 0 -> 37455 bytes docs/vercel.md | 30 ++- 37 files changed, 498 insertions(+), 73 deletions(-) create mode 100644 Sources/CodexBar/Providers/Vercel/VercelProviderImplementation.swift create mode 100644 docs/screenshots/vercel-team-settings.png 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 9f4e2f7e983..f69c0ab7200 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -2205,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "اختياري. أدخل معرّف الفريق لعرض ميزانيتك الشخصية باستخدام 'vercel login'. لا يلزم رمز إضافي."; +"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 5fb0f7a9382..7f6d9fa2f03 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -2205,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Opcional. Introdueix un ID d'equip per mostrar el teu pressupost personal amb 'vercel login'. No cal cap testimoni addicional."; +"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 988c27d1b8a..e36c3dc4129 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -2203,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Optional. Gib eine Team-ID ein, um dein persönliches Budget über 'vercel login' anzuzeigen. Kein zusätzliches Token erforderlich."; +"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 bd94455221e..200b165d241 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -2205,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required."; +"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 453723fdf26..728133b1d13 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -2199,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Opcional. Introduce un ID de equipo para mostrar tu presupuesto personal usando 'vercel login'. No se necesita ningún token adicional."; +"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 9fd3103d45f..ca54f695c3c 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -2205,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "اختیاری. شناسهٔ تیم را وارد کنید تا بودجهٔ شخصی شما با استفاده از 'vercel login' نمایش داده شود. نیازی به توکن اضافی نیست."; +"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 fe6805aead2..6c8cd9f0117 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -2200,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Facultatif. Saisissez un ID d'équipe pour afficher votre budget personnel via 'vercel login'. Aucun jeton supplémentaire requis."; +"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 04f96e9767f..0410beac549 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -2200,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Opcional. Introduce un ID de equipo para mostrar o teu orzamento persoal con 'vercel login'. Non se precisa ningún token adicional."; +"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 1ea080d32e2..579ee0e677e 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -2205,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Opsional. Masukkan ID tim untuk menampilkan anggaran pribadi melalui 'vercel login'. Tidak perlu token tambahan."; +"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 c561519accd..5d5be530c4b 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -2205,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Facoltativo. Inserisci un ID del team per mostrare il tuo budget personale tramite 'vercel login'. Non serve un token aggiuntivo."; +"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 0d5c699ce8f..e17fc23f30d 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -2201,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "任意。チームIDを入力すると、'vercel login' のログイン情報で個人予算を表示します。追加のトークンは不要です。"; +"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 f655882e882..e3572360314 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -2169,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "선택 사항입니다. 팀 ID를 입력하면 'vercel login'의 로그인 정보로 개인 예산을 표시합니다. 추가 토큰은 필요하지 않습니다."; +"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 47809468384..44fd1627957 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -2200,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Optioneel. Voer een team-ID in om je persoonlijke budget via 'vercel login' te tonen. Geen extra token nodig."; +"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 a4e225895bc..4a266eb3baa 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -2204,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Opcjonalnie. Wpisz ID zespołu, aby wyświetlić budżet osobisty przez 'vercel login'. Dodatkowy token nie jest wymagany."; +"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 d3c8427c0ef..c21bb845398 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -2201,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Opcional. Informe um ID de equipe para exibir seu orçamento pessoal usando 'vercel login'. Nenhum token adicional é necessário."; +"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 8ca86cfea21..a23f8c5912f 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -2202,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Необязательно. Укажите ID команды, чтобы показать личный бюджет через 'vercel login'. Дополнительный токен не нужен."; +"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 badd07cdcd1..217a35752db 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -2200,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Valfritt. Ange ett team-ID för att visa din personliga budget via 'vercel login'. Ingen extra token krävs."; +"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 5d006b76b09..31e63d1d86f 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -2205,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "ไม่บังคับ ระบุ ID ทีมเพื่อแสดงงบประมาณส่วนบุคคลผ่าน 'vercel login' โดยไม่ต้องใช้โทเค็นเพิ่มเติม"; +"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 aa93a274f3c..40ff93dc5a9 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -2204,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "İsteğe bağlı. Kişisel bütçenizi 'vercel login' ile göstermek için takım kimliğini girin. Ek token gerekmez."; +"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 c5ccf0ea713..ce0d5490612 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -2201,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Необов’язково. Вкажіть ID команди, щоб показати особистий бюджет через 'vercel login'. Додатковий токен не потрібен."; +"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 a4595d66dfb..5ee204c47f3 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -2202,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "Tùy chọn. Nhập ID đội để hiển thị ngân sách cá nhân qua 'vercel login'. Không cần token bổ sung."; +"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 6db1a8ee436..1516ade0900 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -2196,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "可选。输入团队 ID,即可使用 'vercel login' 的登录信息显示个人预算。无需额外令牌。"; +"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 997a113d564..550758c90ce 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -2183,7 +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"; -"Optional. Set a team ID to show your personal budget using 'vercel login'. No extra token required." = "選填。輸入團隊 ID,即可使用 'vercel login' 的登入資訊顯示個人預算。無需額外權杖。"; +"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 index 18a72b2210a..94c8b80f8b2 100644 --- a/Sources/CodexBarCore/Providers/Vercel/VercelCLIUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Vercel/VercelCLIUsageFetcher.swift @@ -1,20 +1,31 @@ import Foundation -struct VercelCLIUsageFetcher: Sendable { +public struct VercelCLIUsageFetcher: Sendable { var run: @Sendable ([String], [String: String]) async throws -> String = Self.runCommand + public init() {} + + init(run: @escaping @Sendable ([String], [String: String]) async throws -> String) { + self.run = run + } + func fetch(environment: [String: String], now: Date = Date()) async throws -> UsageSnapshot { - guard let teamID = SettingsValue.cleaned(environment["VERCEL_TEAM_ID"]), - teamID.range(of: #"^team_[A-Za-z0-9]+$"#, options: .regularExpression) != nil - else { + 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).first(where: \.current) { + teamID = current.id + } else { throw ProviderFetchClassifiedError( - kind: .missingCredential, message: "Set a Vercel team ID (team_...) to read your personal budget.") + 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) guard !user.user.id.isEmpty else { throw Self.invalidOutput } + // limit과 cursor를 생략하면 API가 해당 scope의 전체 예산을 반환한다. let response: BudgetResponse = try await self.read( "/ai-gateway/budgets/list?teamId=\(teamID)&scopeType=user", teamID: teamID, environment: environment) - guard response.nextCursor == nil else { throw Self.invalidOutput } let matches = response.budgets.filter { $0.scopeType == "user" && $0.scopeId == user.user.id && $0.active && !$0.archived } @@ -71,15 +82,50 @@ struct VercelCLIUsageFetcher: Sendable { dataConfidence: .exact) } + public func teams(environment: [String: String]) async throws -> [VercelCLITeam] { + var teams: [VercelCLITeam] = [] + var ids: Set = [] + var cursor: Int? + var cursors: Set = [] + // ponytail: 최대 20페이지까지 읽는다. 더 큰 계정은 전체 조회 시간 제한으로 전환한다. + 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) + 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]) async throws -> Value + { + // CLI의 기본 팀이 URL의 teamId를 덮어쓰므로 호출 범위도 같은 팀으로 고정한다. + try await self.command( + ["api", endpoint, "--raw", "--non-interactive", "--scope", teamID], environment: environment) + } + + private func command( + _ arguments: [String], environment: [String: String]) async throws -> Value { let output: String do { - // CLI의 기본 팀이 URL의 teamId를 덮어쓰므로 호출 범위도 같은 팀으로 고정한다. - output = try await self.run( - ["api", endpoint, "--raw", "--non-interactive", "--scope", teamID], - Self.commandEnvironment(environment)) + output = try await self.run(arguments, Self.commandEnvironment(environment)) } catch is CancellationError { throw CancellationError() } catch SubprocessRunnerError.binaryNotFound { @@ -100,7 +146,7 @@ struct VercelCLIUsageFetcher: Sendable { } private static let invalidOutput = ProviderFetchClassifiedError( - kind: .parseFailure, message: "Vercel CLI returned an unrecognized personal budget response.") + kind: .parseFailure, message: "Vercel CLI returned an unrecognized usage or team response.") private static func runCommand(_ arguments: [String], environment: [String: String]) async throws -> String { let path = PathBuilder.effectivePATH( @@ -149,7 +195,14 @@ struct VercelCLIUsageFetcher: Sendable { 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 { @@ -188,5 +241,15 @@ struct VercelCLIFetchStrategy: ProviderFetchStrategy { usage: VercelCLIUsageFetcher().fetch(environment: context.env), sourceLabel: "cli") } - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { false } + 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 ae9862ad322..a4a8da6a72a 100644 --- a/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift @@ -9,7 +9,9 @@ public enum VercelProviderDescriptor { [VercelCLIFetchStrategy()] case .auto where SettingsValue.cleaned(context.env["VERCEL_TEAM_ID"]) != nil: [VercelCLIFetchStrategy()] - case .auto, .api: + case .auto: + [VercelCLIFetchStrategy(), Self.spec.makeStrategy()] + case .api: [Self.spec.makeStrategy()] case .web, .oauth: [] @@ -40,7 +42,6 @@ public enum VercelProviderDescriptor { field: .init( id: "vercel-team-id", title: "Team ID", - subtitle: "Optional. Set a team ID to show your personal budget using 'vercel login'. " - + "No extra token required.", + subtitle: "Uses the CLI's current team by default. Refresh to choose another team.", placeholder: nil))) } diff --git a/Tests/CodexBarTests/APIBalanceScreenshotRenderTests.swift b/Tests/CodexBarTests/APIBalanceScreenshotRenderTests.swift index ebca867f429..7d3c552b8f5 100644 --- a/Tests/CodexBarTests/APIBalanceScreenshotRenderTests.swift +++ b/Tests/CodexBarTests/APIBalanceScreenshotRenderTests.swift @@ -49,5 +49,23 @@ final class APIBalanceScreenshotRenderTests: XCTestCase { .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/Fixtures/plugin-provider-specs.json b/Tests/CodexBarTests/Fixtures/plugin-provider-specs.json index 6512e7437ae..f0507f47929 100644 --- a/Tests/CodexBarTests/Fixtures/plugin-provider-specs.json +++ b/Tests/CodexBarTests/Fixtures/plugin-provider-specs.json @@ -45,13 +45,13 @@ "availability": [true, true, true], "cliResolution": {"VERCEL": ["vercel"]}, "enterpriseHost": false, - "environmentAvailability": [[true, false], [true, true], [true, true], [true, true]], - "fields": ["secure", "plain"], + "environmentAvailability": [[true, true], [true, true], [true, true], [true, true]], + "fields": ["secure"], "id": "vercel", "projectToken": "", "projections": {"AI_GATEWAY_API_KEY": "fixture-key", "VERCEL_TEAM_ID": "fixture-project"}, "sourceModes": ["api", "auto", "cli"], - "strategies": ["vercel.js"], + "strategies": ["vercel.cli", "vercel.js"], "supportsTokenCost": false, "workspaceOrder": 9 }, diff --git a/Tests/CodexBarTests/PluginProviderSpecTests.swift b/Tests/CodexBarTests/PluginProviderSpecTests.swift index 4e2370e22ac..3e448272a0e 100644 --- a/Tests/CodexBarTests/PluginProviderSpecTests.swift +++ b/Tests/CodexBarTests/PluginProviderSpecTests.swift @@ -142,8 +142,9 @@ 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"]))) diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index f44692f87e7..d7f8376a140 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -24,13 +24,14 @@ struct ProviderSettingsDescriptorTests { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-\(provider.rawValue)") let implementation = try #require(ProviderCatalog.implementation(for: provider)) let fields = implementation.settingsFields(context: fixture.settingsContext(provider: provider)) - #expect(fields.map(\.id) == (provider == .vercel - ? ["vercel-api-key", "vercel-team-id"] : ["atlascloud-api-key"])) - #expect(fields.map(\.kind) == (provider == .vercel ? [.secure, .plain] : [.secure])) + #expect(fields.map(\.id) == ["\(provider.rawValue)-api-key"]) + #expect(fields.map(\.kind) == [.secure]) fields[0].binding.wrappedValue = "fixture-key" #expect(fixture.settings.providerConfig(for: provider)?.apiKey == "fixture-key") if provider == .vercel { - fields[1].binding.wrappedValue = "team_fixture" + 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)[ diff --git a/Tests/CodexBarTests/VercelCLIUsageTests.swift b/Tests/CodexBarTests/VercelCLIUsageTests.swift index ae589a43ed5..3dde548a463 100644 --- a/Tests/CodexBarTests/VercelCLIUsageTests.swift +++ b/Tests/CodexBarTests/VercelCLIUsageTests.swift @@ -1,10 +1,15 @@ import Foundation 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", @@ -22,32 +27,104 @@ struct VercelCLIUsageTests { static func fetch( _ budgets: [String], user: String = Self.user, - now: Date = Self.now) async throws -> UsageSnapshot + now: Date = Self.now, + teamID: String? = "team_fixture") async throws -> UsageSnapshot { + let expectedTeam = teamID ?? "team_fixture" let fetcher = VercelCLIUsageFetcher(run: { arguments, environment in - #expect(arguments[3] == "--non-interactive") - #expect(Array(arguments.suffix(2)) == ["--scope", "team_fixture"]) - #expect(arguments.prefix(1) == ["api"]) - #expect(arguments[2] == "--raw") #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=team_fixture&scopeType=user": + case "/ai-gateway/budgets/list?teamId=\(expectedTeam)&scopeType=user": return "{\"budgets\":[\(budgets.joined(separator: ","))]}" default: Issue.record("Unexpected endpoint") return "{}" } }) - return try await fetcher.fetch(environment: [ - "VERCEL_TEAM_ID": "team_fixture", "AI_GATEWAY_API_KEY": "fixture-key", + var environment = [ + "AI_GATEWAY_API_KEY": "fixture-key", "OTHER_SECRET": "private-response", "CURSOR_TRACE_ID": "fixture-agent", "VERCEL_TOKEN": "fixture-token", - ], now: now) + ] + 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 @@ -75,6 +152,16 @@ struct VercelCLIUsageTests { #expect(try await Self.fetch([inherited, custom]).primary?.usedPercent == 15) } + @Test + func `unpaged requests select the user custom budget at the end of a large flat list`() async throws { + let others = (0..<250).map { Self.budget(user: "other_user_\($0)", spend: "99") } + let inherited = Self.budget(spend: "20", limit: "50", source: "default") + let custom = Self.budget(spend: "30", limit: "200") + let snapshot = try await Self.fetch([inherited] + others + [custom]) + #expect(snapshot.primary?.usedPercent == 15) + #expect(snapshot.identity?.accountID == "user_fixture") + } + @Test func `unlimited users never borrow another member budget or invent zero spend`() async throws { for budgets in [ @@ -130,12 +217,12 @@ struct VercelCLIUsageTests { } @Test - func `team scope is required before running any command`() async throws { + 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"] { + 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) @@ -169,12 +256,11 @@ struct VercelCLIUsageTests { } @Test - func `invalid users and incomplete responses fail closed`() async throws { + func `invalid users and malformed responses fail closed`() async throws { for (user, response) in [ ("{}", "{\"budgets\":[]}"), (#"{"user":{"id":""}}"#, "{\"budgets\":[]}"), (Self.user, "private-response"), - (Self.user, #"{"budgets":[],"nextCursor":"more"}"#), ] { let fetcher = VercelCLIUsageFetcher(run: { arguments, _ in arguments[1] == "/v2/user" ? user : response @@ -190,11 +276,11 @@ struct VercelCLIUsageTests { } @Test - func `personal budgets are opt in and API key balances retain their source`() async { + 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.js"]) + #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"]) @@ -205,5 +291,109 @@ struct VercelCLIUsageTests { #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))) + } +} + +@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) } } diff --git a/docs/plugin-conversion-matrix.md b/docs/plugin-conversion-matrix.md index 74b46934b15..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; opt-in native CLI strategy reads personal budgets through an existing Vercel login. No 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 faaf211ecaf..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 USD balance and lifetime spend (`api`); existing Vercel CLI login + team ID → personal budget (`cli`). | +| [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-team-settings.png b/docs/screenshots/vercel-team-settings.png new file mode 100644 index 0000000000000000000000000000000000000000..5c360462fe04946a0a9b4137793641689fec49d4 GIT binary patch literal 37455 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 G|NjAPlP@X& literal 0 HcmV?d00001 diff --git a/docs/vercel.md b/docs/vercel.md index 7b75946ccda..0c6fbc8e564 100644 --- a/docs/vercel.md +++ b/docs/vercel.md @@ -12,26 +12,38 @@ Use `codexbar usage --provider vercel --source api` to fetch it. ## Personal budget with Vercel CLI -No additional access token needs to be copied into CodexBar. Install Vercel CLI and sign in with -`vercel login`, then enter the target **Team ID** (`team_...`) in the provider's settings. In automatic -mode, setting a team ID selects the personal-budget source instead of the API-key balance source. -Leave it blank to keep the existing API-key behavior. The Gateway API key is not required for CLI budgets. +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. -For the CodexBar CLI, set `VERCEL_TEAM_ID` and use `codexbar usage --provider vercel --source cli`. -Explicit `--source api` always reads the Gateway key's team credits, even when a team ID is configured. +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 omits both `limit` and `cursor`: the list API returns every budget in that mode. +Pagination is opt-in for this endpoint, unlike the CLI's team list. + 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. When no active personal budget exists, CodexBar says so without inventing a usage percentage or spend. -CLI login, permission, and parsing failures remain visible instead of falling back to unrelated team credits. +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. 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. @@ -45,6 +57,10 @@ 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. From a54906d6494f335b397c47b17bdcfd288fd27831 Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Sun, 18 Oct 2026 01:54:27 -0700 Subject: [PATCH 3/6] Bound Vercel budget pages and disable shared plan history Read cursor pages within the existing subprocess output ceiling and fail incomplete traversals without publishing partial budgets. Use the existing unavailable history capability to keep team-scoped budgets out of provider-wide histories and reset detectors. Co-Authored-By: Claude Code Claude-Session: https://claude.ai/code/session_01GYiF6FrNLHsyqZXK1MeD8t --- .../Vercel/VercelCLIUsageFetcher.swift | 25 ++++- .../Vercel/VercelProviderDescriptor.swift | 2 + Tests/CodexBarTests/VercelCLIUsageTests.swift | 105 ++++++++++++++++-- docs/vercel.md | 9 +- 4 files changed, 126 insertions(+), 15 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Vercel/VercelCLIUsageFetcher.swift b/Sources/CodexBarCore/Providers/Vercel/VercelCLIUsageFetcher.swift index 94c8b80f8b2..86f672d5ee6 100644 --- a/Sources/CodexBarCore/Providers/Vercel/VercelCLIUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Vercel/VercelCLIUsageFetcher.swift @@ -23,11 +23,25 @@ public struct VercelCLIUsageFetcher: Sendable { } let user: UserResponse = try await self.read("/v2/user", teamID: teamID, environment: environment) guard !user.user.id.isEmpty else { throw Self.invalidOutput } - // limit과 cursor를 생략하면 API가 해당 scope의 전체 예산을 반환한다. - let response: BudgetResponse = try await self.read( - "/ai-gateway/budgets/list?teamId=\(teamID)&scopeType=user", teamID: teamID, environment: environment) - let matches = response.budgets.filter { - $0.scopeType == "user" && $0.scopeId == user.user.id && $0.active && !$0.archived + var matches: [Budget] = [] + var cursor: String? + var cursors: Set = [] + // ponytail: 100페이지를 상한으로 둔다. 더 큰 계정은 전체 조회 시간 제한으로 전환한다. + 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) + 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 @@ -195,6 +209,7 @@ public struct VercelCLIUsageFetcher: Sendable { private struct BudgetResponse: Decodable { let budgets: [Budget] + let nextCursor: String? } private struct TeamsResponse: Decodable { diff --git a/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift index a4a8da6a72a..bb4d6c3f473 100644 --- a/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift @@ -29,6 +29,8 @@ public enum VercelProviderDescriptor { 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), diff --git a/Tests/CodexBarTests/VercelCLIUsageTests.swift b/Tests/CodexBarTests/VercelCLIUsageTests.swift index 3dde548a463..efeade0cc92 100644 --- a/Tests/CodexBarTests/VercelCLIUsageTests.swift +++ b/Tests/CodexBarTests/VercelCLIUsageTests.swift @@ -49,7 +49,7 @@ struct VercelCLIUsageTests { #expect(arguments[2] == "--raw") switch arguments[1] { case "/v2/user": return user - case "/ai-gateway/budgets/list?teamId=\(expectedTeam)&scopeType=user": + case "/ai-gateway/budgets/list?teamId=\(expectedTeam)&scopeType=user&limit=200": return "{\"budgets\":[\(budgets.joined(separator: ","))]}" default: Issue.record("Unexpected endpoint") @@ -152,14 +152,67 @@ struct VercelCLIUsageTests { #expect(try await Self.fetch([inherited, custom]).primary?.usedPercent == 15) } - @Test - func `unpaged requests select the user custom budget at the end of a large flat list`() async throws { - let others = (0..<250).map { Self.budget(user: "other_user_\($0)", spend: "99") } - let inherited = Self.budget(spend: "20", limit: "50", source: "default") - let custom = Self.budget(spend: "30", limit: "200") - let snapshot = try await Self.fetch([inherited] + others + [custom]) + @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) - #expect(snapshot.identity?.accountID == "user_fixture") + } + + @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 @@ -396,4 +449,40 @@ struct VercelTeamSettingsTests { #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/vercel.md b/docs/vercel.md index 0c6fbc8e564..a74b3fa53a8 100644 --- a/docs/vercel.md +++ b/docs/vercel.md @@ -33,14 +33,19 @@ CodexBar runs read-only `vercel api` commands with JSON output: - `/v2/user` identifies the signed-in CLI user. - `/ai-gateway/budgets/list?teamId=...&scopeType=user` supplies that team's personal budgets. -The budget request omits both `limit` and `cursor`: the list API returns every budget in that mode. -Pagination is opt-in for this endpoint, unlike the CLI's team list. +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. 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. From 024ea9ddc4ca22f2d9a1d16991032df41d458367 Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Sun, 18 Oct 2026 02:26:21 -0700 Subject: [PATCH 4/6] Align Vercel burn-down capability expectations Keep the catalog test aligned with disabled team-scoped plan history and verify both widget modes across five window durations. Co-Authored-By: Claude Code Claude-Session: https://claude.ai/code/session_01GYiF6FrNLHsyqZXK1MeD8t --- Tests/CodexBarTests/BurnDownCapabilityTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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) From d8a8cd9c775da0d75d1eef6facdaf1a12274f427 Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Sun, 18 Oct 2026 03:03:23 -0700 Subject: [PATCH 5/6] Recognize Vercel CLI as an untranslated brand Co-Authored-By: Claude Code Claude-Session: https://claude.ai/code/session_01GYiF6FrNLHsyqZXK1MeD8t --- Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift | 1 + 1 file changed, 1 insertion(+) 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=…", From 4549f94258db83b87d0b6f9f5a40397ee0665c1c Mon Sep 17 00:00:00 2001 From: sf-jin-ku Date: Sun, 18 Oct 2026 12:41:33 -0700 Subject: [PATCH 6/6] Bound Vercel CLI refreshes and explain fallback Co-Authored-By: Claude Code Claude-Session: https://claude.ai/code/session_01GYiF6FrNLHsyqZXK1MeD8t --- .../Vercel/VercelCLIUsageFetcher.swift | 96 ++++++--- .../Vercel/VercelProviderDescriptor.swift | 31 ++- Tests/CodexBarTests/VercelCLIUsageTests.swift | 183 +++++++++++++++++- docs/vercel.md | 6 + 4 files changed, 278 insertions(+), 38 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Vercel/VercelCLIUsageFetcher.swift b/Sources/CodexBarCore/Providers/Vercel/VercelCLIUsageFetcher.swift index 86f672d5ee6..f356b26f679 100644 --- a/Sources/CodexBarCore/Providers/Vercel/VercelCLIUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Vercel/VercelCLIUsageFetcher.swift @@ -1,32 +1,40 @@ import Foundation public struct VercelCLIUsageFetcher: Sendable { - var run: @Sendable ([String], [String: String]) async throws -> String = Self.runCommand + 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]) async throws -> String) { + 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).first(where: \.current) { + } 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) + 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 = [] - // ponytail: 100페이지를 상한으로 둔다. 더 큰 계정은 전체 조회 시간 제한으로 전환한다. for pageIndex in 0..<100 { var endpoint = "/ai-gateway/budgets/list?teamId=\(teamID)&scopeType=user&limit=200" if let cursor { @@ -35,7 +43,8 @@ public struct VercelCLIUsageFetcher: Sendable { } endpoint += "&cursor=\(encoded)" } - let page: BudgetResponse = try await self.read(endpoint, teamID: teamID, environment: environment) + 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 } @@ -97,15 +106,20 @@ public struct VercelCLIUsageFetcher: Sendable { } 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 = [] - // ponytail: 최대 20페이지까지 읽는다. 더 큰 계정은 전체 조회 시간 제한으로 전환한다. 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) + 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 @@ -127,42 +141,71 @@ public struct VercelCLIUsageFetcher: Sendable { } private func read( - _ endpoint: String, teamID: String, environment: [String: String]) async throws -> Value + _ 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) + ["api", endpoint, "--raw", "--non-interactive", "--scope", teamID], + environment: environment, + deadline: deadline) } private func command( - _ arguments: [String], environment: [String: String]) async throws -> Value + _ 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)) - } catch is CancellationError { - throw CancellationError() - } catch SubprocessRunnerError.binaryNotFound { - throw ProviderFetchClassifiedError( - kind: .missingCredential, - message: "Install the Vercel CLI and run 'vercel login' to read personal budgets.") + output = try await self.run(arguments, Self.commandEnvironment(environment), timeout) } catch { - throw ProviderFetchClassifiedError( - kind: .apiFailure, - message: "Vercel CLI could not read personal budgets. " - + "Check 'vercel login', team access, and CLI updates.") + 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 Task.checkCancellation() + _ = 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]) async throws -> String { + 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( @@ -176,7 +219,7 @@ public struct VercelCLIUsageFetcher: Sendable { binary: binary, arguments: arguments, environment: childEnvironment, - timeout: 15, + timeout: timeout, maxOutputBytes: 1024 * 1024, standardInput: FileHandle.nullDevice, currentDirectoryURL: URL(fileURLWithPath: environment["HOME"] ?? NSHomeDirectory(), isDirectory: true), @@ -248,12 +291,13 @@ public struct VercelCLIUsageFetcher: Sendable { 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: VercelCLIUsageFetcher().fetch(environment: context.env), sourceLabel: "cli") + usage: self.fetcher.fetch(environment: context.env), sourceLabel: "cli") } func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { diff --git a/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift index bb4d6c3f473..11aeb309959 100644 --- a/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift @@ -10,9 +10,9 @@ public enum VercelProviderDescriptor { case .auto where SettingsValue.cleaned(context.env["VERCEL_TEAM_ID"]) != nil: [VercelCLIFetchStrategy()] case .auto: - [VercelCLIFetchStrategy(), Self.spec.makeStrategy()] + [VercelCLIFetchStrategy(), VercelAPIFetchStrategy()] case .api: - [Self.spec.makeStrategy()] + [VercelAPIFetchStrategy()] case .web, .oauth: [] } @@ -47,3 +47,30 @@ public enum VercelProviderDescriptor { 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/VercelCLIUsageTests.swift b/Tests/CodexBarTests/VercelCLIUsageTests.swift index efeade0cc92..e47ebfa40c7 100644 --- a/Tests/CodexBarTests/VercelCLIUsageTests.swift +++ b/Tests/CodexBarTests/VercelCLIUsageTests.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif import Testing @testable import CodexBar @testable import CodexBarCore @@ -31,7 +34,7 @@ struct VercelCLIUsageTests { teamID: String? = "team_fixture") async throws -> UsageSnapshot { let expectedTeam = teamID ?? "team_fixture" - let fetcher = VercelCLIUsageFetcher(run: { arguments, environment in + let fetcher = VercelCLIUsageFetcher(run: { arguments, environment, _ in #expect(environment["CI"] == "1") #expect(environment["VERCEL_TELEMETRY_DISABLED"] == "1") #expect(environment["AI_GATEWAY_API_KEY"] == nil) @@ -75,7 +78,7 @@ struct VercelCLIUsageTests { @Test func `team discovery follows every page without switching the CLI team`() async throws { - let fetcher = VercelCLIUsageFetcher(run: { arguments, _ in + let fetcher = VercelCLIUsageFetcher(run: { arguments, _, _ in #expect(arguments.prefix(2) == ["teams", "list"]) #expect(!arguments.contains("switch")) #expect(!arguments.contains("--scope")) @@ -98,7 +101,7 @@ struct VercelCLIUsageTests { @Test func `missing current teams are not replaced by the first membership`() async throws { - let fetcher = VercelCLIUsageFetcher(run: { arguments, _ in + let fetcher = VercelCLIUsageFetcher(run: { arguments, _, _ in #expect(arguments.first == "teams") return "{\"teams\":\(Self.teamsJSON.replacingOccurrences(of: "true", with: "false"))}" }) @@ -116,7 +119,7 @@ struct VercelCLIUsageTests { "private-response", "{}", #"{"teams":[],"pagination":{"next":10}}"#, "{\"teams\":\(Self.teamsJSON.replacingOccurrences(of: "false", with: "true"))}", ] { - let fetcher = VercelCLIUsageFetcher(run: { _, _ in body }) + let fetcher = VercelCLIUsageFetcher(run: { _, _, _ in body }) do { _ = try await fetcher.teams(environment: [:]) Issue.record("Expected invalid team response") @@ -167,7 +170,7 @@ struct VercelCLIUsageTests { + "\"nextCursor\":null}" let cap = 1024 * 1024 #expect(first.utf8.count + last.utf8.count > cap) - let fetcher = VercelCLIUsageFetcher(run: { arguments, _ in + 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) @@ -187,7 +190,7 @@ struct VercelCLIUsageTests { @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 + 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 { @@ -271,7 +274,7 @@ struct VercelCLIUsageTests { @Test func `invalid explicit team scopes are rejected before running a command`() async throws { - let fetcher = VercelCLIUsageFetcher(run: { _, _ in + let fetcher = VercelCLIUsageFetcher(run: { _, _, _ in Issue.record("Invalid team must not launch the CLI") return "{}" }) @@ -285,7 +288,7 @@ struct VercelCLIUsageTests { @Test func `CLI failures hide stderr and cancellation propagates`() async throws { - let fetcher = VercelCLIUsageFetcher(run: { _, _ in + let fetcher = VercelCLIUsageFetcher(run: { _, _, _ in throw SubprocessRunnerError.nonZeroExit(code: 1, stderr: "private-response") }) do { @@ -295,7 +298,7 @@ struct VercelCLIUsageTests { #expect(error.kind == .apiFailure) #expect(!error.message.contains("private-response")) } - let cancelled = VercelCLIUsageFetcher(run: { _, _ in throw CancellationError() }) + let cancelled = VercelCLIUsageFetcher(run: { _, _, _ in throw CancellationError() }) await #expect(throws: CancellationError.self) { try await cancelled.fetch(environment: ["VERCEL_TEAM_ID": "team_fixture"]) } @@ -315,7 +318,7 @@ struct VercelCLIUsageTests { (#"{"user":{"id":""}}"#, "{\"budgets\":[]}"), (Self.user, "private-response"), ] { - let fetcher = VercelCLIUsageFetcher(run: { arguments, _ in + let fetcher = VercelCLIUsageFetcher(run: { arguments, _, _ in arguments[1] == "/v2/user" ? user : response }) do { @@ -354,6 +357,166 @@ struct VercelCLIUsageTests { } } +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] { diff --git a/docs/vercel.md b/docs/vercel.md index a74b3fa53a8..d6dd93397f2 100644 --- a/docs/vercel.md +++ b/docs/vercel.md @@ -36,6 +36,9 @@ CodexBar runs read-only `vercel api` commands with JSON output: 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 @@ -49,6 +52,9 @@ Existing history is preserved on disk, but is neither displayed nor extended by 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.