diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dfb2951c66..b148b91bfb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - CLI: Claude Auto usage follows the active claude-swap account, matching cards and dashboard; explicit source/account selections retain their behavior, and adapter fallbacks identify their actual source and account (#4599). Thanks @postoso! ### Added +- Vercel: optionally show your personal AI Gateway budget using a separate Vercel access token and Team ID, with UTC reset times and a clear unconfigured state (#4621, #4623). Thanks @sf-jin-ku! - Plugins: user plugins can declare a monochrome inline SVG icon (`icon.svg`, at most 8 KB, plain shapes only), drawn as a template image in the switcher and menu card; an SVG that fails validation falls back to the monogram (#4608). Thanks @shivanathd! - Plugins: detail rows can declare normal, warning, or critical severity independently of progress, with system colors and warning symbols (#4602). Thanks @DamonYoungWork! 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..cf33f7d7ca9 --- /dev/null +++ b/Sources/CodexBar/Providers/Vercel/VercelProviderImplementation.swift @@ -0,0 +1,34 @@ +import CodexBarCore +import Foundation + +struct VercelProviderImplementation: ProviderImplementation { + let id: UsageProvider = .vercel + private let base = PluginAPIKeyProviderImplementation(spec: VercelProviderDescriptor.spec) + + @MainActor + func observeSettings(_ settings: SettingsStore) { + self.base.observeSettings(settings) + _ = settings.providerConfig(for: self.id) + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + var fields = self.base.settingsFields(context: context) + fields.insert( + ProviderSettingsFieldDescriptor( + id: "vercel-access-token", + title: "Vercel access token", + subtitle: "Optional. Shows your personal budget instead of team credits. " + + "Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN.", + kind: .secure, + placeholder: nil, + binding: context.providerConfigSecretBinding(key: "VERCEL_ACCESS_TOKEN", logField: "accessToken"), + actions: [.openURL( + id: "vercel-open-tokens", + title: "Open Vercel tokens", + url: URL(string: "https://vercel.com/account/settings/tokens"))], + isVisible: nil), + at: min(1, fields.count)) + return fields + } +} diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 31bd7ba039c..808992bae1c 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -2205,6 +2205,10 @@ "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"; +"Vercel access token" = "رمز وصول Vercel"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "اختياري. يعرض ميزانيتك الشخصية بدلاً من أرصدة الفريق. يُحفَظ في ملف الإعداد المحلي لـ CodexBar. أو عيّن VERCEL_ACCESS_TOKEN."; +"Open Vercel tokens" = "فتح رموز Vercel"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "مطلوب مع رمز وصول Vercel. أو عيّن VERCEL_TEAM_ID."; "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."; @@ -2695,3 +2699,5 @@ "Move up" = "نقل لأعلى"; "Move down" = "نقل لأسفل"; "Leave blank to keep the saved credential." = "اتركه فارغًا للاحتفاظ ببيانات الاعتماد المحفوظة."; + +"No personal budget configured" = "لم يتم إعداد ميزانية شخصية"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 9d1862346a4..c35626fba69 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -2205,6 +2205,10 @@ "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"; +"Vercel access token" = "Testimoni d'accés de Vercel"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "Opcional. Mostra el vostre pressupost personal en lloc dels crèdits de l'equip. Es desa al fitxer de configuració local de CodexBar. O definiu VERCEL_ACCESS_TOKEN."; +"Open Vercel tokens" = "Obre els testimonis de Vercel"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "Obligatori amb un testimoni d'accés de Vercel. O definiu VERCEL_TEAM_ID."; "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."; @@ -2693,3 +2697,5 @@ "Move up" = "Mou amunt"; "Move down" = "Mou avall"; "Leave blank to keep the saved credential." = "Deixeu-ho en blanc per conservar la credencial desada."; + +"No personal budget configured" = "No hi ha cap pressupost personal configurat"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index ede8e1181a5..19fae7aac86 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -2203,6 +2203,10 @@ "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"; +"Vercel access token" = "Vercel-Zugriffstoken"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "Optional. Zeigt Ihr persönliches Budget statt der Team-Guthaben. Wird in der lokalen Konfigurationsdatei von CodexBar gespeichert. Alternativ VERCEL_ACCESS_TOKEN setzen."; +"Open Vercel tokens" = "Vercel-Tokens öffnen"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "Zusammen mit einem Vercel-Zugriffstoken erforderlich. Alternativ VERCEL_TEAM_ID setzen."; "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."; @@ -2689,3 +2693,5 @@ "Move up" = "Nach oben"; "Move down" = "Nach unten"; "Leave blank to keep the saved credential." = "Leer lassen, um die gespeicherten Zugangsdaten beizubehalten."; + +"No personal budget configured" = "Kein persönliches Budget konfiguriert"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 331656c35d8..02151a4dcda 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -2205,6 +2205,10 @@ "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"; +"Vercel access token" = "Vercel access token"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN."; +"Open Vercel tokens" = "Open Vercel tokens"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "Required with a Vercel access token. Or set VERCEL_TEAM_ID."; "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."; @@ -2696,3 +2700,5 @@ "Move up" = "Move up"; "Move down" = "Move down"; "Leave blank to keep the saved credential." = "Leave blank to keep the saved credential."; + +"No personal budget configured" = "No personal budget configured"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index a71af774204..22e6eb2832c 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -2199,6 +2199,10 @@ "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"; +"Vercel access token" = "Token de acceso de Vercel"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "Opcional. Muestra tu presupuesto personal en lugar de los créditos del equipo. Se guarda en el archivo de configuración local de CodexBar. O configura VERCEL_ACCESS_TOKEN."; +"Open Vercel tokens" = "Abrir tokens de Vercel"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "Obligatorio con un token de acceso de Vercel. O configura VERCEL_TEAM_ID."; "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."; @@ -2689,3 +2693,5 @@ "Move up" = "Subir"; "Move down" = "Bajar"; "Leave blank to keep the saved credential." = "Déjalo en blanco para conservar la credencial guardada."; + +"No personal budget configured" = "No hay ningún presupuesto personal configurado"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index e17879f2fa8..d5cb8abcffe 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -2205,6 +2205,10 @@ "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"; +"Vercel access token" = "توکن دسترسی Vercel"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "اختیاری. به‌جای اعتبار تیم، بودجهٔ شخصی شما را نشان می‌دهد. در فایل پیکربندی محلی CodexBar ذخیره می‌شود. یا VERCEL_ACCESS_TOKEN را تنظیم کنید."; +"Open Vercel tokens" = "باز کردن توکن‌های Vercel"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "همراه با توکن دسترسی Vercel لازم است. یا VERCEL_TEAM_ID را تنظیم کنید."; "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 را کپی کنید."; @@ -2695,3 +2699,5 @@ "Move up" = "انتقال به بالا"; "Move down" = "انتقال به پایین"; "Leave blank to keep the saved credential." = "برای حفظ اطلاعات ورود ذخیره‌شده، خالی بگذارید."; + +"No personal budget configured" = "بودجهٔ شخصی تنظیم نشده است"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 974530d3ede..14173b6a87c 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -2200,6 +2200,10 @@ "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"; +"Vercel access token" = "Jeton d'accès Vercel"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "Facultatif. Affiche votre budget personnel au lieu des crédits de l'équipe. Enregistré dans le fichier de configuration local de CodexBar. Ou définissez VERCEL_ACCESS_TOKEN."; +"Open Vercel tokens" = "Ouvrir les jetons Vercel"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "Requis avec un jeton d'accès Vercel. Ou définissez VERCEL_TEAM_ID."; "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."; @@ -2690,3 +2694,5 @@ "Move up" = "Monter"; "Move down" = "Descendre"; "Leave blank to keep the saved credential." = "Laissez ce champ vide pour conserver les identifiants enregistrés."; + +"No personal budget configured" = "Aucun budget personnel configuré"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 34011314c53..8cc65d331b1 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -2200,6 +2200,10 @@ "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"; +"Vercel access token" = "Token de acceso de Vercel"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "Opcional. Mostra o teu orzamento persoal en lugar dos créditos do equipo. Gárdase no ficheiro de configuración local de CodexBar. Ou define VERCEL_ACCESS_TOKEN."; +"Open Vercel tokens" = "Abrir os tokens de Vercel"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "Obrigatorio cun token de acceso de Vercel. Ou define VERCEL_TEAM_ID."; "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."; @@ -2690,3 +2694,5 @@ "Move up" = "Mover arriba"; "Move down" = "Mover abaixo"; "Leave blank to keep the saved credential." = "Déixao en branco para conservar a credencial gardada."; + +"No personal budget configured" = "Non hai ningún orzamento persoal configurado"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index e8c58d33825..5dd8081afbc 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -2205,6 +2205,10 @@ "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"; +"Vercel access token" = "Token akses Vercel"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "Opsional. Menampilkan anggaran pribadi Anda, bukan kredit tim. Disimpan di file konfigurasi lokal CodexBar. Atau atur VERCEL_ACCESS_TOKEN."; +"Open Vercel tokens" = "Buka token Vercel"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "Wajib bersama token akses Vercel. Atau atur VERCEL_TEAM_ID."; "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."; @@ -2693,3 +2697,5 @@ "Move up" = "Pindah ke atas"; "Move down" = "Pindah ke bawah"; "Leave blank to keep the saved credential." = "Biarkan kosong untuk mempertahankan kredensial tersimpan."; + +"No personal budget configured" = "Belum ada anggaran pribadi yang dikonfigurasi"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 879df2ab190..9da6fa2be80 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -2205,6 +2205,10 @@ "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"; +"Vercel access token" = "Token di accesso Vercel"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "Facoltativo. Mostra il tuo budget personale anziché i crediti del team. Salvato nel file di configurazione locale di CodexBar. Oppure imposta VERCEL_ACCESS_TOKEN."; +"Open Vercel tokens" = "Apri i token Vercel"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "Obbligatorio con un token di accesso Vercel. Oppure imposta VERCEL_TEAM_ID."; "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."; @@ -2695,3 +2699,5 @@ "Move up" = "Sposta su"; "Move down" = "Sposta giù"; "Leave blank to keep the saved credential." = "Lascia vuoto per mantenere la credenziale salvata."; + +"No personal budget configured" = "Nessun budget personale configurato"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index f023c7b3ce6..8d51dc97bef 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -2201,6 +2201,10 @@ "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"; +"Vercel access token" = "Vercel アクセストークン"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "任意。チームのクレジットの代わりに個人予算を表示します。CodexBar のローカル設定ファイルに保存されます。または VERCEL_ACCESS_TOKEN を設定してください。"; +"Open Vercel tokens" = "Vercel トークンを開く"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "Vercel アクセストークンと併せて必要です。または VERCEL_TEAM_ID を設定してください。"; "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 リクエスト ヘッダーをコピーします。"; @@ -2691,3 +2695,5 @@ "Move up" = "上へ移動"; "Move down" = "下へ移動"; "Leave blank to keep the saved credential." = "保存済みの認証情報を維持するには空欄にしてください。"; + +"No personal budget configured" = "個人予算が設定されていません"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 2998c729fa4..3e4a4d65c34 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -2169,6 +2169,10 @@ "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"; +"Vercel access token" = "Vercel 액세스 토큰"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "선택 사항입니다. 팀 크레딧 대신 개인 예산을 표시합니다. CodexBar의 로컬 구성 파일에 저장됩니다. 또는 VERCEL_ACCESS_TOKEN을 설정하세요."; +"Open Vercel tokens" = "Vercel 토큰 열기"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "Vercel 액세스 토큰과 함께 필요합니다. 또는 VERCEL_TEAM_ID를 설정하세요."; "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 요청 헤더를 복사합니다."; @@ -2659,3 +2663,5 @@ "Move up" = "위로 이동"; "Move down" = "아래로 이동"; "Leave blank to keep the saved credential." = "저장된 인증 정보를 유지하려면 비워 두세요."; + +"No personal budget configured" = "개인 예산이 설정되지 않았습니다"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index af7bf5977bf..f56ccbb427a 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -2200,6 +2200,10 @@ "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"; +"Vercel access token" = "Vercel-toegangstoken"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "Optioneel. Toont je persoonlijke budget in plaats van teamtegoed. Opgeslagen in het lokale configuratiebestand van CodexBar. Of stel VERCEL_ACCESS_TOKEN in."; +"Open Vercel tokens" = "Vercel-tokens openen"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "Vereist samen met een Vercel-toegangstoken. Of stel VERCEL_TEAM_ID in."; "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."; @@ -2688,3 +2692,5 @@ "Move up" = "Omhoog"; "Move down" = "Omlaag"; "Leave blank to keep the saved credential." = "Laat leeg om de opgeslagen inloggegevens te behouden."; + +"No personal budget configured" = "Geen persoonlijk budget ingesteld"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 2b9726064a5..1b4289a606d 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -2204,6 +2204,10 @@ "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"; +"Vercel access token" = "Token dostępu Vercel"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "Opcjonalne. Pokazuje Twój budżet osobisty zamiast środków zespołu. Zapisywane w lokalnym pliku konfiguracji CodexBar. Możesz też ustawić VERCEL_ACCESS_TOKEN."; +"Open Vercel tokens" = "Otwórz tokeny Vercel"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "Wymagane razem z tokenem dostępu Vercel. Możesz też ustawić VERCEL_TEAM_ID."; "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."; @@ -2694,3 +2698,5 @@ "Move up" = "Przenieś w górę"; "Move down" = "Przenieś w dół"; "Leave blank to keep the saved credential." = "Pozostaw puste, aby zachować zapisane dane uwierzytelniające."; + +"No personal budget configured" = "Nie skonfigurowano budżetu osobistego"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index d7fc4b43295..f789e906881 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -2201,6 +2201,10 @@ "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"; +"Vercel access token" = "Token de acesso da Vercel"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "Opcional. Mostra seu orçamento pessoal em vez dos créditos da equipe. Salvo no arquivo de configuração local do CodexBar. Ou defina VERCEL_ACCESS_TOKEN."; +"Open Vercel tokens" = "Abrir tokens da Vercel"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "Obrigatório com um token de acesso da Vercel. Ou defina VERCEL_TEAM_ID."; "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."; @@ -2691,3 +2695,5 @@ "Move up" = "Mover para cima"; "Move down" = "Mover para baixo"; "Leave blank to keep the saved credential." = "Deixe em branco para manter a credencial salva."; + +"No personal budget configured" = "Nenhum orçamento pessoal configurado"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index f945a752a6d..b67101865db 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -2202,6 +2202,10 @@ "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"; +"Vercel access token" = "Токен доступа Vercel"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "Необязательно. Показывает ваш личный бюджет вместо кредитов команды. Сохраняется в локальном файле конфигурации CodexBar. Или задайте VERCEL_ACCESS_TOKEN."; +"Open Vercel tokens" = "Открыть токены Vercel"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "Требуется вместе с токеном доступа Vercel. Или задайте VERCEL_TEAM_ID."; "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."; @@ -2692,3 +2696,5 @@ "Move up" = "Переместить вверх"; "Move down" = "Переместить вниз"; "Leave blank to keep the saved credential." = "Оставьте поле пустым, чтобы сохранить текущие учётные данные."; + +"No personal budget configured" = "Личный бюджет не настроен"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 6ebd3d97982..41ca94e5b7f 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -2200,6 +2200,10 @@ "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"; +"Vercel access token" = "Vercel-åtkomsttoken"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "Valfritt. Visar din personliga budget i stället för teamets krediter. Sparas i CodexBars lokala konfigurationsfil. Eller ange VERCEL_ACCESS_TOKEN."; +"Open Vercel tokens" = "Öppna Vercel-tokens"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "Krävs tillsammans med en Vercel-åtkomsttoken. Eller ange VERCEL_TEAM_ID."; "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."; @@ -2690,3 +2694,5 @@ "Move up" = "Flytta upp"; "Move down" = "Flytta ned"; "Leave blank to keep the saved credential." = "Lämna tomt för att behålla de sparade inloggningsuppgifterna."; + +"No personal budget configured" = "Ingen personlig budget har konfigurerats"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 3269d86c516..f860e0b4ca9 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -2205,6 +2205,10 @@ "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"; +"Vercel access token" = "โทเค็นการเข้าถึง Vercel"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "ไม่บังคับ แสดงงบประมาณส่วนตัวของคุณแทนเครดิตของทีม บันทึกไว้ในไฟล์การกำหนดค่าภายในเครื่องของ CodexBar หรือตั้งค่า VERCEL_ACCESS_TOKEN"; +"Open Vercel tokens" = "เปิดโทเค็น Vercel"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "จำเป็นเมื่อใช้โทเค็นการเข้าถึง Vercel หรือตั้งค่า VERCEL_TEAM_ID"; "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"; @@ -2693,3 +2697,5 @@ "Move up" = "เลื่อนขึ้น"; "Move down" = "เลื่อนลง"; "Leave blank to keep the saved credential." = "เว้นว่างไว้เพื่อใช้ข้อมูลรับรองที่บันทึกไว้ต่อไป"; + +"No personal budget configured" = "ยังไม่ได้กำหนดงบประมาณส่วนตัว"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index f9f52102bb5..02012853d3e 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -2204,6 +2204,10 @@ "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"; +"Vercel access token" = "Vercel erişim belirteci"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "İsteğe bağlı. Ekip kredileri yerine kişisel bütçenizi gösterir. CodexBar'ın yerel yapılandırma dosyasına kaydedilir. Veya VERCEL_ACCESS_TOKEN ayarlayın."; +"Open Vercel tokens" = "Vercel belirteçlerini aç"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "Vercel erişim belirteciyle birlikte gereklidir. Veya VERCEL_TEAM_ID ayarlayın."; "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."; @@ -2694,3 +2698,5 @@ "Move up" = "Yukarı taşı"; "Move down" = "Aşağı taşı"; "Leave blank to keep the saved credential." = "Kayıtlı kimlik bilgisini korumak için boş bırakın."; + +"No personal budget configured" = "Kişisel bütçe yapılandırılmadı"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index af1a79300f6..17f8a73b98b 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -2201,6 +2201,10 @@ "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"; +"Vercel access token" = "Токен доступу Vercel"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "Необов'язково. Показує ваш особистий бюджет замість кредитів команди. Зберігається в локальному файлі конфігурації CodexBar. Або задайте VERCEL_ACCESS_TOKEN."; +"Open Vercel tokens" = "Відкрити токени Vercel"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "Потрібен разом із токеном доступу Vercel. Або задайте VERCEL_TEAM_ID."; "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."; @@ -2691,3 +2695,5 @@ "Move up" = "Перемістити вгору"; "Move down" = "Перемістити вниз"; "Leave blank to keep the saved credential." = "Залиште порожнім, щоб зберегти поточні облікові дані."; + +"No personal budget configured" = "Особистий бюджет не налаштовано"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 9a84b097848..fa214612901 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -2202,6 +2202,10 @@ "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"; +"Vercel access token" = "Mã truy cập Vercel"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "Tùy chọn. Hiển thị ngân sách cá nhân của bạn thay vì tín dụng của nhóm. Được lưu trong tệp cấu hình cục bộ của CodexBar. Hoặc đặt VERCEL_ACCESS_TOKEN."; +"Open Vercel tokens" = "Mở mã Vercel"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "Bắt buộc khi dùng mã truy cập Vercel. Hoặc đặt VERCEL_TEAM_ID."; "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."; @@ -2692,3 +2696,5 @@ "Move up" = "Di chuyển lên"; "Move down" = "Di chuyển xuống"; "Leave blank to keep the saved credential." = "Để trống để giữ thông tin xác thực đã lưu."; + +"No personal budget configured" = "Chưa thiết lập ngân sách cá nhân"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index e1ea5a7cf2b..ba3606f1776 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -2196,6 +2196,10 @@ "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"; +"Vercel access token" = "Vercel 访问令牌"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "可选。显示你的个人预算,而不是团队额度。保存在 CodexBar 的本地配置文件中。或设置 VERCEL_ACCESS_TOKEN。"; +"Open Vercel tokens" = "打开 Vercel 令牌"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "使用 Vercel 访问令牌时必填。或设置 VERCEL_TEAM_ID。"; "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 请求标头。"; @@ -2683,3 +2687,5 @@ "Move up" = "上移"; "Move down" = "下移"; "Leave blank to keep the saved credential." = "留空以保留已保存的凭据。"; + +"No personal budget configured" = "尚未配置个人预算"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 313f44c5d3a..8fa114701dd 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -2183,6 +2183,10 @@ "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"; +"Vercel access token" = "Vercel 存取權杖"; +"Optional. Shows your personal budget instead of team credits. Saved in CodexBar's local config file. Or set VERCEL_ACCESS_TOKEN." = "選用。顯示你的個人預算,而非團隊額度。儲存在 CodexBar 的本機設定檔中。或設定 VERCEL_ACCESS_TOKEN。"; +"Open Vercel tokens" = "開啟 Vercel 權杖"; +"Required with a Vercel access token. Or set VERCEL_TEAM_ID." = "使用 Vercel 存取權杖時必填。或設定 VERCEL_TEAM_ID。"; "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 請求標頭。"; @@ -2669,3 +2673,5 @@ "Move up" = "上移"; "Move down" = "下移"; "Leave blank to keep the saved credential." = "留空以保留已儲存的憑證。"; + +"No personal budget configured" = "尚未設定個人預算"; diff --git a/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift index 3e430bcbf07..cac002b5bcb 100644 --- a/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Vercel/VercelProviderDescriptor.swift @@ -1,23 +1,67 @@ import Foundation public enum VercelProviderDescriptor { - public static let descriptor = Self.spec.makeDescriptor() + static let tokenKey = "VERCEL_ACCESS_TOKEN" + static let teamKey = "VERCEL_TEAM_ID" + + /// The plugin host binds one auth secret per manifest, so the personal budget is a second bundled script. + public static let descriptor = Self.spec.makeDescriptor(fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { context in + Self.budgetValues(context) == nil ? [Self.spec.makeStrategy()] : [Self.budgetStrategy()] + }))) + public static let spec = PluginProviderSpec( id: .vercel, displayName: "Vercel AI Gateway", - sessionLabel: "Balance", + sessionLabel: "Personal budget", weeklyLabel: "Balance", toggleTitle: "Show Vercel AI Gateway balance", balanceOnly: true, dashboardURL: "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway", color: .init(hex: 0xFFFFFF), confetti: [0xFFFFFF, 0xA3A3A3], + progressColorStyle: .label, noDataMessage: "Vercel AI Gateway cost history is not available.", + history: .unavailable, + burnDownWidgetSelectable: false, environmentKey: "AI_GATEWAY_API_KEY", - missingCredentialMessage: { _ in "Set a Vercel AI Gateway API key in Settings or AI_GATEWAY_API_KEY." }, + missingCredentialMessage: { _ in + "Set a Vercel AI Gateway API key, or a Vercel access token and Team ID for your personal budget." + }, + additionalProjections: [.init(key: Self.tokenKey, value: { $0.pluginSecrets?[Self.tokenKey] })], + 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: Self.teamKey, + field: .init( + id: "vercel-team-id", + title: "Team ID", + subtitle: "Required with a Vercel access token. Or set VERCEL_TEAM_ID.", + placeholder: nil))) + + static func budgetValues(_ context: ProviderFetchContext) -> ScriptFetchStrategy.Values? { + guard let token = SettingsValue.cleaned(context.env[tokenKey]) else { return nil } + return .init( + settings: [Self.teamKey: SettingsValue.cleaned(context.env[Self.teamKey]) ?? ""], + secrets: [Self.tokenKey: token]) + } + + static func budgetStrategy( + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) -> ScriptFetchStrategy + { + ScriptFetchStrategy( + id: "vercel-budget.js", + provider: .vercel, + bundledPlugin: "vercel-budget", + secretKey: self.tokenKey, + sourceLabel: "api", + transport: transport, + resolveValues: self.budgetValues, + isEnabled: { _ in true }) + } } diff --git a/Sources/CodexBarCore/Resources/Plugins/vercel-budget.js b/Sources/CodexBarCore/Resources/Plugins/vercel-budget.js new file mode 100644 index 00000000000..82ca0dc2874 --- /dev/null +++ b/Sources/CodexBarCore/Resources/Plugins/vercel-budget.js @@ -0,0 +1,130 @@ +defineProvider({ + id: "vercel", + name: "Vercel AI Gateway", + endpoints: ["https://api.vercel.com"], + auth: { type: "bearer", secret: "VERCEL_ACCESS_TOKEN" }, + settings: [ + { key: "VERCEL_ACCESS_TOKEN", title: "Vercel access token", type: "secure" }, + { key: "VERCEL_TEAM_ID", title: "Team ID", type: "plain" }, + ], + capabilities: ["http-status"], + async fetchUsage(ctx) { + const team = ctx.settings.get("VERCEL_TEAM_ID")?.trim(); + if (!team) throw ctx.fail.missingCredential("Set the Vercel Team ID to read your personal budget."); + if (!/^[A-Za-z0-9_-]{1,128}$/.test(team)) { + throw ctx.fail.permissionDenied("Invalid Vercel Team ID. Enter the team ID or slug without spaces."); + } + function fail() { + throw ctx.fail.parseFailure("Vercel returned an unrecognized personal budget."); + } + async function read(url) { + const response = await ctx.http.get(url); + const message = `Vercel API returned HTTP ${response.status}.`; + if (response.status === 401) throw ctx.fail.authenticationExpired(message); + if (response.status === 403) throw ctx.fail.permissionDenied(message); + if (response.status === 429) { + const delay = Number(response.headers["retry-after"] ?? 1); + throw ctx.fail.rateLimited(message, { + retryAfterSeconds: Number.isFinite(delay) && delay >= 0 ? Math.min(delay, 10) : 1, + }); + } + if (response.status >= 500) throw ctx.fail.providerUnavailable(message); + if (response.status !== 200) throw ctx.fail.apiFailure(message); + try { + return JSON.parse(response.bodyText); + } catch { + return fail(); + } + } + + const user = (await read("https://api.vercel.com/v2/user"))?.user; + if (typeof user?.id !== "string" || !user.id) return fail(); + + // At most 20 pages; the plugin deadline bounds slower teams. + const matches = []; + const cursors = new Set(); + let cursor = null; + for (let page = 0; ; page += 1) { + const query = + `teamId=${encodeURIComponent(team)}&scopeType=user&limit=200` + + (cursor === null ? "" : `&cursor=${encodeURIComponent(cursor)}`); + const body = await read(`https://api.vercel.com/ai-gateway/budgets/list?${query}`); + if (!Array.isArray(body?.budgets)) return fail(); + for (const budget of body.budgets) { + if (budget?.scopeType !== "user" || budget.scopeId !== user.id) continue; + if (typeof budget.active !== "boolean" || typeof budget.archived !== "boolean") return fail(); + if (budget.source !== undefined && budget.source !== "default") return fail(); + if (budget.active && !budget.archived) matches.push(budget); + } + const next = body.nextCursor; + if (next === undefined || next === null) break; + if (typeof next !== "string" || !next || cursors.has(next) || page >= 19) return fail(); + cursors.add(next); + cursor = next; + } + + const identity = { + email: typeof user.email === "string" ? user.email : null, + organization: team, + loginMethod: "Access token", + accountID: user.id, + }; + const custom = matches.filter((budget) => budget.source !== "default"); + const effective = custom.length > 0 ? custom : matches; + if (effective.length > 1) return fail(); + const budget = effective[0]; + if (!budget) { + return { + details: [{ title: "Personal budget", rows: [{ label: "No personal budget configured", value: "—" }] }], + identity, + dataConfidence: "exact", + }; + } + const limit = budget.limitAmount; + const spent = budget.currentSpend; + if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) return fail(); + if (typeof spent !== "number" || !Number.isFinite(spent) || spent < 0) return fail(); + + // Documented UTC boundaries: daily midnight, weekly Monday, monthly the 1st; `none` never resets. + const now = ctx.date.now(); + const year = now.getUTCFullYear(); + const month = now.getUTCMonth(); + const day = now.getUTCDate(); + let start = null; + let end = null; + if (budget.refreshPeriod === "daily") { + start = Date.UTC(year, month, day); + end = Date.UTC(year, month, day + 1); + } else if (budget.refreshPeriod === "weekly") { + start = Date.UTC(year, month, day - ((now.getUTCDay() + 6) % 7)); + end = start + 7 * 24 * 60 * 60 * 1000; + } else if (budget.refreshPeriod === "monthly") { + start = Date.UTC(year, month, 1); + end = Date.UTC(year, month + 1, 1); + } else if (budget.refreshPeriod !== "none") { + return fail(); + } + const spentText = ctx.format.usd(spent); + const limitText = ctx.format.usd(limit); + return { + primary: { + usedPercent: (spent / limit) * 100, + windowMinutes: start === null ? null : (end - start) / 60000, + resetsAt: end === null ? null : new Date(end), + resetDescription: `${spentText} / ${limitText}`, + }, + details: [ + { + title: "Personal budget", + rows: [ + { label: "spent", value: spentText }, + { label: "Limit", value: limitText }, + { label: "Remaining", value: ctx.format.usd(Math.max(0, limit - spent)) }, + ], + }, + ], + identity, + dataConfidence: "exact", + }; + }, +}); diff --git a/Tests/CodexBarTests/BurnDownCapabilityTests.swift b/Tests/CodexBarTests/BurnDownCapabilityTests.swift index 9138f48db66..ba7776e0223 100644 --- a/Tests/CodexBarTests/BurnDownCapabilityTests.swift +++ b/Tests/CodexBarTests/BurnDownCapabilityTests.swift @@ -20,7 +20,8 @@ struct BurnDownCapabilityTests { @Test func `provider eligibility preserves supported catalog entries`() { for provider in UsageProvider.allCases { - let expected: [UsageProvider] = [.langdock, .xapi, .tinyapi, .exa].contains(provider) ? [] : [provider] + let expected: [UsageProvider] = [.langdock, .xapi, .tinyapi, .exa, .vercel] + .contains(provider) ? [] : [provider] for minutes in [90, 300, 1440, 10080, 43200] { let snapshot = Self.snapshot(provider: provider, primary: Self.window(minutes: minutes)) #expect(BurnProviderOptions.choices(in: snapshot).map(\.provider) == expected) diff --git a/Tests/CodexBarTests/ConfigValidationTests.swift b/Tests/CodexBarTests/ConfigValidationTests.swift index 7de0b46b52d..858ac7815c4 100644 --- a/Tests/CodexBarTests/ConfigValidationTests.swift +++ b/Tests/CodexBarTests/ConfigValidationTests.swift @@ -379,7 +379,7 @@ struct ConfigValidationTests { let issue = issues.first { $0.provider == .gemini && $0.code == "workspace_unused" } let expectedMessage = "workspaceID is set but only azureopenai, openai, opencode, opencodego, devin, deepgram, " + - "xai, gitkraken, and muse support workspaceID." + "xai, gitkraken, muse, and vercel support workspaceID." #expect(issue?.message == expectedMessage) } diff --git a/Tests/CodexBarTests/Fixtures/plugin-provider-specs.json b/Tests/CodexBarTests/Fixtures/plugin-provider-specs.json index 281eaa73500..1da98cb744c 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", "secure", "plain"], "id": "vercel", "projectToken": "", - "projections": {"AI_GATEWAY_API_KEY": "fixture-key"}, + "projections": {"AI_GATEWAY_API_KEY": "fixture-key", "VERCEL_TEAM_ID": "fixture-project"}, "sourceModes": ["api", "auto"], "strategies": ["vercel.js"], "supportsTokenCost": false, - "workspaceOrder": null + "workspaceOrder": 9 }, { "availability": [true, true, true], diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 29da4d2e562..7fb116f95ad 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -19,7 +19,7 @@ struct ProviderSettingsDescriptorTests { #expect(fixture.settings.providerConfig(for: .xkiro)?.apiKey == "fixture-key") } - @Test(arguments: [UsageProvider.atlascloud, .vercel]) + @Test(arguments: [UsageProvider.atlascloud]) func `balance providers keep API keys in their own config`(provider: UsageProvider) throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-\(provider.rawValue)") let implementation = try #require(ProviderCatalog.implementation(for: provider)) diff --git a/Tests/CodexBarTests/VercelBudgetPluginTests.swift b/Tests/CodexBarTests/VercelBudgetPluginTests.swift new file mode 100644 index 00000000000..3e4aa537e26 --- /dev/null +++ b/Tests/CodexBarTests/VercelBudgetPluginTests.swift @@ -0,0 +1,256 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +struct VercelBudgetPluginTests { + static let settings = ["VERCEL_TEAM_ID": "team_fixture"] + static let secrets = ["VERCEL_ACCESS_TOKEN": "fixture-token"] + /// Wednesday, 2026-10-14 12:00 UTC. + static let now = Date(timeIntervalSince1970: 1_791_979_200) + static let user = #"{"user":{"id":"user_fixture","email":"fixture@example.com"}}"# + + static func budget( + _ user: String = "user_fixture", + spend: String = "30", + limit: String = "120", + 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 page(_ budgets: [String], next: String? = nil) -> String { + #"{"budgets":[\#(budgets.joined(separator: ","))]\#(next.map { #","nextCursor":"\#($0)""# } ?? "")}"# + } + + /// Serves `/v2/user`, then budget pages keyed by the request's cursor (`""` for the first page). + static func transport( + pages: [String: String], + recorder: Recorder = Recorder()) -> ProviderHTTPTransportHandler + { + ProviderHTTPTransportHandler { request in + recorder.append(request) + let url = try #require(request.url) + #expect(url.host == "api.vercel.com") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-token") + if url.path == "/v2/user" { return try Self.response(request, body: Self.user) } + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + #expect(items.first { $0.name == "teamId" }?.value == "team_fixture") + #expect(items.first { $0.name == "scopeType" }?.value == "user") + let cursor = items.first { $0.name == "cursor" }?.value ?? "" + return try Self.response(request, body: #require(pages[cursor])) + } + } + + static func fetch( + pages: [String: String], + engine: ProviderPluginEngineKind = .quickJS, + settings: [String: String] = Self.settings, + recorder: Recorder = Recorder()) async throws -> UsageSnapshot + { + let runtime = try BundledPluginTestSupport.runtime( + "vercel-budget", engine: engine, transport: Self.transport(pages: pages, recorder: recorder)) + return try await runtime.fetchUsage(settings: settings, secrets: Self.secrets, now: Self.now) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `own custom budget wins across pages and other users are ignored`( + engine: ProviderPluginEngineKind) async throws + { + let recorder = Recorder() + let first = [ + Self.budget("user_other", spend: "1", limit: "2"), + Self.budget(spend: "5", limit: "50", source: "default"), + ] + let pages = ["": Self.page(first, next: "page 2"), "page 2": Self.page([Self.budget()])] + let snapshot = try await Self.fetch(pages: pages, engine: engine, recorder: recorder) + #expect(snapshot.primary?.usedPercent == 25) + #expect(snapshot.primary?.resetDescription == "$30.00 / $120.00") + #expect(snapshot.details.first?.title == "Personal budget") + #expect(snapshot.details.first?.rows.map(\.value) == ["$30.00", "$120.00", "$90.00"]) + #expect(snapshot.identity?.accountID == "user_fixture") + #expect(snapshot.identity?.accountOrganization == "team_fixture") + #expect(snapshot.dataConfidence == .exact) + #expect(recorder.all.compactMap(\.url?.path) == [ + "/v2/user", + "/ai-gateway/budgets/list", + "/ai-gateway/budgets/list", + ]) + } + + @Test(arguments: [ + ("daily", 1440, "2026-10-15T00:00:00Z"), + ("weekly", 10080, "2026-10-19T00:00:00Z"), + ("monthly", 44640, "2026-11-01T00:00:00Z"), + ]) + func `documented UTC refresh boundaries set the reset`( + period: String, minutes: Int, reset: String) async throws + { + let snapshot = try await Self.fetch(pages: ["": Self.page([Self.budget(period: period)])]) + #expect(snapshot.primary?.windowMinutes == minutes) + #expect(snapshot.primary?.resetsAt == ISO8601DateFormatter().date(from: reset)) + } + + @Test + func `cumulative budget has no reset and missing budget is not zero`() async throws { + let cumulative = try await Self.fetch(pages: ["": Self.page([Self.budget(period: "none")])]) + #expect(cumulative.primary?.resetsAt == nil) + #expect(cumulative.primary?.windowMinutes == nil) + + let missing = try await Self.fetch(pages: ["": Self.page([Self.budget("user_other")])]) + #expect(missing.primary == nil) + #expect(missing.details.first?.rows.first?.label == "No personal budget configured") + #expect(missing.details.first?.rows.first?.value == "—") + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `malformed own budget cannot masquerade as not configured`(engine: ProviderPluginEngineKind) async throws { + let malformed = Self.budget().replacingOccurrences(of: "\"active\":true,", with: "") + do { + _ = try await Self.fetch(pages: ["": Self.page([malformed])], engine: engine) + Issue.record("Expected malformed own budget to fail closed") + } catch let error as ProviderFetchClassifiedError { + #expect(error.kind == .parseFailure) + } + } + + @Test(arguments: [ + ("repeated cursor", ["": Self.page([], next: "a"), "a": Self.page([], next: "a")]), + ("ambiguous custom", ["": Self.page([Self.budget(), Self.budget()])]), + ("negative spend", ["": Self.page([Self.budget(spend: "-1")])]), + ("zero limit", ["": Self.page([Self.budget(limit: "0")])]), + ("unknown period", ["": Self.page([Self.budget(period: "hourly")])]), + ("missing list", ["": #"{"nextCursor":null}"#]), + ("malformed", ["": "private-response"]), + ]) + func `malformed or incomplete budgets fail closed`(name: String, pages: [String: String]) async throws { + do { + _ = try await Self.fetch(pages: pages) + Issue.record("Expected \(name) to fail") + } catch let error as ProviderFetchClassifiedError { + #expect(error.kind == .parseFailure) + #expect(!error.message.contains("private-response")) + } + } + + @Test + func `pagination stops after twenty pages`() async throws { + let recorder = Recorder() + var pages = ["": Self.page([], next: "c1")] + for index in 1...25 { + pages["c\(index)"] = Self.page([], next: "c\(index + 1)") + } + await #expect(throws: ProviderFetchClassifiedError.self) { + try await Self.fetch(pages: pages, recorder: recorder) + } + #expect(recorder.all.count == 21) + } + + @Test + func `HTTP failures expose status without response bodies`() async throws { + for (code, kind) in [ + (401, ProviderFetchClassifiedError.Kind.authenticationExpired), (403, .permissionDenied), + (429, .rateLimited), (503, .providerUnavailable), (400, .apiFailure), + ] { + let runtime = try BundledPluginTestSupport.runtime( + "vercel-budget", engine: .quickJS, transport: ProviderHTTPTransportHandler { request in + try Self.response(request, body: "private-response", status: code) + }) + do { + _ = try await runtime.fetchUsage(settings: Self.settings, secrets: Self.secrets) + Issue.record("Expected HTTP \(code) to fail") + } catch let error as ProviderFetchClassifiedError { + #expect(error.kind == kind) + #expect(error.message.contains("HTTP \(code)")) + #expect(!error.message.contains("private-response")) + } + } + } + + @Test + func `missing team fails before any request`() async throws { + let recorder = Recorder() + do { + _ = try await Self.fetch(pages: [:], settings: [:], recorder: recorder) + Issue.record("Expected a missing team ID to fail") + } catch let error as ProviderFetchClassifiedError { + #expect(error.kind == .missingCredential) + } + #expect(recorder.all.isEmpty) + } + + @Test + func `token cannot reach the gateway origin`() async throws { + let url = try #require(CodexBarCoreResources.bundle?.url(forResource: "vercel-budget", withExtension: "js")) + let source = try String(contentsOf: url, encoding: .utf8).replacingOccurrences( + of: #"read("https://api.vercel.com/v2/user")"#, with: #"read("https://ai-gateway.vercel.sh/v1/credits")"#) + let transport = ProviderHTTPTransportHandler { request in + Issue.record("Unexpected request to \(request.url?.absoluteString ?? "")") + return try Self.response(request, body: "{}") + } + let runtime = try ProviderPluginRuntime(source: source, transport: transport, engine: .quickJS) + do { + _ = try await runtime.fetchUsage(settings: Self.settings, secrets: Self.secrets) + Issue.record("Expected the undeclared gateway origin to be rejected") + } catch let ProviderPluginError.script(message) { + #expect(message.contains("origin 'https://ai-gateway.vercel.sh' is not declared")) + } + } + + @Test + func `configured token selects the budget script even without a gateway key`() async throws { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .vercel) + let config = ProviderConfig( + id: UsageProvider.vercel.instanceID, + workspaceID: "team_fixture", + pluginSecrets: ["VERCEL_ACCESS_TOKEN": "fixture-token"]) + let environment = try #require(descriptor.credentials).applyConfig(base: [:], config: config) + #expect(environment["VERCEL_ACCESS_TOKEN"] == "fixture-token") + #expect(environment["VERCEL_TEAM_ID"] == "team_fixture") + #expect(environment["AI_GATEWAY_API_KEY"] == nil) + + let tokenOnly = ProviderCutoverTestSupport.context(environment: environment) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(tokenOnly) + #expect(strategies.map(\.id) == ["vercel-budget.js"]) + let keyOnly = ProviderCutoverTestSupport.context(environment: ["AI_GATEWAY_API_KEY": "fixture-gateway"]) + #expect(await descriptor.fetchPlan.pipeline.resolveStrategies(keyOnly).map(\.id) == ["vercel.js"]) + + let strategy = VercelProviderDescriptor.budgetStrategy(transport: Self.transport(pages: [ + "": Self.page([Self.budget()]), + ])) + #expect(await strategy.isAvailable(tokenOnly)) + let result = try await strategy.fetch(tokenOnly) + #expect(result.strategyID == "vercel-budget.js") + #expect(result.usage.identity?.providerID == .vercel) + #expect(result.usage.primary?.usedPercent == 25) + } + + static func response(_ request: URLRequest, body: String, status: Int = 200) throws -> (Data, URLResponse) { + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: status, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])) + return (Data(body.utf8), response) + } + + final class Recorder: @unchecked Sendable { + private let lock = NSLock() + private var requests: [URLRequest] = [] + + func append(_ request: URLRequest) { + self.lock.withLock { self.requests.append(request) } + } + + var all: [URLRequest] { + self.lock.withLock { self.requests } + } + } +} diff --git a/Tests/CodexBarTests/VercelProviderSettingsTests.swift b/Tests/CodexBarTests/VercelProviderSettingsTests.swift new file mode 100644 index 00000000000..bfad51f59fa --- /dev/null +++ b/Tests/CodexBarTests/VercelProviderSettingsTests.swift @@ -0,0 +1,39 @@ +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct VercelProviderSettingsTests { + @Test + func `missing personal budget message is localized as a detail label`() throws { + let label = "No personal budget configured" + let details = try [ProviderDetailSection(title: "Personal budget", rows: [.init(label: label, value: "—")])] + for language in AppLanguage.allCases where language != .system && language != .english { + CodexBarLocalizationOverride.$appLanguage.withValue(language.rawValue) { + let localized = UsageMenuCardView.Model.localizedProviderDetails(details, provider: .vercel) + #expect(localized.first?.rows.first?.label != label) + #expect(localized.first?.rows.first?.value == "—") + } + } + } + + @Test + func `Vercel keeps the gateway key, access token, and team in provider config`() throws { + let fixture = try ProviderSettingsDescriptorTests().makeSettingsFixture(suite: #function) + let fields = try #require(ProviderCatalog.implementation(for: .vercel)) + .settingsFields(context: fixture.settingsContext(provider: .vercel)) + #expect(fields.map(\.id) == ["vercel-api-key", "vercel-access-token", "vercel-team-id"]) + #expect(fields.map(\.kind) == [.secure, .secure, .plain]) + fields[0].binding.wrappedValue = "fixture-key" + fields[1].binding.wrappedValue = "fixture-token" + fields[2].binding.wrappedValue = "team_fixture" + let config = try #require(fixture.settings.providerConfig(for: .vercel)) + #expect(config.apiKey == "fixture-key") + #expect(config.pluginSecrets?["VERCEL_ACCESS_TOKEN"] == "fixture-token") + #expect(config.workspaceID == "team_fixture") + let environment = try #require(VercelProviderDescriptor.descriptor.credentials) + .applyConfig(base: [:], config: config) + #expect(environment["VERCEL_ACCESS_TOKEN"] == "fixture-token") + #expect(environment["VERCEL_TEAM_ID"] == "team_fixture") + } +} diff --git a/docs/plugin-conversion-matrix.md b/docs/plugin-conversion-matrix.md index 7278bc0600f..d8bdabaa690 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; a second bundled script reads the personal budget with an optional Vercel access token. No CLI discovery or 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 573ed8d485f..36c263d489f 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-wide USD balance and lifetime spend; optional Vercel access token and Team ID → personal budget instead (`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/vercel.md b/docs/vercel.md index 028817989a7..482fb0216b2 100644 --- a/docs/vercel.md +++ b/docs/vercel.md @@ -1,25 +1,55 @@ --- -summary: "Vercel AI Gateway credit balance and lifetime spend through its public API." +summary: "Vercel AI Gateway team credits through an AI Gateway key, or a personal budget through an optional Vercel access token." read_when: - Setting up or modifying the Vercel AI Gateway provider --- # Vercel AI Gateway -Enable **Vercel AI Gateway** in Settings → Providers and enter an AI Gateway API key, or set -`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. +Enable **Vercel AI Gateway** in Settings → Providers. The provider shows one of two views: -The bundled JavaScript plugin reads `GET https://ai-gateway.vercel.sh/v1/credits` with bearer authentication. +- **Team credits** (default): enter an AI Gateway API key, or set `AI_GATEWAY_API_KEY`. +- **Personal budget** (optional): enter a Vercel access token and Team ID, or set `VERCEL_ACCESS_TOKEN` and + `VERCEL_TEAM_ID`. When a token is configured, it replaces the team-credit view. + +Values entered in Settings are saved in CodexBar's local config file. Use `codexbar usage --provider vercel` to fetch. + +The plugin host binds one authentication secret per manifest, so each view is a separate bundled script with its own +secret and origin: `vercel.js` sends only the AI Gateway key to `ai-gateway.vercel.sh`, and `vercel-budget.js` sends only +the access token to `api.vercel.com`. Neither credential can reach the other origin. Neither script reads Vercel CLI +credentials, switches teams, inspects Keychain, or queries the metered Custom Reporting API. + +## Team credits + +`vercel.js` 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 the view shows amounts without a percentage bar, +reset countdown, or billing-period estimate. Select the appropriate team's API key to change scope. + +## Personal budget + +`vercel-budget.js` reads `GET /v2/user` for the token's user ID, then +`GET /ai-gateway/budgets/list?teamId=…&scopeType=user` with `limit=200`, following `nextCursor` for up to 20 pages +with the same team on every page. The budget is matched by user ID; a custom budget takes precedence over an inherited +default. The card shows spend, limit, and remaining personal allowance. Daily, weekly, and monthly +budgets reset at the documented UTC boundaries (weekly on Monday); a cumulative (`none`) budget has no reset date. + +A missing personal budget is shown as not configured, never as zero spend. HTTP failures, malformed values, ambiguous +matches, invalid active/archive flags, repeated cursors, and incomplete pagination fail the refresh without publishing a partial budget. Every team +role except Contributor can read budgets. + +The remaining personal allowance is not the spendable amount: team, project, and key budgets, or the team balance, can +stop requests first. Only keys with **User** spend attribution count toward a personal budget, and BYOK spend does not. + +Vercel does not participate in plan-utilization history or burn-down widgets: a personal budget belongs to a user within +one team, which a provider-wide history bucket cannot represent. Existing history is preserved but not extended. 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 +[usage semantics](https://vercel.com/docs/ai-gateway/observability-and-spend/usage), +[budgets](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), +[spend attribution](https://vercel.com/docs/ai-gateway/authentication-and-byok/api-keys#spend-attribution), and [reporting pricing](https://vercel.com/docs/ai-gateway/observability-and-spend/custom-reporting#pricing).