;
}
+function DashboardTeamSwitcherFallback() {
+ return
;
+}
+
function DashboardNav({
groups,
trailing,
diff --git a/web/app/[locale]/dashboard/dashboard-team-switcher.tsx b/web/app/[locale]/dashboard/dashboard-team-switcher.tsx
new file mode 100644
index 000000000000..a4f77183068b
--- /dev/null
+++ b/web/app/[locale]/dashboard/dashboard-team-switcher.tsx
@@ -0,0 +1,247 @@
+"use client";
+
+import { Menu } from "@base-ui-components/react/menu";
+import { useUser } from "@stackframe/stack";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useTranslations } from "next-intl";
+import { useSearchParams } from "next/navigation";
+import { usePathname, useRouter } from "@/i18n/navigation";
+import { persistCoderouterOrganizationScope } from "@/services/coderouter/organizationScope";
+
+export type DashboardTeamCatalog = {
+ readonly selectedTeamId: string | null;
+ readonly teams: readonly DashboardCatalogTeam[];
+};
+
+export type DashboardCatalogTeam = {
+ readonly id: string;
+ readonly name: string;
+ readonly personal: boolean;
+ readonly permissions: {
+ readonly use: boolean;
+ readonly manageAccounts: boolean;
+ };
+};
+
+const CATALOG_TIMEOUT_MS = 10_000;
+
+const menuItemClass =
+ "flex min-h-9 w-full cursor-default select-none items-center gap-2 px-2.5 py-2 text-left text-sm text-foreground outline-none data-[highlighted]:bg-code-bg";
+
+/**
+ * The dashboard-wide team scope, shown at the bottom of the sidebar. Every
+ * team-scoped page reads the same persisted scope on the server, so switching
+ * here changes what the whole dashboard shows without a page-level picker.
+ */
+export function DashboardTeamSwitcher() {
+ const t = useTranslations("dashboard.teamSwitcher");
+ const user = useUser({ or: "return-null" });
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const queryClient = useQueryClient();
+ const queryKey = ["dashboard-team-catalog", user?.id ?? null] as const;
+ const { data, isPending } = useQuery({
+ queryKey,
+ queryFn: ({ signal }) => loadTeamCatalog(signal),
+ enabled: user !== null,
+ staleTime: 0,
+ refetchOnWindowFocus: "always",
+ refetchOnReconnect: "always",
+ });
+
+ if (!user) return null;
+ if (isPending) {
+ return
;
+ }
+ if (!data) return null;
+
+ const teams = permittedTeams(data);
+ if (teams.length === 0) return null;
+ const selected = selectedTeam(teams, data.selectedTeamId, searchParams.get("team"));
+
+ const switchTeam = (team: DashboardCatalogTeam) => {
+ if (team.id === selected.id) return;
+ // The scope cookie is what the server reads. Persisting it before the
+ // refresh means the very next render already shows the chosen team.
+ persistCoderouterOrganizationScope(user.id, team.id);
+ queryClient.setQueryData
(
+ queryKey,
+ (current) => current ? { ...current, selectedTeamId: team.id } : current,
+ );
+ if (searchParams.has("team")) {
+ // A deep-linked team in the URL would keep overriding the new scope.
+ router.replace(pathname);
+ }
+ router.refresh();
+ };
+
+ return (
+
+
+
+
+
+ {selected.name}
+
+ {selected.personal ? t("personal") : t("team")}
+
+
+
+
+
+
+
+ {t("label")}
+ {teams.map((team) => (
+ switchTeam(team)}
+ >
+
+ {team.name}
+ {team.personal ? (
+ {t("personal")}
+ ) : null}
+ {team.id === selected.id ? : }
+
+ ))}
+
+
+
+
+
+ );
+}
+
+/** Teams the dashboard can show: route users and account-only managers. */
+export function permittedTeams(catalog: DashboardTeamCatalog): readonly DashboardCatalogTeam[] {
+ return catalog.teams.filter(
+ (team) => team.permissions.use || team.permissions.manageAccounts,
+ );
+}
+
+/**
+ * Mirrors the server: an explicit `?team=` deep link wins, then the persisted
+ * scope the catalog already resolved, then the personal team, then the first.
+ */
+export function selectedTeam(
+ teams: readonly DashboardCatalogTeam[],
+ catalogSelectedId: string | null,
+ requestedId: string | null,
+): DashboardCatalogTeam {
+ const requested = requestedId?.trim();
+ const byRequest = requested ? teams.find((team) => team.id === requested) : undefined;
+ if (byRequest) return byRequest;
+ const byCatalog = catalogSelectedId
+ ? teams.find((team) => team.id === catalogSelectedId)
+ : undefined;
+ if (byCatalog) return byCatalog;
+ return teams.find((team) => team.personal) ?? teams[0];
+}
+
+async function loadTeamCatalog(cancellationSignal: AbortSignal): Promise {
+ const response = await fetch("/api/subrouter/teams", {
+ headers: { accept: "application/json" },
+ signal: AbortSignal.any([cancellationSignal, AbortSignal.timeout(CATALOG_TIMEOUT_MS)]),
+ });
+ if (!response.ok) throw new Error("Could not load dashboard teams");
+ const parsed = parseTeamCatalog(await response.json());
+ if (!parsed) throw new Error("Invalid dashboard team response");
+ return parsed;
+}
+
+export function parseTeamCatalog(value: unknown): DashboardTeamCatalog | null {
+ if (!isPlainRecord(value) || !Array.isArray(value.teams)) return null;
+ const selectedTeamId = value.selectedTeamId;
+ if (selectedTeamId !== null && !validText(selectedTeamId)) return null;
+ const teams: DashboardCatalogTeam[] = [];
+ const seen = new Set();
+ for (const raw of value.teams) {
+ if (
+ !isPlainRecord(raw) ||
+ !validText(raw.id) ||
+ !validText(raw.name) ||
+ typeof raw.personal !== "boolean" ||
+ !isPlainRecord(raw.permissions) ||
+ typeof raw.permissions.use !== "boolean" ||
+ typeof raw.permissions.manageAccounts !== "boolean" ||
+ seen.has(raw.id)
+ ) {
+ return null;
+ }
+ seen.add(raw.id);
+ teams.push({
+ id: raw.id,
+ name: raw.name,
+ personal: raw.personal,
+ permissions: {
+ use: raw.permissions.use,
+ manageAccounts: raw.permissions.manageAccounts,
+ },
+ });
+ }
+ return { selectedTeamId, teams };
+}
+
+function validText(value: unknown): value is string {
+ return typeof value === "string" &&
+ value.length > 0 &&
+ value.length <= 200 &&
+ value === value.trim();
+}
+
+function isPlainRecord(value: unknown): value is Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+function TeamGlyph({ name }: { readonly name: string }) {
+ const initial = [...name.trim()][0]?.toUpperCase() ?? "?";
+ return (
+
+ {initial}
+
+ );
+}
+
+function ChevronsUpDown() {
+ return (
+
+ );
+}
+
+function CheckIcon() {
+ return (
+
+ );
+}
diff --git a/web/messages/ar.json b/web/messages/ar.json
index de21cacb61aa..065afc4f2ef8 100644
--- a/web/messages/ar.json
+++ b/web/messages/ar.json
@@ -295,64 +295,6 @@
"organizationSwitchError": "تعذّر تبديل المؤسسة. حاول مرة أخرى.",
"signIn": "تسجيل الدخول"
},
- "aiAccounts": {
- "metaTitle": "حسابات الذكاء الاصطناعي — cmux",
- "metaDescription": "إدارة حسابات مزوّدي الذكاء الاصطناعي للفريق في cmux.",
- "section": "لوحة التحكم",
- "title": "حسابات الذكاء الاصطناعي",
- "description": "أدر حسابات المزوّدين التي يمكن لهذا الفريق استخدامها مع cmux AI.",
- "personalTeam": "شخصي",
- "teamSwitcherLabel": "الفريق",
- "notConfiguredTitle": "إدارة حسابات الذكاء الاصطناعي غير متاحة بعد",
- "notConfiguredBody": "حسابات الذكاء الاصطناعي للفرق غير مفعّلة في هذا النشر. حاول لاحقًا أو تواصل مع الدعم.",
- "loadErrorTitle": "تعذّر تحميل الحسابات",
- "loadErrorBody": "تعذّر الوصول إلى خدمة الحسابات. حاول مرة أخرى بعد قليل.",
- "accountsTitle": "الحسابات المتصلة",
- "accountsCount": "{count, plural, zero {لا حسابات} one {حساب واحد} two {حسابان} few {# حسابات} many {# حسابًا} other {# حساب}}",
- "emptyTitle": "لا توجد حسابات بعد",
- "emptyBody": "أضف حساب مزوّد ليصبح متاحًا لهذا الفريق.",
- "providerColumn": "المزوّد",
- "labelColumn": "التسمية",
- "createdColumn": "تاريخ الإنشاء",
- "actionsColumn": "الإجراءات",
- "unlabeledAccount": "بدون تسمية",
- "unknownCreatedAt": "غير معروف",
- "addAccountsTitle": "إضافة حساب",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "مفتاح API من Anthropic",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "مفتاح API من OpenAI",
- "providerUnknown": "مزوّد غير معروف",
- "labelField": "التسمية",
- "labelPlaceholder": "افتراضي للفريق",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "مفتاح API",
- "addClaude": "إضافة Claude",
- "addAnthropic": "إضافة Anthropic",
- "addCodex": "إضافة Codex",
- "addOpenAi": "إضافة OpenAI",
- "addSuccess": "تمت إضافة الحساب.",
- "addError": "تعذّر حفظ الحساب.",
- "deleteError": "تعذّر حذف الحساب.",
- "jsonError": "الصق كائن JSON صالحًا.",
- "validationError": "تحقق من بيانات الحساب وحاول مرة أخرى.",
- "teamAccessError": "ليست لديك صلاحية الوصول إلى هذا الفريق.",
- "deletingAction": "جارٍ الحذف",
- "deleteAction": "حذف",
- "deleteConfirmTitle": "هل تريد حذف هذا الحساب؟",
- "deleteConfirmBody": "سيُزال حساب المزوّد للفريق بأكمله. ستحتاج إلى بيانات الاعتماد الأصلية لإضافته مجددًا.",
- "cancelAction": "إلغاء",
- "claudeJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"expiresAt\":1770000000000}",
- "codexJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"idToken\":\"...\",\"accountID\":\"...\"}",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
"billing": {
"plan": {
"heading": "خطتك الشخصية",
diff --git a/web/messages/bs.json b/web/messages/bs.json
index 48678d42858e..e8443952f552 100644
--- a/web/messages/bs.json
+++ b/web/messages/bs.json
@@ -295,64 +295,6 @@
"organizationSwitchError": "Nije moguće promijeniti organizaciju. Pokušajte ponovo.",
"signIn": "Prijavi se"
},
- "aiAccounts": {
- "metaTitle": "AI nalozi — cmux",
- "metaDescription": "Upravljaj timskim nalozima AI provajdera za cmux.",
- "section": "Kontrolna tabla",
- "title": "AI nalozi",
- "description": "Upravljaj nalozima provajdera koje ovaj tim može koristiti za cmux AI.",
- "personalTeam": "Lično",
- "teamSwitcherLabel": "Tim",
- "notConfiguredTitle": "Upravljanje AI nalozima još nije dostupno",
- "notConfiguredBody": "Timski AI nalozi nisu omogućeni za ovu instalaciju. Pokušaj kasnije ili kontaktiraj podršku.",
- "loadErrorTitle": "Nalozi se nisu mogli učitati",
- "loadErrorBody": "Servis naloga nije dostupan. Pokušaj ponovo za koji trenutak.",
- "accountsTitle": "Povezani nalozi",
- "accountsCount": "{count, plural, one {# nalog} few {# naloga} other {# naloga}}",
- "emptyTitle": "Još nema naloga",
- "emptyBody": "Dodaj nalog provajdera da bi bio dostupan ovom timu.",
- "providerColumn": "Provajder",
- "labelColumn": "Oznaka",
- "createdColumn": "Kreiran",
- "actionsColumn": "Radnje",
- "unlabeledAccount": "Bez oznake",
- "unknownCreatedAt": "Nepoznato",
- "addAccountsTitle": "Dodaj nalog",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "Anthropic API ključ",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "OpenAI API ključ",
- "providerUnknown": "Nepoznat provajder",
- "labelField": "Oznaka",
- "labelPlaceholder": "Zadano za tim",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "API ključ",
- "addClaude": "Dodaj Claude",
- "addAnthropic": "Dodaj Anthropic",
- "addCodex": "Dodaj Codex",
- "addOpenAi": "Dodaj OpenAI",
- "addSuccess": "Nalog je dodan.",
- "addError": "Nalog se nije mogao sačuvati.",
- "deleteError": "Nalog se nije mogao obrisati.",
- "jsonError": "Zalijepi važeći JSON objekat.",
- "validationError": "Provjeri podatke naloga i pokušaj ponovo.",
- "teamAccessError": "Nemaš pristup ovom timu.",
- "deletingAction": "Brisanje",
- "deleteAction": "Obriši",
- "deleteConfirmTitle": "Obrisati ovaj nalog?",
- "deleteConfirmBody": "Nalog provajdera bit će uklonjen za cijeli tim. Za ponovno dodavanje trebat će ti originalni podaci za prijavu.",
- "cancelAction": "Otkaži",
- "claudeJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"expiresAt\":1770000000000}",
- "codexJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"idToken\":\"...\",\"accountID\":\"...\"}",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
"billing": {
"plan": {
"heading": "Vaš lični plan",
diff --git a/web/messages/da.json b/web/messages/da.json
index cc3e2ad1981a..3c54ded6c834 100644
--- a/web/messages/da.json
+++ b/web/messages/da.json
@@ -295,64 +295,6 @@
"organizationSwitchError": "Kunne ikke skifte organisation. Prøv igen.",
"signIn": "Log ind"
},
- "aiAccounts": {
- "metaTitle": "AI-konti — cmux",
- "metaDescription": "Administrér teamets AI-udbyderkonti til cmux.",
- "section": "Dashboard",
- "title": "AI-konti",
- "description": "Administrér de udbyderkonti, dette team kan bruge til cmux AI.",
- "personalTeam": "Personlig",
- "teamSwitcherLabel": "Team",
- "notConfiguredTitle": "AI-kontoadministration er ikke tilgængelig endnu",
- "notConfiguredBody": "Team-AI-konti er ikke aktiveret for denne installation. Prøv igen senere, eller kontakt support.",
- "loadErrorTitle": "Konti kunne ikke indlæses",
- "loadErrorBody": "Kontotjenesten kunne ikke nås. Prøv igen om lidt.",
- "accountsTitle": "Forbundne konti",
- "accountsCount": "{count, plural, one {# konto} other {# konti}}",
- "emptyTitle": "Ingen konti endnu",
- "emptyBody": "Tilføj en udbyderkonto for at gøre den tilgængelig for dette team.",
- "providerColumn": "Udbyder",
- "labelColumn": "Etiket",
- "createdColumn": "Oprettet",
- "actionsColumn": "Handlinger",
- "unlabeledAccount": "Uden etiket",
- "unknownCreatedAt": "Ukendt",
- "addAccountsTitle": "Tilføj konto",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "Anthropic API-nøgle",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "OpenAI API-nøgle",
- "providerUnknown": "Ukendt udbyder",
- "labelField": "Etiket",
- "labelPlaceholder": "Teamstandard",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "API-nøgle",
- "addClaude": "Tilføj Claude",
- "addAnthropic": "Tilføj Anthropic",
- "addCodex": "Tilføj Codex",
- "addOpenAi": "Tilføj OpenAI",
- "addSuccess": "Konto tilføjet.",
- "addError": "Kontoen kunne ikke gemmes.",
- "deleteError": "Kontoen kunne ikke slettes.",
- "jsonError": "Indsæt et gyldigt JSON-objekt.",
- "validationError": "Tjek kontooplysningerne, og prøv igen.",
- "teamAccessError": "Du har ikke adgang til dette team.",
- "deletingAction": "Sletter",
- "deleteAction": "Slet",
- "deleteConfirmTitle": "Vil du slette denne konto?",
- "deleteConfirmBody": "Udbyderkontoen fjernes for hele teamet. Du skal bruge de oprindelige legitimationsoplysninger for at tilføje den igen.",
- "cancelAction": "Annuller",
- "claudeJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"expiresAt\":1770000000000}",
- "codexJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"idToken\":\"...\",\"accountID\":\"...\"}",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
"billing": {
"plan": {
"heading": "Din personlige plan",
diff --git a/web/messages/de.json b/web/messages/de.json
index f7819302fffe..cd29b3f16c39 100644
--- a/web/messages/de.json
+++ b/web/messages/de.json
@@ -295,64 +295,6 @@
"organizationSwitchError": "Organisation konnte nicht gewechselt werden. Erneut versuchen.",
"signIn": "Anmelden"
},
- "aiAccounts": {
- "metaTitle": "KI-Konten — cmux",
- "metaDescription": "Verwalte die KI-Anbieterkonten deines Teams für cmux.",
- "section": "Dashboard",
- "title": "KI-Konten",
- "description": "Verwalte die Anbieterkonten, die dieses Team für cmux AI nutzen kann.",
- "personalTeam": "Persönlich",
- "teamSwitcherLabel": "Team",
- "notConfiguredTitle": "KI-Kontoverwaltung ist noch nicht verfügbar",
- "notConfiguredBody": "Team-KI-Konten sind für diese Bereitstellung nicht aktiviert. Versuche es später erneut oder wende dich an den Support.",
- "loadErrorTitle": "Konten konnten nicht geladen werden",
- "loadErrorBody": "Der Kontodienst war nicht erreichbar. Versuche es gleich noch einmal.",
- "accountsTitle": "Verbundene Konten",
- "accountsCount": "{count, plural, one {# Konto} other {# Konten}}",
- "emptyTitle": "Noch keine Konten",
- "emptyBody": "Füge ein Anbieterkonto hinzu, um es diesem Team zur Verfügung zu stellen.",
- "providerColumn": "Anbieter",
- "labelColumn": "Bezeichnung",
- "createdColumn": "Erstellt",
- "actionsColumn": "Aktionen",
- "unlabeledAccount": "Ohne Bezeichnung",
- "unknownCreatedAt": "Unbekannt",
- "addAccountsTitle": "Konto hinzufügen",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "Anthropic-API-Schlüssel",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "OpenAI-API-Schlüssel",
- "providerUnknown": "Unbekannter Anbieter",
- "labelField": "Bezeichnung",
- "labelPlaceholder": "Team-Standard",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "API-Schlüssel",
- "addClaude": "Claude hinzufügen",
- "addAnthropic": "Anthropic hinzufügen",
- "addCodex": "Codex hinzufügen",
- "addOpenAi": "OpenAI hinzufügen",
- "addSuccess": "Konto hinzugefügt.",
- "addError": "Konto konnte nicht gespeichert werden.",
- "deleteError": "Konto konnte nicht gelöscht werden.",
- "jsonError": "Füge ein gültiges JSON-Objekt ein.",
- "validationError": "Prüfe die Kontodaten und versuche es erneut.",
- "teamAccessError": "Du hast keinen Zugriff auf dieses Team.",
- "deletingAction": "Wird gelöscht",
- "deleteAction": "Löschen",
- "deleteConfirmTitle": "Dieses Konto löschen?",
- "deleteConfirmBody": "Das Anbieterkonto wird für das gesamte Team entfernt. Zum erneuten Hinzufügen brauchst du die ursprünglichen Zugangsdaten.",
- "cancelAction": "Abbrechen",
- "claudeJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"expiresAt\":1770000000000}",
- "codexJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"idToken\":\"...\",\"accountID\":\"...\"}",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
"billing": {
"plan": {
"heading": "Dein persönlicher Tarif",
diff --git a/web/messages/en.json b/web/messages/en.json
index 494ac608b86f..0b397d770089 100644
--- a/web/messages/en.json
+++ b/web/messages/en.json
@@ -234,6 +234,73 @@
"estimateNote": "API-equivalent value is an estimate using public list prices, not actual spend."
}
},
+ "coderouterAccounts": {
+ "title": "Accounts",
+ "description": "Claude Code, Codex, and OpenCode on this team's Cloud VMs and CLI route through these accounts. Each machine is pinned to one account per provider so prompt caching keeps working. A rate limit or a rejected credential puts that account in cooldown and moves the machine to the next one.",
+ "accountsCount": "{count, plural, one {# account} other {# accounts}}",
+ "emptyTitle": "No accounts yet",
+ "emptyBody": "Requests from this team fail until an account is added.",
+ "notConfiguredTitle": "Shared account management isn't available yet",
+ "notConfiguredBody": "Shared Codex accounts aren't enabled for this deployment. Anthropic and Bedrock accounts still work.",
+ "migrationPendingTitle": "Shared accounts temporarily unavailable",
+ "migrationPendingBody": "Shared Codex accounts are temporarily unavailable. Try again shortly.",
+ "loadErrorTitle": "Some accounts could not load",
+ "loadErrorBody": "Part of the account list could not be read. The accounts shown are complete for the other providers. Try again shortly.",
+ "pageErrorTitle": "coderouter could not load",
+ "pageErrorBody": "The account service could not be reached. Try again shortly.",
+ "providerColumn": "Provider",
+ "labelColumn": "Label",
+ "statusColumn": "Status",
+ "actionsColumn": "Actions",
+ "unlabeledAccount": "Unlabeled",
+ "kindAnthropicApiKey": "Anthropic API key",
+ "kindClaudeOauth": "Claude Code OAuth",
+ "kindBedrock": "Amazon Bedrock",
+ "kindCodex": "Codex",
+ "kindOpencode": "OpenCode",
+ "kindOpenAiApiKey": "OpenAI API key",
+ "kindUnknown": "Unknown provider",
+ "stateActive": "Active",
+ "stateDisabled": "Disabled",
+ "stateUnhealthy": "Needs attention",
+ "coolingDown": "Cooling down until {until}",
+ "lastFailure": "last failure {code}",
+ "lastUsed": "last used {at}",
+ "neverUsed": "never used",
+ "createdAt": "added {at}",
+ "addTitle": "Add account",
+ "kindSelectorLabel": "Account kind",
+ "labelField": "Label",
+ "labelPlaceholder": "Optional, for example work",
+ "apiKeyField": "API key",
+ "oauthTokenField": "OAuth token",
+ "oauthHint": "Create a long-lived token on your own machine with",
+ "regionField": "AWS region",
+ "accessKeyIdField": "Access key ID",
+ "secretAccessKeyField": "Secret access key",
+ "sessionTokenField": "Session token",
+ "optionalPlaceholder": "Optional",
+ "codexBody": "Codex signs in through a browser, so add it from your terminal. Sign in with this team, then run:",
+ "opencodeBody": "OpenCode reads its provider list from coderouter. Sign in with this team, then run:",
+ "copyCommand": "Copy command",
+ "copied": "Copied",
+ "saveAction": "Add account",
+ "savingAction": "Adding",
+ "saveSuccess": "Account added.",
+ "saveError": "Account could not be added.",
+ "validationError": "Check the credential format and try again.",
+ "teamAccessError": "You do not have access to this team.",
+ "enableAction": "Enable",
+ "disableAction": "Disable",
+ "updateError": "Account could not be updated.",
+ "removeAction": "Remove",
+ "removingAction": "Removing",
+ "removeError": "Account could not be removed.",
+ "removeConfirmTitle": "Remove this account?",
+ "removeClaudeConfirmBody": "Machines pinned to it move to the team's other accounts. If it is the last one, Claude requests from this team fail until a new account is added.",
+ "removeSharedConfirmBody": "This removes the account for the whole team. Adding it back requires signing in again.",
+ "cancelAction": "Cancel"
+ },
"accountMenu": {
"label": "Account menu",
"settings": "Settings",
@@ -244,6 +311,11 @@
"organizationSwitchError": "Could not switch organization. Try again.",
"signIn": "Sign in"
},
+ "teamSwitcher": {
+ "label": "Team",
+ "team": "Team",
+ "personal": "Personal"
+ },
"billing": {
"eyebrow": "account",
"title": "billing",
@@ -491,108 +563,6 @@
"seats": "{count, plural, =1 {1 seat} other {# seats}}",
"truncated": "This list is capped at 5,000 rows per source and some rows are not shown. Use search for anything missing."
}
- },
- "aiAccounts": {
- "metaTitle": "AI accounts — cmux",
- "metaDescription": "Manage team AI provider accounts for cmux.",
- "section": "dashboard",
- "title": "AI accounts",
- "description": "Manage the provider accounts this team can use for cmux AI.",
- "personalTeam": "Personal",
- "teamSwitcherLabel": "Team",
- "notConfiguredTitle": "AI account management isn't available yet",
- "notConfiguredBody": "Team AI accounts aren't enabled for this deployment. Try again later or contact support.",
- "migrationPendingTitle": "Accounts temporarily unavailable",
- "migrationPendingBody": "Shared accounts are temporarily unavailable. Try again shortly.",
- "loadErrorTitle": "Accounts could not load",
- "loadErrorBody": "The account service could not be reached. Try again shortly.",
- "accountsTitle": "Connected accounts",
- "accountsCount": "{count, plural, one {# account} other {# accounts}}",
- "emptyTitle": "No accounts yet",
- "emptyBody": "Add a provider account to make it available to this team.",
- "providerColumn": "Provider",
- "labelColumn": "Label",
- "createdColumn": "Created",
- "actionsColumn": "Actions",
- "unlabeledAccount": "Unlabeled",
- "unknownCreatedAt": "Unknown",
- "addAccountsTitle": "Add account",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "Anthropic API key",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "OpenAI API key",
- "providerUnknown": "Unknown provider",
- "labelField": "Label",
- "labelPlaceholder": "Team default",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "API key",
- "addClaude": "Add Claude",
- "addAnthropic": "Add Anthropic",
- "addCodex": "Add Codex",
- "addOpenAi": "Add OpenAI",
- "addSuccess": "Account added.",
- "addError": "Account could not be saved.",
- "deleteError": "Account could not be deleted.",
- "jsonError": "Paste a valid JSON object.",
- "validationError": "Check the account details and try again.",
- "teamAccessError": "You do not have access to this team.",
- "deletingAction": "Deleting",
- "deleteAction": "Delete",
- "deleteConfirmTitle": "Delete this account?",
- "deleteConfirmBody": "This removes the provider account for the whole team. Adding it back requires the original credentials.",
- "cancelAction": "Cancel",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
- "claudeUpstream": {
- "title": "Claude upstream accounts",
- "description": "Claude Code inside this team's cmux Cloud VMs sends Anthropic Messages traffic through coderouter. Add any number of accounts: Anthropic API keys, Claude Code OAuth tokens, or Amazon Bedrock credentials. Each machine is pinned to one account so prompt caching keeps working; a rate limit or a rejected credential puts that account in cooldown and moves the machine to the next one.",
- "emptyTitle": "No Claude upstream accounts",
- "emptyBody": "Claude requests from this team's Cloud VMs fail until an account is added.",
- "accountsLabel": "{count, plural, one {# account} other {# accounts}}",
- "loadErrorTitle": "Claude upstream could not load",
- "loadErrorBody": "The upstream settings could not be read. Try again shortly.",
- "kindSelectorLabel": "Upstream kind",
- "kindApiKey": "Anthropic API key",
- "kindOauth": "Claude Code OAuth token",
- "kindBedrock": "Amazon Bedrock",
- "labelField": "Label",
- "labelPlaceholder": "Optional, for example work",
- "apiKeyField": "API key",
- "oauthTokenField": "OAuth token",
- "oauthHint": "Create a long-lived token on your own machine with",
- "regionField": "AWS region",
- "accessKeyIdField": "Access key ID",
- "secretAccessKeyField": "Secret access key",
- "sessionTokenField": "Session token",
- "optionalPlaceholder": "Optional",
- "saveAction": "Add account",
- "savingAction": "Adding",
- "saveSuccess": "Account added.",
- "saveError": "Account could not be added.",
- "validationError": "Check the credential format and try again.",
- "teamAccessError": "You do not have access to this team.",
- "removeAction": "Remove",
- "removingAction": "Removing",
- "removeError": "Account could not be removed.",
- "removeConfirmTitle": "Remove this Claude upstream account?",
- "removeConfirmBody": "Machines pinned to it move to the team's other accounts. If it is the last one, Claude requests from this team's Cloud VMs fail until a new account is added.",
- "cancelAction": "Cancel",
- "stateActive": "Active",
- "stateDisabled": "Disabled",
- "coolingDown": "Cooling down until {until}",
- "lastFailure": "last failure {code}",
- "lastUsed": "last used {at}",
- "neverUsed": "never used",
- "enableAction": "Enable",
- "disableAction": "Disable",
- "updateError": "Account could not be updated."
}
},
"vault": {
diff --git a/web/messages/es.json b/web/messages/es.json
index 0e6f1aae79d6..5b9e21fff16e 100644
--- a/web/messages/es.json
+++ b/web/messages/es.json
@@ -295,64 +295,6 @@
"organizationSwitchError": "No se pudo cambiar de organización. Inténtalo de nuevo.",
"signIn": "Iniciar sesión"
},
- "aiAccounts": {
- "metaTitle": "Cuentas de IA — cmux",
- "metaDescription": "Gestiona las cuentas de proveedores de IA del equipo para cmux.",
- "section": "Panel",
- "title": "Cuentas de IA",
- "description": "Gestiona las cuentas de proveedores que este equipo puede usar con cmux AI.",
- "personalTeam": "Personal",
- "teamSwitcherLabel": "Equipo",
- "notConfiguredTitle": "La gestión de cuentas de IA aún no está disponible",
- "notConfiguredBody": "Las cuentas de IA de equipo no están habilitadas en esta implementación. Inténtalo más tarde o contacta con soporte.",
- "loadErrorTitle": "No se pudieron cargar las cuentas",
- "loadErrorBody": "No se pudo conectar con el servicio de cuentas. Inténtalo de nuevo en un momento.",
- "accountsTitle": "Cuentas conectadas",
- "accountsCount": "{count, plural, one {# cuenta} other {# cuentas}}",
- "emptyTitle": "Aún no hay cuentas",
- "emptyBody": "Añade una cuenta de proveedor para que este equipo pueda usarla.",
- "providerColumn": "Proveedor",
- "labelColumn": "Etiqueta",
- "createdColumn": "Creada",
- "actionsColumn": "Acciones",
- "unlabeledAccount": "Sin etiqueta",
- "unknownCreatedAt": "Desconocida",
- "addAccountsTitle": "Añadir cuenta",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "Clave de API de Anthropic",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "Clave de API de OpenAI",
- "providerUnknown": "Proveedor desconocido",
- "labelField": "Etiqueta",
- "labelPlaceholder": "Predeterminada del equipo",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "Clave de API",
- "addClaude": "Añadir Claude",
- "addAnthropic": "Añadir Anthropic",
- "addCodex": "Añadir Codex",
- "addOpenAi": "Añadir OpenAI",
- "addSuccess": "Cuenta añadida.",
- "addError": "No se pudo guardar la cuenta.",
- "deleteError": "No se pudo eliminar la cuenta.",
- "jsonError": "Pega un objeto JSON válido.",
- "validationError": "Revisa los datos de la cuenta y vuelve a intentarlo.",
- "teamAccessError": "No tienes acceso a este equipo.",
- "deletingAction": "Eliminando",
- "deleteAction": "Eliminar",
- "deleteConfirmTitle": "¿Eliminar esta cuenta?",
- "deleteConfirmBody": "La cuenta del proveedor se eliminará para todo el equipo. Para volver a añadirla necesitarás las credenciales originales.",
- "cancelAction": "Cancelar",
- "claudeJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"expiresAt\":1770000000000}",
- "codexJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"idToken\":\"...\",\"accountID\":\"...\"}",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
"billing": {
"plan": {
"heading": "Tu plan personal",
diff --git a/web/messages/fr.json b/web/messages/fr.json
index 82836bae32db..9e874248a0bc 100644
--- a/web/messages/fr.json
+++ b/web/messages/fr.json
@@ -295,64 +295,6 @@
"organizationSwitchError": "Impossible de changer d’organisation. Réessayez.",
"signIn": "Se connecter"
},
- "aiAccounts": {
- "metaTitle": "Comptes IA — cmux",
- "metaDescription": "Gérez les comptes de fournisseurs d'IA de l'équipe pour cmux.",
- "section": "Tableau de bord",
- "title": "Comptes IA",
- "description": "Gérez les comptes de fournisseurs que cette équipe peut utiliser avec cmux AI.",
- "personalTeam": "Personnel",
- "teamSwitcherLabel": "Équipe",
- "notConfiguredTitle": "La gestion des comptes IA n'est pas encore disponible",
- "notConfiguredBody": "Les comptes IA d'équipe ne sont pas activés pour ce déploiement. Réessayez plus tard ou contactez le support.",
- "loadErrorTitle": "Impossible de charger les comptes",
- "loadErrorBody": "Le service de comptes est injoignable. Réessayez dans un instant.",
- "accountsTitle": "Comptes connectés",
- "accountsCount": "{count, plural, one {# compte} other {# comptes}}",
- "emptyTitle": "Aucun compte pour l'instant",
- "emptyBody": "Ajoutez un compte fournisseur pour le rendre disponible à cette équipe.",
- "providerColumn": "Fournisseur",
- "labelColumn": "Libellé",
- "createdColumn": "Créé",
- "actionsColumn": "Actions",
- "unlabeledAccount": "Sans libellé",
- "unknownCreatedAt": "Inconnu",
- "addAccountsTitle": "Ajouter un compte",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "Clé API Anthropic",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "Clé API OpenAI",
- "providerUnknown": "Fournisseur inconnu",
- "labelField": "Libellé",
- "labelPlaceholder": "Par défaut pour l'équipe",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "Clé API",
- "addClaude": "Ajouter Claude",
- "addAnthropic": "Ajouter Anthropic",
- "addCodex": "Ajouter Codex",
- "addOpenAi": "Ajouter OpenAI",
- "addSuccess": "Compte ajouté.",
- "addError": "Le compte n'a pas pu être enregistré.",
- "deleteError": "Le compte n'a pas pu être supprimé.",
- "jsonError": "Collez un objet JSON valide.",
- "validationError": "Vérifiez les informations du compte et réessayez.",
- "teamAccessError": "Vous n'avez pas accès à cette équipe.",
- "deletingAction": "Suppression",
- "deleteAction": "Supprimer",
- "deleteConfirmTitle": "Supprimer ce compte ?",
- "deleteConfirmBody": "Le compte fournisseur sera retiré pour toute l'équipe. Pour le rajouter, vous aurez besoin des identifiants d'origine.",
- "cancelAction": "Annuler",
- "claudeJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"expiresAt\":1770000000000}",
- "codexJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"idToken\":\"...\",\"accountID\":\"...\"}",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
"billing": {
"plan": {
"heading": "Votre forfait personnel",
diff --git a/web/messages/it.json b/web/messages/it.json
index 3eec849ee706..71db69e0afb1 100644
--- a/web/messages/it.json
+++ b/web/messages/it.json
@@ -295,64 +295,6 @@
"organizationSwitchError": "Impossibile cambiare organizzazione. Riprova.",
"signIn": "Accedi"
},
- "aiAccounts": {
- "metaTitle": "Account IA — cmux",
- "metaDescription": "Gestisci gli account dei provider IA del team per cmux.",
- "section": "Dashboard",
- "title": "Account IA",
- "description": "Gestisci gli account dei provider che questo team può usare con cmux AI.",
- "personalTeam": "Personale",
- "teamSwitcherLabel": "Team",
- "notConfiguredTitle": "La gestione degli account IA non è ancora disponibile",
- "notConfiguredBody": "Gli account IA di team non sono abilitati per questa installazione. Riprova più tardi o contatta il supporto.",
- "loadErrorTitle": "Impossibile caricare gli account",
- "loadErrorBody": "Il servizio account non è raggiungibile. Riprova tra poco.",
- "accountsTitle": "Account collegati",
- "accountsCount": "{count, plural, one {# account} other {# account}}",
- "emptyTitle": "Ancora nessun account",
- "emptyBody": "Aggiungi un account provider per renderlo disponibile a questo team.",
- "providerColumn": "Provider",
- "labelColumn": "Etichetta",
- "createdColumn": "Creato",
- "actionsColumn": "Azioni",
- "unlabeledAccount": "Senza etichetta",
- "unknownCreatedAt": "Sconosciuto",
- "addAccountsTitle": "Aggiungi account",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "Chiave API Anthropic",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "Chiave API OpenAI",
- "providerUnknown": "Provider sconosciuto",
- "labelField": "Etichetta",
- "labelPlaceholder": "Predefinito del team",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "Chiave API",
- "addClaude": "Aggiungi Claude",
- "addAnthropic": "Aggiungi Anthropic",
- "addCodex": "Aggiungi Codex",
- "addOpenAi": "Aggiungi OpenAI",
- "addSuccess": "Account aggiunto.",
- "addError": "Impossibile salvare l'account.",
- "deleteError": "Impossibile eliminare l'account.",
- "jsonError": "Incolla un oggetto JSON valido.",
- "validationError": "Controlla i dati dell'account e riprova.",
- "teamAccessError": "Non hai accesso a questo team.",
- "deletingAction": "Eliminazione",
- "deleteAction": "Elimina",
- "deleteConfirmTitle": "Eliminare questo account?",
- "deleteConfirmBody": "L'account del provider verrà rimosso per tutto il team. Per aggiungerlo di nuovo serviranno le credenziali originali.",
- "cancelAction": "Annulla",
- "claudeJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"expiresAt\":1770000000000}",
- "codexJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"idToken\":\"...\",\"accountID\":\"...\"}",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
"billing": {
"plan": {
"heading": "Il tuo piano personale",
diff --git a/web/messages/ja.json b/web/messages/ja.json
index 606b94c9cdfc..973bf9defeab 100644
--- a/web/messages/ja.json
+++ b/web/messages/ja.json
@@ -234,6 +234,73 @@
"estimateNote": "API換算額は公開定価による推定で、実際の請求額ではありません。"
}
},
+ "coderouterAccounts": {
+ "title": "アカウント",
+ "description": "このチームの Cloud VM と CLI 上の Claude Code、Codex、OpenCode はこれらのアカウントを経由します。各マシンはプロバイダーごとに 1 つのアカウントに固定されるためプロンプトキャッシュが機能し続けます。レート制限や認証情報の拒否が起きるとそのアカウントはクールダウンに入り、マシンは次のアカウントに移ります。",
+ "accountsCount": "{count} 件のアカウント",
+ "emptyTitle": "まだアカウントがありません",
+ "emptyBody": "アカウントを追加するまで、このチームからのリクエストは失敗します。",
+ "notConfiguredTitle": "共有アカウント管理はまだ利用できません",
+ "notConfiguredBody": "このデプロイでは共有 Codex アカウントが有効になっていません。Anthropic と Bedrock のアカウントは引き続き使えます。",
+ "migrationPendingTitle": "共有アカウントを一時的に利用できません",
+ "migrationPendingBody": "共有 Codex アカウントは一時的に利用できません。しばらくしてからもう一度お試しください。",
+ "loadErrorTitle": "一部のアカウントを読み込めませんでした",
+ "loadErrorBody": "アカウント一覧の一部を読み取れませんでした。他のプロバイダーの表示は完全です。しばらくしてから再試行してください。",
+ "pageErrorTitle": "coderouter を読み込めませんでした",
+ "pageErrorBody": "アカウントサービスに接続できませんでした。しばらくしてから再試行してください。",
+ "providerColumn": "プロバイダー",
+ "labelColumn": "ラベル",
+ "statusColumn": "状態",
+ "actionsColumn": "操作",
+ "unlabeledAccount": "ラベルなし",
+ "kindAnthropicApiKey": "Anthropic APIキー",
+ "kindClaudeOauth": "Claude Code OAuth",
+ "kindBedrock": "Amazon Bedrock",
+ "kindCodex": "Codex",
+ "kindOpencode": "OpenCode",
+ "kindOpenAiApiKey": "OpenAI APIキー",
+ "kindUnknown": "不明なプロバイダー",
+ "stateActive": "有効",
+ "stateDisabled": "無効",
+ "stateUnhealthy": "要確認",
+ "coolingDown": "{until} までクールダウン中",
+ "lastFailure": "最後の失敗 {code}",
+ "lastUsed": "最終使用 {at}",
+ "neverUsed": "未使用",
+ "createdAt": "追加日 {at}",
+ "addTitle": "アカウントを追加",
+ "kindSelectorLabel": "アカウントの種類",
+ "labelField": "ラベル",
+ "labelPlaceholder": "任意。例: work",
+ "apiKeyField": "APIキー",
+ "oauthTokenField": "OAuthトークン",
+ "oauthHint": "自分のマシンで次のコマンドを実行して長期トークンを作成します:",
+ "regionField": "AWSリージョン",
+ "accessKeyIdField": "アクセスキーID",
+ "secretAccessKeyField": "シークレットアクセスキー",
+ "sessionTokenField": "セッショントークン",
+ "optionalPlaceholder": "任意",
+ "codexBody": "Codex はブラウザでサインインするため、ターミナルから追加します。このチームでサインインしてから次を実行してください:",
+ "opencodeBody": "OpenCode は coderouter からプロバイダー一覧を読み込みます。このチームでサインインしてから次を実行してください:",
+ "copyCommand": "コマンドをコピー",
+ "copied": "コピーしました",
+ "saveAction": "アカウントを追加",
+ "savingAction": "追加中",
+ "saveSuccess": "アカウントを追加しました。",
+ "saveError": "アカウントを追加できませんでした。",
+ "validationError": "認証情報の形式を確認して再試行してください。",
+ "teamAccessError": "このチームへのアクセス権がありません。",
+ "enableAction": "有効化",
+ "disableAction": "無効化",
+ "updateError": "アカウントを更新できませんでした。",
+ "removeAction": "削除",
+ "removingAction": "削除中",
+ "removeError": "アカウントを削除できませんでした。",
+ "removeConfirmTitle": "このアカウントを削除しますか?",
+ "removeClaudeConfirmBody": "このアカウントに固定されたマシンはチームの他のアカウントに移ります。最後のアカウントの場合、新しいアカウントを追加するまでこのチームからの Claude リクエストは失敗します。",
+ "removeSharedConfirmBody": "チーム全体からこのアカウントが削除されます。再追加には再度サインインが必要です。",
+ "cancelAction": "キャンセル"
+ },
"accountMenu": {
"label": "アカウントメニュー",
"settings": "設定",
@@ -244,6 +311,11 @@
"organizationSwitchError": "組織を切り替えられませんでした。もう一度お試しください。",
"signIn": "サインイン"
},
+ "teamSwitcher": {
+ "label": "チーム",
+ "team": "チーム",
+ "personal": "個人"
+ },
"billing": {
"eyebrow": "アカウント",
"title": "請求",
@@ -491,108 +563,6 @@
"seats": "{count} シート",
"truncated": "この一覧はソースごとに 5,000 行までに制限されており、一部の行は表示されていません。見つからないものは検索してください。"
}
- },
- "aiAccounts": {
- "metaTitle": "AIアカウント — cmux",
- "metaDescription": "cmuxのチームAIプロバイダーアカウントを管理します。",
- "section": "ダッシュボード",
- "title": "AIアカウント",
- "description": "このチームがcmux AIで使えるプロバイダーアカウントを管理します。",
- "personalTeam": "個人",
- "teamSwitcherLabel": "チーム",
- "notConfiguredTitle": "AIアカウント管理はまだ利用できません",
- "notConfiguredBody": "このデプロイではチームAIアカウントが有効になっていません。しばらくしてから再試行するか、サポートにお問い合わせください。",
- "migrationPendingTitle": "アカウントを一時的に利用できません",
- "migrationPendingBody": "共有アカウントは一時的に利用できません。しばらくしてからもう一度お試しください。",
- "loadErrorTitle": "アカウントを読み込めませんでした",
- "loadErrorBody": "アカウントサービスに接続できませんでした。しばらくしてから再試行してください。",
- "accountsTitle": "接続済みアカウント",
- "accountsCount": "{count}件のアカウント",
- "emptyTitle": "まだアカウントがありません",
- "emptyBody": "プロバイダーアカウントを追加すると、このチームで利用できます。",
- "providerColumn": "プロバイダー",
- "labelColumn": "ラベル",
- "createdColumn": "作成日",
- "actionsColumn": "操作",
- "unlabeledAccount": "ラベルなし",
- "unknownCreatedAt": "不明",
- "addAccountsTitle": "アカウントを追加",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "Anthropic APIキー",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "OpenAI APIキー",
- "providerUnknown": "不明なプロバイダー",
- "labelField": "ラベル",
- "labelPlaceholder": "チーム既定",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "APIキー",
- "addClaude": "Claudeを追加",
- "addAnthropic": "Anthropicを追加",
- "addCodex": "Codexを追加",
- "addOpenAi": "OpenAIを追加",
- "addSuccess": "アカウントを追加しました。",
- "addError": "アカウントを保存できませんでした。",
- "deleteError": "アカウントを削除できませんでした。",
- "jsonError": "有効なJSONオブジェクトを貼り付けてください。",
- "validationError": "アカウント情報を確認して再試行してください。",
- "teamAccessError": "このチームへのアクセス権がありません。",
- "deletingAction": "削除中",
- "deleteAction": "削除",
- "deleteConfirmTitle": "このアカウントを削除しますか?",
- "deleteConfirmBody": "チーム全体からこのプロバイダーアカウントが削除されます。再追加には元の認証情報が必要です。",
- "cancelAction": "キャンセル",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
- "claudeUpstream": {
- "title": "Claude アップストリームアカウント",
- "description": "このチームの cmux Cloud VM 内の Claude Code は、Anthropic Messages のトラフィックを coderouter 経由で送信します。Anthropic API キー、Claude Code OAuth トークン、Amazon Bedrock 認証情報を必要なだけ追加できます。各マシンは 1 つのアカウントに固定されるためプロンプトキャッシュが機能し続け、レート制限や認証情報の拒否が起きるとそのアカウントはクールダウンに入り、マシンは次のアカウントに移ります。",
- "emptyTitle": "Claude アップストリームアカウントがありません",
- "emptyBody": "アカウントを追加するまで、このチームの Cloud VM からの Claude リクエストは失敗します。",
- "accountsLabel": "{count} 件のアカウント",
- "loadErrorTitle": "Claudeアップストリームを読み込めませんでした",
- "loadErrorBody": "アップストリーム設定を読み取れませんでした。しばらくしてから再試行してください。",
- "kindSelectorLabel": "アップストリームの種類",
- "kindApiKey": "Anthropic APIキー",
- "kindOauth": "Claude Code OAuthトークン",
- "kindBedrock": "Amazon Bedrock",
- "labelField": "ラベル",
- "labelPlaceholder": "任意。例: work",
- "apiKeyField": "APIキー",
- "oauthTokenField": "OAuthトークン",
- "oauthHint": "自分のマシンで次のコマンドを実行して長期トークンを作成します:",
- "regionField": "AWSリージョン",
- "accessKeyIdField": "アクセスキーID",
- "secretAccessKeyField": "シークレットアクセスキー",
- "sessionTokenField": "セッショントークン",
- "optionalPlaceholder": "任意",
- "saveAction": "アカウントを追加",
- "savingAction": "追加中",
- "saveSuccess": "アカウントを追加しました。",
- "saveError": "アカウントを追加できませんでした。",
- "validationError": "認証情報の形式を確認して再試行してください。",
- "teamAccessError": "このチームへのアクセス権がありません。",
- "removeAction": "削除",
- "removingAction": "削除中",
- "removeError": "アカウントを削除できませんでした。",
- "removeConfirmTitle": "この Claude アップストリームアカウントを削除しますか?",
- "removeConfirmBody": "このアカウントに固定されたマシンはチームの他のアカウントに移ります。最後のアカウントの場合、新しいアカウントを追加するまでこのチームの Cloud VM からの Claude リクエストは失敗します。",
- "cancelAction": "キャンセル",
- "stateActive": "有効",
- "stateDisabled": "無効",
- "coolingDown": "{until} までクールダウン中",
- "lastFailure": "最後の失敗 {code}",
- "lastUsed": "最終使用 {at}",
- "neverUsed": "未使用",
- "enableAction": "有効にする",
- "disableAction": "無効にする",
- "updateError": "アカウントを更新できませんでした。"
}
},
"vault": {
diff --git a/web/messages/km.json b/web/messages/km.json
index b010d789a173..a40f2ccab88c 100644
--- a/web/messages/km.json
+++ b/web/messages/km.json
@@ -295,64 +295,6 @@
"organizationSwitchError": "មិនអាចប្ដូរអង្គការបានទេ។ សូមព្យាយាមម្តងទៀត។",
"signIn": "ចូល"
},
- "aiAccounts": {
- "metaTitle": "គណនី AI — cmux",
- "metaDescription": "គ្រប់គ្រងគណនីអ្នកផ្តល់សេវា AI របស់ក្រុមសម្រាប់ cmux។",
- "section": "ផ្ទាំងគ្រប់គ្រង",
- "title": "គណនី AI",
- "description": "គ្រប់គ្រងគណនីអ្នកផ្តល់សេវាដែលក្រុមនេះអាចប្រើជាមួយ cmux AI។",
- "personalTeam": "ផ្ទាល់ខ្លួន",
- "teamSwitcherLabel": "ក្រុម",
- "notConfiguredTitle": "ការគ្រប់គ្រងគណនី AI មិនទាន់អាចប្រើបានទេ",
- "notConfiguredBody": "គណនី AI របស់ក្រុមមិនត្រូវបានបើកសម្រាប់ការដំឡើងនេះទេ។ សូមព្យាយាមម្តងទៀតនៅពេលក្រោយ ឬទាក់ទងផ្នែកគាំទ្រ។",
- "loadErrorTitle": "មិនអាចផ្ទុកគណនីបានទេ",
- "loadErrorBody": "មិនអាចភ្ជាប់ទៅសេវាគណនីបានទេ។ សូមព្យាយាមម្តងទៀតបន្តិចទៀត។",
- "accountsTitle": "គណនីដែលបានភ្ជាប់",
- "accountsCount": "{count} គណនី",
- "emptyTitle": "មិនទាន់មានគណនីនៅឡើយ",
- "emptyBody": "បន្ថែមគណនីអ្នកផ្តល់សេវា ដើម្បីឱ្យក្រុមនេះអាចប្រើបាន។",
- "providerColumn": "អ្នកផ្តល់សេវា",
- "labelColumn": "ស្លាក",
- "createdColumn": "បានបង្កើត",
- "actionsColumn": "សកម្មភាព",
- "unlabeledAccount": "គ្មានស្លាក",
- "unknownCreatedAt": "មិនស្គាល់",
- "addAccountsTitle": "បន្ថែមគណនី",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "សោ API របស់ Anthropic",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "សោ API របស់ OpenAI",
- "providerUnknown": "អ្នកផ្តល់សេវាមិនស្គាល់",
- "labelField": "ស្លាក",
- "labelPlaceholder": "លំនាំដើមរបស់ក្រុម",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "សោ API",
- "addClaude": "បន្ថែម Claude",
- "addAnthropic": "បន្ថែម Anthropic",
- "addCodex": "បន្ថែម Codex",
- "addOpenAi": "បន្ថែម OpenAI",
- "addSuccess": "បានបន្ថែមគណនី។",
- "addError": "មិនអាចរក្សាទុកគណនីបានទេ។",
- "deleteError": "មិនអាចលុបគណនីបានទេ។",
- "jsonError": "សូមបិទភ្ជាប់វត្ថុ JSON ដែលត្រឹមត្រូវ។",
- "validationError": "សូមពិនិត្យព័ត៌មានគណនី រួចព្យាយាមម្តងទៀត។",
- "teamAccessError": "អ្នកមិនមានសិទ្ធិចូលប្រើក្រុមនេះទេ។",
- "deletingAction": "កំពុងលុប",
- "deleteAction": "លុប",
- "deleteConfirmTitle": "លុបគណនីនេះ?",
- "deleteConfirmBody": "គណនីអ្នកផ្តល់សេវានឹងត្រូវបានដកចេញសម្រាប់ក្រុមទាំងមូល។ ការបន្ថែមឡើងវិញត្រូវការព័ត៌មានសម្គាល់ដើម។",
- "cancelAction": "បោះបង់",
- "claudeJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"expiresAt\":1770000000000}",
- "codexJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"idToken\":\"...\",\"accountID\":\"...\"}",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
"billing": {
"plan": {
"heading": "គម្រោងផ្ទាល់ខ្លួនរបស់អ្នក",
diff --git a/web/messages/ko.json b/web/messages/ko.json
index 30d829092f36..dd66a7f31687 100644
--- a/web/messages/ko.json
+++ b/web/messages/ko.json
@@ -295,64 +295,6 @@
"organizationSwitchError": "조직을 전환할 수 없습니다. 다시 시도하세요.",
"signIn": "로그인"
},
- "aiAccounts": {
- "metaTitle": "AI 계정 — cmux",
- "metaDescription": "cmux 팀 AI 제공업체 계정을 관리합니다.",
- "section": "대시보드",
- "title": "AI 계정",
- "description": "이 팀이 cmux AI에서 사용할 수 있는 제공업체 계정을 관리합니다.",
- "personalTeam": "개인",
- "teamSwitcherLabel": "팀",
- "notConfiguredTitle": "AI 계정 관리를 아직 사용할 수 없습니다",
- "notConfiguredBody": "이 배포에서는 팀 AI 계정이 활성화되어 있지 않습니다. 나중에 다시 시도하거나 지원팀에 문의해 주세요.",
- "loadErrorTitle": "계정을 불러오지 못했습니다",
- "loadErrorBody": "계정 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.",
- "accountsTitle": "연결된 계정",
- "accountsCount": "계정 {count}개",
- "emptyTitle": "아직 계정이 없습니다",
- "emptyBody": "제공업체 계정을 추가하면 이 팀에서 사용할 수 있습니다.",
- "providerColumn": "제공업체",
- "labelColumn": "라벨",
- "createdColumn": "생성일",
- "actionsColumn": "작업",
- "unlabeledAccount": "라벨 없음",
- "unknownCreatedAt": "알 수 없음",
- "addAccountsTitle": "계정 추가",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "Anthropic API 키",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "OpenAI API 키",
- "providerUnknown": "알 수 없는 제공업체",
- "labelField": "라벨",
- "labelPlaceholder": "팀 기본값",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "API 키",
- "addClaude": "Claude 추가",
- "addAnthropic": "Anthropic 추가",
- "addCodex": "Codex 추가",
- "addOpenAi": "OpenAI 추가",
- "addSuccess": "계정을 추가했습니다.",
- "addError": "계정을 저장하지 못했습니다.",
- "deleteError": "계정을 삭제하지 못했습니다.",
- "jsonError": "유효한 JSON 객체를 붙여넣어 주세요.",
- "validationError": "계정 정보를 확인한 뒤 다시 시도해 주세요.",
- "teamAccessError": "이 팀에 접근할 권한이 없습니다.",
- "deletingAction": "삭제 중",
- "deleteAction": "삭제",
- "deleteConfirmTitle": "이 계정을 삭제할까요?",
- "deleteConfirmBody": "팀 전체에서 이 제공업체 계정이 제거됩니다. 다시 추가하려면 원래 인증 정보가 필요합니다.",
- "cancelAction": "취소",
- "claudeJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"expiresAt\":1770000000000}",
- "codexJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"idToken\":\"...\",\"accountID\":\"...\"}",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
"billing": {
"plan": {
"heading": "내 개인 플랜",
diff --git a/web/messages/no.json b/web/messages/no.json
index fa4735502c42..db95d025bcfd 100644
--- a/web/messages/no.json
+++ b/web/messages/no.json
@@ -295,64 +295,6 @@
"organizationSwitchError": "Kunne ikke bytte organisasjon. Prøv igjen.",
"signIn": "Logg inn"
},
- "aiAccounts": {
- "metaTitle": "AI-kontoer — cmux",
- "metaDescription": "Administrer teamets AI-leverandørkontoer for cmux.",
- "section": "Dashbord",
- "title": "AI-kontoer",
- "description": "Administrer leverandørkontoene dette teamet kan bruke med cmux AI.",
- "personalTeam": "Personlig",
- "teamSwitcherLabel": "Team",
- "notConfiguredTitle": "AI-kontoadministrasjon er ikke tilgjengelig ennå",
- "notConfiguredBody": "Team-AI-kontoer er ikke aktivert for denne installasjonen. Prøv igjen senere, eller kontakt support.",
- "loadErrorTitle": "Kunne ikke laste kontoer",
- "loadErrorBody": "Kontotjenesten var ikke tilgjengelig. Prøv igjen om litt.",
- "accountsTitle": "Tilkoblede kontoer",
- "accountsCount": "{count, plural, one {# konto} other {# kontoer}}",
- "emptyTitle": "Ingen kontoer ennå",
- "emptyBody": "Legg til en leverandørkonto for å gjøre den tilgjengelig for dette teamet.",
- "providerColumn": "Leverandør",
- "labelColumn": "Etikett",
- "createdColumn": "Opprettet",
- "actionsColumn": "Handlinger",
- "unlabeledAccount": "Uten etikett",
- "unknownCreatedAt": "Ukjent",
- "addAccountsTitle": "Legg til konto",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "Anthropic API-nøkkel",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "OpenAI API-nøkkel",
- "providerUnknown": "Ukjent leverandør",
- "labelField": "Etikett",
- "labelPlaceholder": "Teamstandard",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "API-nøkkel",
- "addClaude": "Legg til Claude",
- "addAnthropic": "Legg til Anthropic",
- "addCodex": "Legg til Codex",
- "addOpenAi": "Legg til OpenAI",
- "addSuccess": "Konto lagt til.",
- "addError": "Kontoen kunne ikke lagres.",
- "deleteError": "Kontoen kunne ikke slettes.",
- "jsonError": "Lim inn et gyldig JSON-objekt.",
- "validationError": "Sjekk kontoopplysningene og prøv igjen.",
- "teamAccessError": "Du har ikke tilgang til dette teamet.",
- "deletingAction": "Sletter",
- "deleteAction": "Slett",
- "deleteConfirmTitle": "Slette denne kontoen?",
- "deleteConfirmBody": "Leverandørkontoen fjernes for hele teamet. Du trenger de opprinnelige påloggingsopplysningene for å legge den til igjen.",
- "cancelAction": "Avbryt",
- "claudeJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"expiresAt\":1770000000000}",
- "codexJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"idToken\":\"...\",\"accountID\":\"...\"}",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
"billing": {
"plan": {
"heading": "Ditt personlige abonnement",
diff --git a/web/messages/pl.json b/web/messages/pl.json
index 6535bacd9606..078313d4ff76 100644
--- a/web/messages/pl.json
+++ b/web/messages/pl.json
@@ -295,64 +295,6 @@
"organizationSwitchError": "Nie udało się zmienić organizacji. Spróbuj ponownie.",
"signIn": "Zaloguj się"
},
- "aiAccounts": {
- "metaTitle": "Konta AI — cmux",
- "metaDescription": "Zarządzaj kontami dostawców AI zespołu w cmux.",
- "section": "Panel",
- "title": "Konta AI",
- "description": "Zarządzaj kontami dostawców, z których ten zespół może korzystać w cmux AI.",
- "personalTeam": "Osobisty",
- "teamSwitcherLabel": "Zespół",
- "notConfiguredTitle": "Zarządzanie kontami AI nie jest jeszcze dostępne",
- "notConfiguredBody": "Zespołowe konta AI nie są włączone w tym wdrożeniu. Spróbuj później lub skontaktuj się z pomocą techniczną.",
- "loadErrorTitle": "Nie udało się wczytać kont",
- "loadErrorBody": "Nie można połączyć się z usługą kont. Spróbuj ponownie za chwilę.",
- "accountsTitle": "Połączone konta",
- "accountsCount": "{count, plural, one {# konto} few {# konta} many {# kont} other {# konta}}",
- "emptyTitle": "Nie ma jeszcze kont",
- "emptyBody": "Dodaj konto dostawcy, aby udostępnić je temu zespołowi.",
- "providerColumn": "Dostawca",
- "labelColumn": "Etykieta",
- "createdColumn": "Utworzono",
- "actionsColumn": "Akcje",
- "unlabeledAccount": "Bez etykiety",
- "unknownCreatedAt": "Nieznana",
- "addAccountsTitle": "Dodaj konto",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "Klucz API Anthropic",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "Klucz API OpenAI",
- "providerUnknown": "Nieznany dostawca",
- "labelField": "Etykieta",
- "labelPlaceholder": "Domyślne zespołu",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "Klucz API",
- "addClaude": "Dodaj Claude",
- "addAnthropic": "Dodaj Anthropic",
- "addCodex": "Dodaj Codex",
- "addOpenAi": "Dodaj OpenAI",
- "addSuccess": "Dodano konto.",
- "addError": "Nie udało się zapisać konta.",
- "deleteError": "Nie udało się usunąć konta.",
- "jsonError": "Wklej prawidłowy obiekt JSON.",
- "validationError": "Sprawdź dane konta i spróbuj ponownie.",
- "teamAccessError": "Nie masz dostępu do tego zespołu.",
- "deletingAction": "Usuwanie",
- "deleteAction": "Usuń",
- "deleteConfirmTitle": "Usunąć to konto?",
- "deleteConfirmBody": "Konto dostawcy zostanie usunięte dla całego zespołu. Aby dodać je ponownie, potrzebne będą oryginalne dane logowania.",
- "cancelAction": "Anuluj",
- "claudeJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"expiresAt\":1770000000000}",
- "codexJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"idToken\":\"...\",\"accountID\":\"...\"}",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
"billing": {
"plan": {
"heading": "Twój plan osobisty",
diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json
index cf73834b094b..62e3fca3433f 100644
--- a/web/messages/pt-BR.json
+++ b/web/messages/pt-BR.json
@@ -295,64 +295,6 @@
"organizationSwitchError": "Não foi possível trocar de organização. Tente novamente.",
"signIn": "Entrar"
},
- "aiAccounts": {
- "metaTitle": "Contas de IA — cmux",
- "metaDescription": "Gerencie as contas de provedores de IA da equipe no cmux.",
- "section": "Painel",
- "title": "Contas de IA",
- "description": "Gerencie as contas de provedores que esta equipe pode usar com o cmux AI.",
- "personalTeam": "Pessoal",
- "teamSwitcherLabel": "Equipe",
- "notConfiguredTitle": "O gerenciamento de contas de IA ainda não está disponível",
- "notConfiguredBody": "As contas de IA de equipe não estão habilitadas nesta implantação. Tente mais tarde ou fale com o suporte.",
- "loadErrorTitle": "Não foi possível carregar as contas",
- "loadErrorBody": "Não foi possível acessar o serviço de contas. Tente novamente em instantes.",
- "accountsTitle": "Contas conectadas",
- "accountsCount": "{count, plural, one {# conta} other {# contas}}",
- "emptyTitle": "Nenhuma conta ainda",
- "emptyBody": "Adicione uma conta de provedor para disponibilizá-la a esta equipe.",
- "providerColumn": "Provedor",
- "labelColumn": "Rótulo",
- "createdColumn": "Criada",
- "actionsColumn": "Ações",
- "unlabeledAccount": "Sem rótulo",
- "unknownCreatedAt": "Desconhecida",
- "addAccountsTitle": "Adicionar conta",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "Chave de API da Anthropic",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "Chave de API da OpenAI",
- "providerUnknown": "Provedor desconhecido",
- "labelField": "Rótulo",
- "labelPlaceholder": "Padrão da equipe",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "Chave de API",
- "addClaude": "Adicionar Claude",
- "addAnthropic": "Adicionar Anthropic",
- "addCodex": "Adicionar Codex",
- "addOpenAi": "Adicionar OpenAI",
- "addSuccess": "Conta adicionada.",
- "addError": "Não foi possível salvar a conta.",
- "deleteError": "Não foi possível excluir a conta.",
- "jsonError": "Cole um objeto JSON válido.",
- "validationError": "Confira os dados da conta e tente novamente.",
- "teamAccessError": "Você não tem acesso a esta equipe.",
- "deletingAction": "Excluindo",
- "deleteAction": "Excluir",
- "deleteConfirmTitle": "Excluir esta conta?",
- "deleteConfirmBody": "A conta do provedor será removida para toda a equipe. Para adicioná-la de novo, você precisará das credenciais originais.",
- "cancelAction": "Cancelar",
- "claudeJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"expiresAt\":1770000000000}",
- "codexJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"idToken\":\"...\",\"accountID\":\"...\"}",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
"billing": {
"plan": {
"heading": "Seu plano pessoal",
diff --git a/web/messages/ru.json b/web/messages/ru.json
index cb8367df86f0..2be17d4f1fe0 100644
--- a/web/messages/ru.json
+++ b/web/messages/ru.json
@@ -295,64 +295,6 @@
"organizationSwitchError": "Не удалось сменить организацию. Повторите попытку.",
"signIn": "Войти"
},
- "aiAccounts": {
- "metaTitle": "Аккаунты ИИ — cmux",
- "metaDescription": "Управляйте аккаунтами ИИ-провайдеров команды в cmux.",
- "section": "Панель",
- "title": "Аккаунты ИИ",
- "description": "Управляйте аккаунтами провайдеров, которые эта команда может использовать в cmux AI.",
- "personalTeam": "Личный",
- "teamSwitcherLabel": "Команда",
- "notConfiguredTitle": "Управление аккаунтами ИИ пока недоступно",
- "notConfiguredBody": "Командные аккаунты ИИ не включены для этого развертывания. Повторите попытку позже или обратитесь в поддержку.",
- "loadErrorTitle": "Не удалось загрузить аккаунты",
- "loadErrorBody": "Сервис аккаунтов недоступен. Повторите попытку чуть позже.",
- "accountsTitle": "Подключенные аккаунты",
- "accountsCount": "{count, plural, one {# аккаунт} few {# аккаунта} many {# аккаунтов} other {# аккаунта}}",
- "emptyTitle": "Аккаунтов пока нет",
- "emptyBody": "Добавьте аккаунт провайдера, чтобы команда могла им пользоваться.",
- "providerColumn": "Провайдер",
- "labelColumn": "Метка",
- "createdColumn": "Создан",
- "actionsColumn": "Действия",
- "unlabeledAccount": "Без метки",
- "unknownCreatedAt": "Неизвестно",
- "addAccountsTitle": "Добавить аккаунт",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "API-ключ Anthropic",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "API-ключ OpenAI",
- "providerUnknown": "Неизвестный провайдер",
- "labelField": "Метка",
- "labelPlaceholder": "По умолчанию для команды",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "API-ключ",
- "addClaude": "Добавить Claude",
- "addAnthropic": "Добавить Anthropic",
- "addCodex": "Добавить Codex",
- "addOpenAi": "Добавить OpenAI",
- "addSuccess": "Аккаунт добавлен.",
- "addError": "Не удалось сохранить аккаунт.",
- "deleteError": "Не удалось удалить аккаунт.",
- "jsonError": "Вставьте корректный JSON-объект.",
- "validationError": "Проверьте данные аккаунта и повторите попытку.",
- "teamAccessError": "У вас нет доступа к этой команде.",
- "deletingAction": "Удаление",
- "deleteAction": "Удалить",
- "deleteConfirmTitle": "Удалить этот аккаунт?",
- "deleteConfirmBody": "Аккаунт провайдера будет удален для всей команды. Чтобы добавить его снова, понадобятся исходные учетные данные.",
- "cancelAction": "Отмена",
- "claudeJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"expiresAt\":1770000000000}",
- "codexJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"idToken\":\"...\",\"accountID\":\"...\"}",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
"billing": {
"plan": {
"heading": "Ваш личный план",
diff --git a/web/messages/th.json b/web/messages/th.json
index 20f0f0b1f180..d219c7e5a0d9 100644
--- a/web/messages/th.json
+++ b/web/messages/th.json
@@ -295,64 +295,6 @@
"organizationSwitchError": "เปลี่ยนองค์กรไม่ได้ โปรดลองอีกครั้ง",
"signIn": "เข้าสู่ระบบ"
},
- "aiAccounts": {
- "metaTitle": "บัญชี AI — cmux",
- "metaDescription": "จัดการบัญชีผู้ให้บริการ AI ของทีมสำหรับ cmux",
- "section": "แดชบอร์ด",
- "title": "บัญชี AI",
- "description": "จัดการบัญชีผู้ให้บริการที่ทีมนี้ใช้กับ cmux AI ได้",
- "personalTeam": "ส่วนตัว",
- "teamSwitcherLabel": "ทีม",
- "notConfiguredTitle": "ยังใช้การจัดการบัญชี AI ไม่ได้",
- "notConfiguredBody": "การติดตั้งนี้ยังไม่เปิดใช้บัญชี AI ของทีม ลองใหม่ภายหลังหรือติดต่อฝ่ายสนับสนุน",
- "loadErrorTitle": "โหลดบัญชีไม่สำเร็จ",
- "loadErrorBody": "เชื่อมต่อบริการบัญชีไม่ได้ ลองใหม่อีกสักครู่",
- "accountsTitle": "บัญชีที่เชื่อมต่อแล้ว",
- "accountsCount": "{count} บัญชี",
- "emptyTitle": "ยังไม่มีบัญชี",
- "emptyBody": "เพิ่มบัญชีผู้ให้บริการเพื่อให้ทีมนี้ใช้งานได้",
- "providerColumn": "ผู้ให้บริการ",
- "labelColumn": "ป้ายกำกับ",
- "createdColumn": "สร้างเมื่อ",
- "actionsColumn": "การดำเนินการ",
- "unlabeledAccount": "ไม่มีป้ายกำกับ",
- "unknownCreatedAt": "ไม่ทราบ",
- "addAccountsTitle": "เพิ่มบัญชี",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "คีย์ API ของ Anthropic",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "คีย์ API ของ OpenAI",
- "providerUnknown": "ผู้ให้บริการที่ไม่รู้จัก",
- "labelField": "ป้ายกำกับ",
- "labelPlaceholder": "ค่าเริ่มต้นของทีม",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "คีย์ API",
- "addClaude": "เพิ่ม Claude",
- "addAnthropic": "เพิ่ม Anthropic",
- "addCodex": "เพิ่ม Codex",
- "addOpenAi": "เพิ่ม OpenAI",
- "addSuccess": "เพิ่มบัญชีแล้ว",
- "addError": "บันทึกบัญชีไม่สำเร็จ",
- "deleteError": "ลบบัญชีไม่สำเร็จ",
- "jsonError": "วางออบเจ็กต์ JSON ที่ถูกต้อง",
- "validationError": "ตรวจสอบข้อมูลบัญชีแล้วลองใหม่",
- "teamAccessError": "คุณไม่มีสิทธิ์เข้าถึงทีมนี้",
- "deletingAction": "กำลังลบ",
- "deleteAction": "ลบ",
- "deleteConfirmTitle": "ลบบัญชีนี้หรือไม่",
- "deleteConfirmBody": "บัญชีผู้ให้บริการจะถูกลบออกจากทั้งทีม หากต้องการเพิ่มใหม่จะต้องใช้ข้อมูลรับรองเดิม",
- "cancelAction": "ยกเลิก",
- "claudeJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"expiresAt\":1770000000000}",
- "codexJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"idToken\":\"...\",\"accountID\":\"...\"}",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
"billing": {
"plan": {
"heading": "แผนส่วนตัวของคุณ",
diff --git a/web/messages/tr.json b/web/messages/tr.json
index 65dc506ed838..23ade8056bc0 100644
--- a/web/messages/tr.json
+++ b/web/messages/tr.json
@@ -295,64 +295,6 @@
"organizationSwitchError": "Kuruluş değiştirilemedi. Tekrar deneyin.",
"signIn": "Giriş yap"
},
- "aiAccounts": {
- "metaTitle": "Yapay zeka hesapları — cmux",
- "metaDescription": "cmux için ekibin yapay zeka sağlayıcı hesaplarını yönet.",
- "section": "Panel",
- "title": "Yapay zeka hesapları",
- "description": "Bu ekibin cmux AI ile kullanabileceği sağlayıcı hesaplarını yönet.",
- "personalTeam": "Kişisel",
- "teamSwitcherLabel": "Ekip",
- "notConfiguredTitle": "Yapay zeka hesap yönetimi henüz kullanılamıyor",
- "notConfiguredBody": "Bu dağıtımda ekip yapay zeka hesapları etkin değil. Daha sonra tekrar dene veya destekle iletişime geç.",
- "loadErrorTitle": "Hesaplar yüklenemedi",
- "loadErrorBody": "Hesap hizmetine ulaşılamadı. Birazdan tekrar dene.",
- "accountsTitle": "Bağlı hesaplar",
- "accountsCount": "{count} hesap",
- "emptyTitle": "Henüz hesap yok",
- "emptyBody": "Bu ekibin kullanabilmesi için bir sağlayıcı hesabı ekle.",
- "providerColumn": "Sağlayıcı",
- "labelColumn": "Etiket",
- "createdColumn": "Oluşturulma",
- "actionsColumn": "İşlemler",
- "unlabeledAccount": "Etiketsiz",
- "unknownCreatedAt": "Bilinmiyor",
- "addAccountsTitle": "Hesap ekle",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "Anthropic API anahtarı",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "OpenAI API anahtarı",
- "providerUnknown": "Bilinmeyen sağlayıcı",
- "labelField": "Etiket",
- "labelPlaceholder": "Ekip varsayılanı",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "API anahtarı",
- "addClaude": "Claude ekle",
- "addAnthropic": "Anthropic ekle",
- "addCodex": "Codex ekle",
- "addOpenAi": "OpenAI ekle",
- "addSuccess": "Hesap eklendi.",
- "addError": "Hesap kaydedilemedi.",
- "deleteError": "Hesap silinemedi.",
- "jsonError": "Geçerli bir JSON nesnesi yapıştır.",
- "validationError": "Hesap bilgilerini kontrol edip tekrar dene.",
- "teamAccessError": "Bu ekibe erişimin yok.",
- "deletingAction": "Siliniyor",
- "deleteAction": "Sil",
- "deleteConfirmTitle": "Bu hesap silinsin mi?",
- "deleteConfirmBody": "Sağlayıcı hesabı tüm ekip için kaldırılır. Yeniden eklemek için orijinal kimlik bilgileri gerekir.",
- "cancelAction": "İptal",
- "claudeJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"expiresAt\":1770000000000}",
- "codexJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"idToken\":\"...\",\"accountID\":\"...\"}",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
"billing": {
"plan": {
"heading": "Kişisel planınız",
diff --git a/web/messages/uk.json b/web/messages/uk.json
index 98a81c5d413f..7936b0f69a5b 100644
--- a/web/messages/uk.json
+++ b/web/messages/uk.json
@@ -295,64 +295,6 @@
"organizationSwitchError": "Не вдалося змінити організацію. Спробуйте ще раз.",
"signIn": "Увійти"
},
- "aiAccounts": {
- "metaTitle": "Акаунти ШІ — cmux",
- "metaDescription": "Керуйте акаунтами постачальників ШІ команди в cmux.",
- "section": "Панель",
- "title": "Акаунти ШІ",
- "description": "Керуйте акаунтами постачальників, які ця команда може використовувати з cmux AI.",
- "personalTeam": "Особистий",
- "teamSwitcherLabel": "Команда",
- "notConfiguredTitle": "Керування акаунтами ШІ поки недоступне",
- "notConfiguredBody": "Командні акаунти ШІ не ввімкнено для цього розгортання. Спробуйте пізніше або зверніться до підтримки.",
- "loadErrorTitle": "Не вдалося завантажити акаунти",
- "loadErrorBody": "Сервіс акаунтів недоступний. Спробуйте ще раз за мить.",
- "accountsTitle": "Підключені акаунти",
- "accountsCount": "{count, plural, one {# акаунт} few {# акаунти} many {# акаунтів} other {# акаунта}}",
- "emptyTitle": "Акаунтів поки немає",
- "emptyBody": "Додайте акаунт постачальника, щоб ця команда могла ним користуватися.",
- "providerColumn": "Постачальник",
- "labelColumn": "Мітка",
- "createdColumn": "Створено",
- "actionsColumn": "Дії",
- "unlabeledAccount": "Без мітки",
- "unknownCreatedAt": "Невідомо",
- "addAccountsTitle": "Додати акаунт",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "API-ключ Anthropic",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "API-ключ OpenAI",
- "providerUnknown": "Невідомий постачальник",
- "labelField": "Мітка",
- "labelPlaceholder": "Стандартний для команди",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "API-ключ",
- "addClaude": "Додати Claude",
- "addAnthropic": "Додати Anthropic",
- "addCodex": "Додати Codex",
- "addOpenAi": "Додати OpenAI",
- "addSuccess": "Акаунт додано.",
- "addError": "Не вдалося зберегти акаунт.",
- "deleteError": "Не вдалося видалити акаунт.",
- "jsonError": "Вставте коректний JSON-об'єкт.",
- "validationError": "Перевірте дані акаунта та спробуйте ще раз.",
- "teamAccessError": "Ви не маєте доступу до цієї команди.",
- "deletingAction": "Видалення",
- "deleteAction": "Видалити",
- "deleteConfirmTitle": "Видалити цей акаунт?",
- "deleteConfirmBody": "Акаунт постачальника буде видалено для всієї команди. Щоб додати його знову, знадобляться початкові облікові дані.",
- "cancelAction": "Скасувати",
- "claudeJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"expiresAt\":1770000000000}",
- "codexJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"idToken\":\"...\",\"accountID\":\"...\"}",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
"billing": {
"plan": {
"heading": "Ваш особистий план",
diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json
index e0c5dbe53c07..35aa2a9ec51b 100644
--- a/web/messages/zh-CN.json
+++ b/web/messages/zh-CN.json
@@ -295,64 +295,6 @@
"organizationSwitchError": "无法切换组织,请重试。",
"signIn": "登录"
},
- "aiAccounts": {
- "metaTitle": "AI 账户 — cmux",
- "metaDescription": "管理 cmux 的团队 AI 服务商账户。",
- "section": "控制台",
- "title": "AI 账户",
- "description": "管理此团队可用于 cmux AI 的服务商账户。",
- "personalTeam": "个人",
- "teamSwitcherLabel": "团队",
- "notConfiguredTitle": "AI 账户管理暂不可用",
- "notConfiguredBody": "此部署尚未启用团队 AI 账户。请稍后再试或联系支持。",
- "loadErrorTitle": "无法加载账户",
- "loadErrorBody": "无法连接账户服务,请稍后重试。",
- "accountsTitle": "已连接的账户",
- "accountsCount": "{count} 个账户",
- "emptyTitle": "还没有账户",
- "emptyBody": "添加服务商账户后,此团队即可使用。",
- "providerColumn": "服务商",
- "labelColumn": "标签",
- "createdColumn": "创建时间",
- "actionsColumn": "操作",
- "unlabeledAccount": "无标签",
- "unknownCreatedAt": "未知",
- "addAccountsTitle": "添加账户",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "Anthropic API 密钥",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "OpenAI API 密钥",
- "providerUnknown": "未知服务商",
- "labelField": "标签",
- "labelPlaceholder": "团队默认",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "API 密钥",
- "addClaude": "添加 Claude",
- "addAnthropic": "添加 Anthropic",
- "addCodex": "添加 Codex",
- "addOpenAi": "添加 OpenAI",
- "addSuccess": "账户已添加。",
- "addError": "账户保存失败。",
- "deleteError": "账户删除失败。",
- "jsonError": "请粘贴有效的 JSON 对象。",
- "validationError": "请检查账户信息后重试。",
- "teamAccessError": "你没有访问此团队的权限。",
- "deletingAction": "正在删除",
- "deleteAction": "删除",
- "deleteConfirmTitle": "删除此账户?",
- "deleteConfirmBody": "将为整个团队移除该服务商账户。重新添加需要原始凭据。",
- "cancelAction": "取消",
- "claudeJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"expiresAt\":1770000000000}",
- "codexJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"idToken\":\"...\",\"accountID\":\"...\"}",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
"billing": {
"plan": {
"heading": "你的个人方案",
diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json
index 2519d686dc57..e40cb02862e2 100644
--- a/web/messages/zh-TW.json
+++ b/web/messages/zh-TW.json
@@ -295,64 +295,6 @@
"organizationSwitchError": "無法切換組織,請再試一次。",
"signIn": "登入"
},
- "aiAccounts": {
- "metaTitle": "AI 帳戶 — cmux",
- "metaDescription": "管理 cmux 的團隊 AI 供應商帳戶。",
- "section": "主控台",
- "title": "AI 帳戶",
- "description": "管理此團隊可用於 cmux AI 的供應商帳戶。",
- "personalTeam": "個人",
- "teamSwitcherLabel": "團隊",
- "notConfiguredTitle": "AI 帳戶管理暫不可用",
- "notConfiguredBody": "此部署尚未啟用團隊 AI 帳戶。請稍後再試或聯絡支援。",
- "loadErrorTitle": "無法載入帳戶",
- "loadErrorBody": "無法連線帳戶服務,請稍後再試。",
- "accountsTitle": "已連接的帳戶",
- "accountsCount": "{count} 個帳戶",
- "emptyTitle": "還沒有帳戶",
- "emptyBody": "新增供應商帳戶後,此團隊即可使用。",
- "providerColumn": "供應商",
- "labelColumn": "標籤",
- "createdColumn": "建立時間",
- "actionsColumn": "操作",
- "unlabeledAccount": "無標籤",
- "unknownCreatedAt": "未知",
- "addAccountsTitle": "新增帳戶",
- "providerClaude": "Claude OAuth",
- "providerAnthropicApiKey": "Anthropic API 金鑰",
- "providerCodex": "Codex OAuth",
- "providerOpenAiApiKey": "OpenAI API 金鑰",
- "providerUnknown": "未知供應商",
- "labelField": "標籤",
- "labelPlaceholder": "團隊預設",
- "oauthJsonField": "OAuth JSON",
- "apiKeyField": "API 金鑰",
- "addClaude": "新增 Claude",
- "addAnthropic": "新增 Anthropic",
- "addCodex": "新增 Codex",
- "addOpenAi": "新增 OpenAI",
- "addSuccess": "帳戶已新增。",
- "addError": "帳戶儲存失敗。",
- "deleteError": "帳戶刪除失敗。",
- "jsonError": "請貼上有效的 JSON 物件。",
- "validationError": "請檢查帳戶資訊後再試一次。",
- "teamAccessError": "你沒有存取此團隊的權限。",
- "deletingAction": "刪除中",
- "deleteAction": "刪除",
- "deleteConfirmTitle": "刪除此帳戶?",
- "deleteConfirmBody": "將為整個團隊移除該供應商帳戶。重新新增需要原始憑證。",
- "cancelAction": "取消",
- "claudeJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"expiresAt\":1770000000000}",
- "codexJsonPlaceholder": "{\"accessToken\":\"...\",\"refreshToken\":\"...\",\"idToken\":\"...\",\"accountID\":\"...\"}",
- "anthropicKeyPlaceholder": "sk-ant-...",
- "openAiKeyPlaceholder": "sk-...",
- "cliAddBody": "Add provider accounts securely from your terminal. Sign in with your team, then run one of these commands:",
- "codexTool": "Codex",
- "codexCommand": "Copy codex command",
- "opencodeTool": "OpenCode",
- "opencodeCommand": "Copy opencode command",
- "copied": "Copied"
- },
"billing": {
"plan": {
"heading": "你的個人方案",
diff --git a/web/tests/coderouter-accounts.test.tsx b/web/tests/coderouter-accounts.test.tsx
new file mode 100644
index 000000000000..0422a89d4312
--- /dev/null
+++ b/web/tests/coderouter-accounts.test.tsx
@@ -0,0 +1,177 @@
+import { describe, expect, mock, test } from "bun:test";
+import { renderToStaticMarkup } from "react-dom/server";
+import type React from "react";
+import enMessages from "../messages/en.json";
+
+const routerRefresh = mock(() => undefined);
+
+mock.module("next-intl", () => ({
+ useTranslations: (namespace: string) => translator(namespace),
+ useFormatter: () => ({
+ dateTime: (date: Date) => date.toISOString().slice(0, 10),
+ relativeTime: () => "2 hours ago",
+ }),
+ useNow: () => new Date("2026-09-07T12:00:00.000Z"),
+}));
+
+mock.module("../i18n/navigation", () => ({
+ useRouter: () => ({ refresh: routerRefresh }),
+}));
+
+mock.module("@base-ui-components/react/dialog", () => ({
+ Dialog: {
+ Root: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
+ open ? {children}
: null,
+ Portal: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ Backdrop: () => null,
+ Viewport: ({ children }: { children: React.ReactNode }) => {children}
,
+ Popup: ({ children }: { children: React.ReactNode }) => {children}
,
+ Title: ({ children }: { children: React.ReactNode }) => {children}
,
+ Description: ({ children }: { children: React.ReactNode }) => {children}
,
+ Close: ({ children }: { children: React.ReactNode }) => ,
+ },
+}));
+
+const { CoderouterAccountsSection } = await import(
+ "../app/[locale]/dashboard/components/coderouter-accounts"
+);
+
+const claudeAccount = {
+ id: "claude-1",
+ kind: "anthropic_oauth" as const,
+ label: "work",
+ identifier: "sk-ant-oat01-…a1b2",
+ region: null,
+ modelIds: {},
+ state: "active" as const,
+ cooldownUntil: null,
+ lastFailureCode: null,
+ lastUsedAt: "2026-09-07T10:00:00.000Z",
+ createdAt: "2026-09-01T00:00:00.000Z",
+ updatedAt: "2026-09-01T00:00:00.000Z",
+};
+
+const codexAccount = {
+ id: "codex-1",
+ kind: "codex",
+ label: "shared codex",
+ createdAt: "2026-08-20T00:00:00.000Z",
+ health: { ok: true },
+};
+
+describe("coderouter accounts section", () => {
+ test("lists Claude upstream and shared Codex accounts in one table", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain("2 accounts");
+ expect(html.match(/]*>/g)).toHaveLength(1);
+ expect(html).toContain("Claude Code OAuth");
+ expect(html).toContain("sk-ant-oat01-…a1b2");
+ expect(html).toContain("Codex");
+ expect(html).toContain("shared codex");
+ expect(html).toContain("last used 2 hours ago");
+ expect(html).toContain("added 2026-08-20");
+ // Provider rows are text only.
+ expect(html).not.toContain("
-
+
-
{copy.estimate.replace("{version}", metrics.rateCardVersion)}
+ {unpricedShare > 0
+ ? ` ${copy.unpriced.replace("{share}", percent.format(unpricedShare))}`
+ : ""}
);
@@ -409,12 +417,12 @@ function metricsCopy(locale: string) {
outputTokens: "出力トークン",
tokens: "合計トークン",
apiEquivalent: "API換算額",
- pricingCoverage: "価格対応率",
+ unpriced: "全トークンの {share} は価格が不明なモデルのもので、換算額に含まれていません。",
chartLabel: "日別のCodeRouterトークン使用量",
privacy:
"プロンプト、出力、アカウントラベル、メンバーIDは記録・表示しません。",
estimate:
- "API換算額は公開定価(レート表 {version})による推定で、実際の請求額ではありません。価格不明のモデルは換算額から除外されます。",
+ "API換算額は、同じトークンを公開定価(レート表 {version})で API 利用した場合の推定額で、実際の請求額ではありません。",
unavailable: "チーム使用状況は現在利用できません。",
};
}
@@ -426,12 +434,12 @@ function metricsCopy(locale: string) {
outputTokens: "Output tokens",
tokens: "Total tokens",
apiEquivalent: "API-equivalent value",
- pricingCoverage: "Pricing coverage",
+ unpriced: "{share} of these tokens came from models without a list price and are left out of that value.",
chartLabel: "Daily CodeRouter token usage",
privacy:
"No prompts, outputs, account labels, or member identities are recorded or shown.",
estimate:
- "API-equivalent value is an estimate using public list prices (rate card {version}), not actual spend. Models without a known price are excluded.",
+ "API-equivalent value is what these tokens would have cost at public API list prices (rate card {version}). It is not what you paid.",
unavailable: "Team usage is temporarily unavailable.",
};
}
@@ -494,6 +502,14 @@ async function loadSharedAccounts(
}
}
+async function loadNativeAccounts(teamId: string): Promise
{
+ try {
+ return { kind: "ok", accounts: await listNativeAccounts(teamId) };
+ } catch {
+ return { kind: "error" };
+ }
+}
+
async function loadClaudeAccounts(teamId: string): Promise {
try {
return { kind: "ok", accounts: await listClaudeAccounts(teamId) };
diff --git a/web/app/[locale]/dashboard/components/coderouter-accounts.tsx b/web/app/[locale]/dashboard/components/coderouter-accounts.tsx
index c297fcbda005..1089e0334a89 100644
--- a/web/app/[locale]/dashboard/components/coderouter-accounts.tsx
+++ b/web/app/[locale]/dashboard/components/coderouter-accounts.tsx
@@ -11,6 +11,7 @@ import type {
ClaudeUpstreamKind,
} from "../../../../services/coderouter/claudeUpstream";
import type { SubrouterAccount } from "../../../../services/subrouter/types";
+import type { CodeRouterAccountSummary } from "../../../../services/coderouter/types";
/**
* Every account the team routes through, in one list: the Claude upstream
@@ -23,6 +24,11 @@ export type ClaudeAccountsState =
| { readonly kind: "ok"; readonly accounts: readonly ClaudeAccountDescription[] }
| { readonly kind: "error" };
+/** Accounts `cr add` stores: Codex and OpenCode Go sign-ins routed by coderouter. */
+export type NativeAccountsState =
+ | { readonly kind: "ok"; readonly accounts: readonly CodeRouterAccountSummary[] }
+ | { readonly kind: "error" };
+
export type SharedAccountsState =
| { readonly kind: "ok"; readonly accounts: readonly SubrouterAccount[] }
| { readonly kind: "migrationPending" }
@@ -61,18 +67,21 @@ export function CoderouterAccountsSection({
teamId,
canManage,
claude,
+ native,
shared,
}: {
readonly teamId: string;
readonly canManage: boolean;
readonly claude: ClaudeAccountsState;
+ readonly native: NativeAccountsState;
readonly shared: SharedAccountsState;
}) {
const t = useTranslations("dashboard.coderouterAccounts");
const claudeAccounts = claude.kind === "ok" ? claude.accounts : [];
+ const nativeAccounts = native.kind === "ok" ? native.accounts : [];
const sharedAccounts = shared.kind === "ok" ? shared.accounts : [];
- const total = claudeAccounts.length + sharedAccounts.length;
- const partialFailure = claude.kind === "error" || shared.kind === "error";
+ const total = claudeAccounts.length + nativeAccounts.length + sharedAccounts.length;
+ const partialFailure = claude.kind === "error" || native.kind === "error" || shared.kind === "error";
return (
@@ -120,6 +129,14 @@ export function CoderouterAccountsSection({
canManage={canManage}
/>
))}
+ {nativeAccounts.map((account) => (
+
+ ))}
{sharedAccounts.map((account) => (
now.getTime();
+ const status = account.state === "broken"
+ ? t("stateBroken")
+ : account.state === "expired"
+ ? t("stateExpired")
+ : cooling
+ ? t("coolingDown", {
+ until: format.dateTime(new Date(account.cooldownUntil!), { timeStyle: "short" }),
+ })
+ : t("stateActive");
+ const sessions = t("activeSessions", { count: account.activeSessions });
+ return (
+ : null}
+ t={t}
+ />
+ );
+}
+
+function NativeAccountActions({
+ teamId,
+ accountId,
+}: {
+ readonly teamId: string;
+ readonly accountId: string;
+}) {
+ const t = useTranslations("dashboard.coderouterAccounts");
+ const router = useRouter();
+ const [status, setStatus] = useState(idleStatus);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+
+ const remove = async () => {
+ if (status.state === "submitting") return;
+ setConfirmOpen(false);
+ setStatus({ state: "submitting" });
+ try {
+ const response = await fetch(
+ `/api/coderouter/accounts/${encodeURIComponent(accountId)}`,
+ { method: "DELETE", headers: { "x-cmux-team-id": teamId } },
+ );
+ if (!response.ok && response.status !== 404) {
+ setStatus({ state: "error", message: errorMessageForStatus(response.status, t, t("removeError")) });
+ return;
+ }
+ setStatus(idleStatus);
+ router.refresh();
+ } catch {
+ setStatus({ state: "error", message: t("removeError") });
+ }
+ };
+
+ return (
+
+ );
+}
+
function SharedAccountRow({
teamId,
account,
@@ -655,6 +759,16 @@ function addKindLabel(kind: AddKind, t: Translator): string {
}
}
+/** Provider names for accounts `cr add` stores. */
+function nativeKindLabel(kind: CodeRouterAccountSummary["provider"], t: Translator): string {
+ switch (kind) {
+ case "codex":
+ return t("kindCodex");
+ case "opencode-go":
+ return t("kindOpencodeGo");
+ }
+}
+
/** Provider names for accounts held by the hosted subrouter. */
function sharedKindLabel(kind: string, t: Translator): string {
switch (kind) {
diff --git a/web/messages/en.json b/web/messages/en.json
index 29b8c649190d..d24c9aceefa7 100644
--- a/web/messages/en.json
+++ b/web/messages/en.json
@@ -258,16 +258,21 @@
"kindBedrock": "Amazon Bedrock",
"kindCodex": "Codex",
"kindOpencode": "OpenCode",
+ "kindOpencodeGo": "OpenCode Go",
"kindOpenAiApiKey": "OpenAI API key",
"kindUnknown": "Unknown provider",
"stateActive": "Active",
"stateDisabled": "Disabled",
+ "stateRefreshing": "Refreshing",
+ "stateExpired": "Expired",
+ "stateBroken": "Sign-in broken",
"stateUnhealthy": "Needs attention",
"coolingDown": "Cooling down until {until}",
"lastFailure": "last failure {code}",
"lastUsed": "last used {at}",
"neverUsed": "never used",
"createdAt": "added {at}",
+ "activeSessions": "{count, plural, =0 {no active sessions} one {# active session} other {# active sessions}}",
"addTitle": "Add account",
"kindSelectorLabel": "Account kind",
"labelField": "Label",
@@ -298,6 +303,7 @@
"removeError": "Account could not be removed.",
"removeConfirmTitle": "Remove this account?",
"removeClaudeConfirmBody": "Machines pinned to it move to the team's other accounts. If it is the last one, Claude requests from this team fail until a new account is added.",
+ "removeNativeConfirmBody": "Sessions pinned to it move to the team's other accounts. Adding it back means signing in again with cr add.",
"removeSharedConfirmBody": "This removes the account for the whole team. Adding it back requires signing in again.",
"cancelAction": "Cancel"
},
diff --git a/web/messages/ja.json b/web/messages/ja.json
index 5e7f280f3c97..726875668659 100644
--- a/web/messages/ja.json
+++ b/web/messages/ja.json
@@ -258,16 +258,21 @@
"kindBedrock": "Amazon Bedrock",
"kindCodex": "Codex",
"kindOpencode": "OpenCode",
+ "kindOpencodeGo": "OpenCode Go",
"kindOpenAiApiKey": "OpenAI APIキー",
"kindUnknown": "不明なプロバイダー",
"stateActive": "有効",
"stateDisabled": "無効",
+ "stateRefreshing": "更新中",
+ "stateExpired": "期限切れ",
+ "stateBroken": "サインインが無効",
"stateUnhealthy": "要確認",
"coolingDown": "{until} までクールダウン中",
"lastFailure": "最後の失敗 {code}",
"lastUsed": "最終使用 {at}",
"neverUsed": "未使用",
"createdAt": "追加日 {at}",
+ "activeSessions": "{count} 件のアクティブセッション",
"addTitle": "アカウントを追加",
"kindSelectorLabel": "アカウントの種類",
"labelField": "ラベル",
@@ -298,6 +303,7 @@
"removeError": "アカウントを削除できませんでした。",
"removeConfirmTitle": "このアカウントを削除しますか?",
"removeClaudeConfirmBody": "このアカウントに固定されたマシンはチームの他のアカウントに移ります。最後のアカウントの場合、新しいアカウントを追加するまでこのチームからの Claude リクエストは失敗します。",
+ "removeNativeConfirmBody": "このアカウントに固定されたセッションはチームの他のアカウントに移ります。再追加には cr add で再度サインインが必要です。",
"removeSharedConfirmBody": "チーム全体からこのアカウントが削除されます。再追加には再度サインインが必要です。",
"cancelAction": "キャンセル"
},
diff --git a/web/tests/coderouter-accounts.test.tsx b/web/tests/coderouter-accounts.test.tsx
index 0422a89d4312..70b75275ca9b 100644
--- a/web/tests/coderouter-accounts.test.tsx
+++ b/web/tests/coderouter-accounts.test.tsx
@@ -59,6 +59,18 @@ const codexAccount = {
health: { ok: true },
};
+const nativeCodexAccount = {
+ id: "native-1",
+ provider: "codex" as const,
+ providerAccountId: "acct_9f3",
+ label: "lawrence@example.com",
+ state: "active" as const,
+ credentialExpiresAt: "2026-09-08T00:00:00.000Z",
+ lastFailureCode: null,
+ cooldownUntil: null,
+ activeSessions: 3,
+};
+
describe("coderouter accounts section", () => {
test("lists Claude upstream and shared Codex accounts in one table", () => {
const html = renderToStaticMarkup(
@@ -66,11 +78,14 @@ describe("coderouter accounts section", () => {
teamId="team-1"
canManage
claude={{ kind: "ok", accounts: [claudeAccount] }}
+ native={{ kind: "ok", accounts: [nativeCodexAccount] }}
shared={{ kind: "ok", accounts: [codexAccount] }}
/>,
);
- expect(html).toContain("2 accounts");
+ expect(html).toContain("3 accounts");
+ expect(html).toContain("lawrence@example.com");
+ expect(html).toContain("3 active sessions");
expect(html.match(/]*>/g)).toHaveLength(1);
expect(html).toContain("Claude Code OAuth");
expect(html).toContain("sk-ant-oat01-…a1b2");
@@ -88,6 +103,7 @@ describe("coderouter accounts section", () => {
teamId="team-1"
canManage
claude={{ kind: "ok", accounts: [] }}
+ native={{ kind: "ok", accounts: [] }}
shared={{ kind: "ok", accounts: [] }}
/>,
);
@@ -110,6 +126,7 @@ describe("coderouter accounts section", () => {
teamId="team-1"
canManage={false}
claude={{ kind: "ok", accounts: [claudeAccount] }}
+ native={{ kind: "ok", accounts: [nativeCodexAccount] }}
shared={{ kind: "ok", accounts: [codexAccount] }}
/>,
);
@@ -125,6 +142,7 @@ describe("coderouter accounts section", () => {
teamId="team-1"
canManage
claude={{ kind: "ok", accounts: [claudeAccount] }}
+ native={{ kind: "ok", accounts: [] }}
shared={{ kind: "error" }}
/>,
);
@@ -140,6 +158,7 @@ describe("coderouter accounts section", () => {
teamId="team-1"
canManage
claude={{ kind: "ok", accounts: [claudeAccount] }}
+ native={{ kind: "ok", accounts: [] }}
shared={{ kind: "migrationPending" }}
/>,
);
@@ -167,9 +186,12 @@ function valueAtPath(root: unknown, path: string): unknown {
function interpolate(message: string, values?: Record): string {
if (!values) return message;
return Object.entries(values).reduce((result, [key, value]) => {
- const plural = result.match(new RegExp(`\\{${key}, plural, one \\{([^}]*)\\} other \\{([^}]*)\\}\\}`));
+ const plural = result.match(new RegExp(`\\{${key}, plural, ((?:=\\d+ \\{[^}]*\\} )?)one \\{([^}]*)\\} other \\{([^}]*)\\}\\}`));
if (plural) {
- const form = value === 1 ? plural[1] : plural[2];
+ const exact = plural[1].match(/^=(\d+) \{([^}]*)\} $/);
+ const form = exact && Number(exact[1]) === value
+ ? exact[2]
+ : value === 1 ? plural[2] : plural[3];
return result.replace(plural[0], form.replaceAll("#", String(value)));
}
return result.replaceAll(`{${key}}`, String(value));
diff --git a/web/tests/dashboard-coderouter-page.test.tsx b/web/tests/dashboard-coderouter-page.test.tsx
index 9c9dc7f0b85c..ddc1653fd9fc 100644
--- a/web/tests/dashboard-coderouter-page.test.tsx
+++ b/web/tests/dashboard-coderouter-page.test.tsx
@@ -227,16 +227,19 @@ mock.module("../app/[locale]/dashboard/components/coderouter-accounts", () => ({
CoderouterAccountsSection: ({
shared,
claude,
+ native,
canManage,
}: {
shared: { kind: string };
claude: { kind: string };
+ native: { kind: string };
canManage: boolean;
}) => (
),
@@ -246,6 +249,10 @@ mock.module("../services/coderouter/claudeUpstream", () => ({
listClaudeAccounts: async () => [],
}));
+mock.module("../services/coderouter/repository", () => ({
+ listAccounts: async () => [],
+}));
+
const { default: CoderouterOverviewPage, CoderouterOverviewContent } = await import(
"../app/[locale]/dashboard/coderouter/page"
);
@@ -312,6 +319,7 @@ describe("coderouter dashboard", () => {
expect(hostedExchangeCalls).toBe(0);
expect(html).toContain('data-shared="migrationPending"');
expect(html).toContain('data-claude="ok"');
+ expect(html).toContain('data-native="ok"');
});
test("renders recovery UI when the bounded Stack session refresh fails", async () => {
@@ -356,6 +364,9 @@ describe("coderouter dashboard", () => {
expect(html).toContain("30-day usage");
expect(html).toContain("1.3K");
expect(html).toContain("$4.25");
+ // Every token was priced, so no coverage caveat and no coverage card.
+ expect(html).not.toContain("Pricing coverage");
+ expect(html).not.toContain("without a list price");
expect(html).toContain("No prompts, outputs, account labels, or member identities");
expect(html).not.toContain("stack-user");
});
From 1c30f5d2e831da56f9b52b2de6037aac900c8c5d Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Mon, 7 Sep 2026 23:20:06 -0700
Subject: [PATCH 05/12] coderouter: route Responses calls through OpenAI and
OpenRouter API keys
Teams can add an OpenAI API key or an OpenRouter API key as a coderouter
account next to their Codex sign-ins. The Responses surface
(/v1/responses, /v1/models) now selects from a provider pool: Codex
sign-ins go to the ChatGPT backend as before, an OpenAI key goes to
api.openai.com, and an OpenRouter key goes to openrouter.ai with bare
OpenAI model ids rewritten to openai/. Session stickiness,
placement spread, rate-limit cooldown and failover are unchanged; a key
the provider rejects is marked broken with last failure api_key_rejected
and the request moves on.
Keys are stored like every other credential (KMS envelope, AAD bound to
team/account/provider). The provider account id is a SHA-256 fingerprint
of the key, so re-adding a key updates the same row and the row never
carries the secret. API keys have no expiry and skip the refresher.
Migration 20260907150000 widens the provider CHECK on the three
coderouter tables. Run it on staging and production before merging.
The dashboard add panel gains OpenAI API key and OpenRouter API key
tabs posting to /api/coderouter/accounts.
Claude-Session: https://claude.ai/code/session_01LMP2oJjwAkLbLxLaWmLaLS
---
.../components/coderouter-accounts.tsx | 97 +++++-
.../migration.sql | 20 ++
web/db/schema.ts | 8 +-
web/messages/en.json | 3 +
web/messages/ja.json | 3 +
web/services/coderouter/accounts.ts | 47 ++-
web/services/coderouter/analytics.ts | 1 +
web/services/coderouter/codexProxy.ts | 138 +++++++--
web/services/coderouter/encryption.ts | 12 +
web/services/coderouter/refresh.ts | 31 +-
web/services/coderouter/repository.ts | 73 +++--
web/services/coderouter/types.ts | 77 ++++-
web/tests/coderouter-accounts.test.tsx | 2 +
.../coderouter-api-key-providers.test.ts | 278 ++++++++++++++++++
web/tests/coderouter-models-proxy.test.ts | 2 +-
web/tests/coderouter-opencode-proxy.test.ts | 3 +-
web/tests/coderouter-refresh.test.ts | 8 +-
web/tests/coderouter-responses-proxy.test.ts | 14 +-
web/tests/coderouter-session-selector.test.ts | 5 +-
19 files changed, 744 insertions(+), 78 deletions(-)
create mode 100644 web/db/migrations/20260907150000_coderouter_api_key_providers/migration.sql
create mode 100644 web/tests/coderouter-api-key-providers.test.ts
diff --git a/web/app/[locale]/dashboard/components/coderouter-accounts.tsx b/web/app/[locale]/dashboard/components/coderouter-accounts.tsx
index 1089e0334a89..bbc4730d03c4 100644
--- a/web/app/[locale]/dashboard/components/coderouter-accounts.tsx
+++ b/web/app/[locale]/dashboard/components/coderouter-accounts.tsx
@@ -43,11 +43,14 @@ type FormStatus = {
const idleStatus: FormStatus = { state: "idle" };
/** Everything the add panel offers, in display order. */
-type AddKind = ClaudeUpstreamKind | "codex" | "opencode";
+type ApiKeyAddKind = "openai-apikey" | "openrouter-apikey";
+type AddKind = ClaudeUpstreamKind | ApiKeyAddKind | "codex" | "opencode";
const ADD_KINDS: readonly AddKind[] = [
"anthropic_api_key",
"anthropic_oauth",
"bedrock",
+ "openai-apikey",
+ "openrouter-apikey",
"codex",
"opencode",
];
@@ -81,6 +84,8 @@ export function CoderouterAccountsSection({
const nativeAccounts = native.kind === "ok" ? native.accounts : [];
const sharedAccounts = shared.kind === "ok" ? shared.accounts : [];
const total = claudeAccounts.length + nativeAccounts.length + sharedAccounts.length;
+ // Field ids are per form, so switching the add tab never leaves two inputs
+ // with one id.
const partialFailure = claude.kind === "error" || native.kind === "error" || shared.kind === "error";
return (
@@ -565,6 +570,8 @@ function AddAccountPanel({ teamId }: { readonly teamId: string }) {
command="npx coderouter@latest add opencode"
t={t}
/>
+ ) : kind === "openai-apikey" || kind === "openrouter-apikey" ? (
+
) : (
)}
@@ -593,6 +600,86 @@ function CliInstructions({
);
}
+/**
+ * Stores an OpenAI or OpenRouter key as a coderouter account. Codex on this
+ * team's machines then routes Responses calls through it, next to any Codex
+ * sign-ins, and moves off it on a rate limit or a rejected key.
+ */
+function ApiKeyForm({
+ teamId,
+ kind,
+}: {
+ readonly teamId: string;
+ readonly kind: ApiKeyAddKind;
+}) {
+ const t = useTranslations("dashboard.coderouterAccounts");
+ const router = useRouter();
+ const [status, setStatus] = useState(idleStatus);
+
+ const submit = async (event: FormEvent) => {
+ event.preventDefault();
+ if (status.state === "submitting") return;
+ const form = event.currentTarget;
+ const data = new FormData(form);
+ const label = String(data.get("label") ?? "").trim();
+ setStatus({ state: "submitting" });
+ try {
+ const response = await fetch("/api/coderouter/accounts", {
+ method: "POST",
+ headers: { "content-type": "application/json", "x-cmux-team-id": teamId },
+ body: JSON.stringify({
+ provider: kind,
+ apiKey: String(data.get("apiKey") ?? "").trim(),
+ ...(label ? { label } : {}),
+ }),
+ });
+ if (!response.ok) {
+ setStatus({
+ state: "error",
+ message: errorMessageForStatus(response.status, t, t("saveError")),
+ });
+ return;
+ }
+ form.reset();
+ setStatus({ state: "success", message: t("saveSuccess") });
+ router.refresh();
+ } catch {
+ setStatus({ state: "error", message: t("saveError") });
+ }
+ };
+
+ return (
+
+ );
+}
+
function ClaudeUpstreamForm({
teamId,
kind,
@@ -754,6 +841,10 @@ function addKindLabel(kind: AddKind, t: Translator): string {
return t("kindCodex");
case "opencode":
return t("kindOpencode");
+ case "openai-apikey":
+ return t("kindOpenAiApiKey");
+ case "openrouter-apikey":
+ return t("kindOpenRouterApiKey");
default:
return claudeKindLabel(kind, t);
}
@@ -766,6 +857,10 @@ function nativeKindLabel(kind: CodeRouterAccountSummary["provider"], t: Translat
return t("kindCodex");
case "opencode-go":
return t("kindOpencodeGo");
+ case "openai-apikey":
+ return t("kindOpenAiApiKey");
+ case "openrouter-apikey":
+ return t("kindOpenRouterApiKey");
}
}
diff --git a/web/db/migrations/20260907150000_coderouter_api_key_providers/migration.sql b/web/db/migrations/20260907150000_coderouter_api_key_providers/migration.sql
new file mode 100644
index 000000000000..c636b996c996
--- /dev/null
+++ b/web/db/migrations/20260907150000_coderouter_api_key_providers/migration.sql
@@ -0,0 +1,20 @@
+-- coderouter routes the OpenAI Responses surface through pasted OpenAI and
+-- OpenRouter API keys as well as Codex sign-ins. The provider check on every
+-- coderouter table widens to admit the two key-based providers.
+ALTER TABLE "coderouter_accounts"
+ DROP CONSTRAINT IF EXISTS "coderouter_accounts_provider_check";
+ALTER TABLE "coderouter_accounts"
+ ADD CONSTRAINT "coderouter_accounts_provider_check"
+ CHECK ("provider" IN ('codex', 'opencode-go', 'openai-apikey', 'openrouter-apikey'));
+
+ALTER TABLE "coderouter_credentials"
+ DROP CONSTRAINT IF EXISTS "coderouter_credentials_provider_check";
+ALTER TABLE "coderouter_credentials"
+ ADD CONSTRAINT "coderouter_credentials_provider_check"
+ CHECK ("provider" IN ('codex', 'opencode-go', 'openai-apikey', 'openrouter-apikey'));
+
+ALTER TABLE "coderouter_session_accounts"
+ DROP CONSTRAINT IF EXISTS "coderouter_session_accounts_provider_check";
+ALTER TABLE "coderouter_session_accounts"
+ ADD CONSTRAINT "coderouter_session_accounts_provider_check"
+ CHECK ("provider" IN ('codex', 'opencode-go', 'openai-apikey', 'openrouter-apikey'));
diff --git a/web/db/schema.ts b/web/db/schema.ts
index 63b1e7dcacaa..41a7f7fb2815 100644
--- a/web/db/schema.ts
+++ b/web/db/schema.ts
@@ -1085,12 +1085,14 @@ export const subrouterTenants = pgTable(
* live in the envelope-encrypted coderouterCredentials table; this table
* coordinates selection and rotating refresh-token leases.
*/
+type CodeRouterProviderColumn = "codex" | "opencode-go" | "openai-apikey" | "openrouter-apikey";
+
export const coderouterAccounts = pgTable(
"coderouter_accounts",
{
id: uuid("id").defaultRandom().primaryKey(),
teamId: text("team_id").notNull(),
- provider: text("provider").$type<"codex" | "opencode-go">().notNull(),
+ provider: text("provider").$type().notNull(),
providerAccountId: text("provider_account_id").notNull(),
label: text("label").notNull(),
state: text("state")
@@ -1166,7 +1168,7 @@ export const coderouterCredentials = pgTable(
.primaryKey()
.references(() => coderouterAccounts.id, { onDelete: "cascade" }),
teamId: text("team_id").notNull(),
- provider: text("provider").$type<"codex" | "opencode-go">().notNull(),
+ provider: text("provider").$type().notNull(),
credentialRevision: bigint("credential_revision", { mode: "number" })
.notNull(),
algorithm: text("algorithm").notNull().default("aes-256-gcm"),
@@ -1219,7 +1221,7 @@ export const coderouterSessionAccounts = pgTable(
"coderouter_session_accounts",
{
teamId: text("team_id").notNull(),
- provider: text("provider").$type<"codex" | "opencode-go">().notNull(),
+ provider: text("provider").$type().notNull(),
sessionKey: text("session_key").notNull(),
accountId: uuid("account_id")
.notNull()
diff --git a/web/messages/en.json b/web/messages/en.json
index d24c9aceefa7..cc9ce559db84 100644
--- a/web/messages/en.json
+++ b/web/messages/en.json
@@ -260,6 +260,7 @@
"kindOpencode": "OpenCode",
"kindOpencodeGo": "OpenCode Go",
"kindOpenAiApiKey": "OpenAI API key",
+ "kindOpenRouterApiKey": "OpenRouter API key",
"kindUnknown": "Unknown provider",
"stateActive": "Active",
"stateDisabled": "Disabled",
@@ -280,6 +281,8 @@
"apiKeyField": "API key",
"oauthTokenField": "OAuth token",
"oauthHint": "Create a long-lived token on your own machine with",
+ "openAiKeyHint": "Codex on this team's machines sends Responses API calls to api.openai.com with this key, alongside any Codex sign-ins. Billing goes to the OpenAI project that owns the key.",
+ "openRouterKeyHint": "Codex on this team's machines sends Responses API calls to OpenRouter with this key. Bare OpenAI model ids are sent as openai/.",
"regionField": "AWS region",
"accessKeyIdField": "Access key ID",
"secretAccessKeyField": "Secret access key",
diff --git a/web/messages/ja.json b/web/messages/ja.json
index 726875668659..f7be3c37cd6e 100644
--- a/web/messages/ja.json
+++ b/web/messages/ja.json
@@ -260,6 +260,7 @@
"kindOpencode": "OpenCode",
"kindOpencodeGo": "OpenCode Go",
"kindOpenAiApiKey": "OpenAI APIキー",
+ "kindOpenRouterApiKey": "OpenRouter APIキー",
"kindUnknown": "不明なプロバイダー",
"stateActive": "有効",
"stateDisabled": "無効",
@@ -280,6 +281,8 @@
"apiKeyField": "APIキー",
"oauthTokenField": "OAuthトークン",
"oauthHint": "自分のマシンで次のコマンドを実行して長期トークンを作成します:",
+ "openAiKeyHint": "このチームのマシン上の Codex は、Codex サインインと並んでこのキーで api.openai.com に Responses API を送信します。請求はキーを所有する OpenAI プロジェクトに行われます。",
+ "openRouterKeyHint": "このチームのマシン上の Codex は、このキーで OpenRouter に Responses API を送信します。ベンダー接頭辞のない OpenAI モデル ID は openai/ として送られます。",
"regionField": "AWSリージョン",
"accessKeyIdField": "アクセスキーID",
"secretAccessKeyField": "シークレットアクセスキー",
diff --git a/web/services/coderouter/accounts.ts b/web/services/coderouter/accounts.ts
index 4a7574508417..3e8177d13a06 100644
--- a/web/services/coderouter/accounts.ts
+++ b/web/services/coderouter/accounts.ts
@@ -1,4 +1,4 @@
-import { randomUUID } from "node:crypto";
+import { createHash, randomUUID } from "node:crypto";
import {
findAccountByProviderIdentity,
deleteAccount,
@@ -8,7 +8,11 @@ import {
withVaultLease,
} from "./repository";
import { encryptCredential } from "./encryption";
-import type { CodeRouterCredential } from "./types";
+import {
+ CODEROUTER_API_KEY_PROVIDERS,
+ type CodeRouterApiKeyProvider,
+ type CodeRouterCredential,
+} from "./types";
import { deleteVaultCredential } from "./vault";
import { reportCoderouterFailure } from "./observability";
@@ -97,9 +101,14 @@ export const removeAccount = createAccountRemover({
report: reportCoderouterFailure,
});
+const MAX_API_KEY_LENGTH = 512;
+const MAX_LABEL_LENGTH = 120;
+const API_KEY_PATTERN = /^[A-Za-z0-9._~+/=-]+$/;
+
export function parseCredential(value: unknown): CodeRouterCredential | null {
if (!isRecord(value)) return null;
const provider = value.provider;
+ if (isApiKeyProviderName(provider)) return parseApiKeyCredential(provider, value);
const accessToken = boundedString(value.accessToken, 32_768);
const refreshToken = boundedString(value.refreshToken, 32_768);
const accountId = boundedString(value.accountId, 512);
@@ -147,6 +156,40 @@ export function parseCredential(value: unknown): CodeRouterCredential | null {
return null;
}
+function isApiKeyProviderName(value: unknown): value is CodeRouterApiKeyProvider {
+ return typeof value === "string" &&
+ (CODEROUTER_API_KEY_PROVIDERS as readonly string[]).includes(value);
+}
+
+/**
+ * `{ provider, apiKey, label? }` from the dashboard or `cr add`. The key is
+ * validated as one printable token; the fingerprint becomes the provider
+ * account id, so re-adding the same key updates the existing row.
+ */
+function parseApiKeyCredential(
+ provider: CodeRouterApiKeyProvider,
+ value: Record,
+): CodeRouterCredential | null {
+ const apiKey = typeof value.apiKey === "string" ? value.apiKey.trim() : "";
+ if (apiKey.length < 16 || apiKey.length > MAX_API_KEY_LENGTH || !API_KEY_PATTERN.test(apiKey)) {
+ return null;
+ }
+ const rawLabel = value.label;
+ if (rawLabel !== undefined && rawLabel !== null && typeof rawLabel !== "string") return null;
+ const label = typeof rawLabel === "string" ? rawLabel.trim() : "";
+ if (label.length > MAX_LABEL_LENGTH) return null;
+ return {
+ provider,
+ apiKey,
+ accountId: apiKeyFingerprint(provider, apiKey),
+ label,
+ };
+}
+
+export function apiKeyFingerprint(provider: CodeRouterApiKeyProvider, apiKey: string): string {
+ return createHash("sha256").update(`${provider}\n${apiKey}`).digest("hex").slice(0, 24);
+}
+
function boundedString(value: unknown, max: number): string | null {
return typeof value === "string" && value.length > 0 && value.length <= max
? value
diff --git a/web/services/coderouter/analytics.ts b/web/services/coderouter/analytics.ts
index 16629dc91fa9..7a27d13f65c9 100644
--- a/web/services/coderouter/analytics.ts
+++ b/web/services/coderouter/analytics.ts
@@ -418,6 +418,7 @@ function accountProvider(value: unknown): string | null {
"openai-apikey",
"anthropic-apikey",
"opencode-go",
+ "openrouter-apikey",
]);
}
diff --git a/web/services/coderouter/codexProxy.ts b/web/services/coderouter/codexProxy.ts
index 154a04b62e37..c2a8de0f36d7 100644
--- a/web/services/coderouter/codexProxy.ts
+++ b/web/services/coderouter/codexProxy.ts
@@ -6,6 +6,7 @@ import {
} from "./repository";
import { freshCredential } from "./refresh";
import { fetchProviderRead } from "./providerFetch";
+import { RESPONSES_PROVIDERS, type CodeRouterCredential } from "./types";
import { captureCoderouterEvent } from "./analytics";
import {
addCoderouterBreadcrumb,
@@ -37,6 +38,10 @@ import {
const CODEX_UPSTREAM = "https://chatgpt.com/backend-api/codex/responses";
const CODEX_MODELS_UPSTREAM = "https://chatgpt.com/backend-api/codex/models";
+const OPENAI_UPSTREAM = "https://api.openai.com/v1/responses";
+const OPENAI_MODELS_UPSTREAM = "https://api.openai.com/v1/models";
+const OPENROUTER_UPSTREAM = "https://openrouter.ai/api/v1/responses";
+const OPENROUTER_MODELS_UPSTREAM = "https://openrouter.ai/api/v1/models";
const ALLOWED_REQUEST_HEADERS = [
"accept",
"content-encoding",
@@ -228,7 +233,7 @@ async function proxyCodexRequestWith(
runtime.now,
(signal) => dependencies.select({
teamId: identity.teamId,
- provider: "codex",
+ provider: RESPONSES_PROVIDERS,
sessionKey,
excludedAccountIds: attempted,
signal,
@@ -308,7 +313,7 @@ async function proxyCodexRequestWith(
if (tag === "CodeRouterCredentialBroken") continue;
throw error;
}
- if (credential.provider !== "codex") continue;
+ if (!servesResponses(credential)) continue;
throwIfRequestAborted(request);
const headersTimeoutMs = remainingUpstreamHeadersTimeoutMs(
upstreamHeaderDeadlineAt,
@@ -321,7 +326,7 @@ async function proxyCodexRequestWith(
}
const upstreamStartedAt = performance.now();
try {
- upstream = await sendCodex(
+ upstream = await sendResponses(
request.clone(),
forwardedHeaders,
credential,
@@ -331,7 +336,7 @@ async function proxyCodexRequestWith(
recordCoderouterSpan({
name: "upstream_attempt",
startedAt: upstreamStartedAt,
- attributes: { provider: "codex", attempt: attempt + 1, status: upstream.status },
+ attributes: { provider: credential.provider, attempt: attempt + 1, status: upstream.status },
});
} catch (error) {
if (request.signal.aborted) throw error;
@@ -387,7 +392,7 @@ async function proxyCodexRequestWith(
break;
}
const retryStartedAt = performance.now();
- upstream = await sendCodex(
+ upstream = await sendResponses(
request.clone(),
forwardedHeaders,
refreshed,
@@ -547,7 +552,7 @@ export function createCodexModelsProxy(dependencies: CodexModelsDependencies) {
const selectStartedAt = performance.now();
const account = await dependencies.select(
identity.teamId,
- "codex",
+ RESPONSES_PROVIDERS,
attempted,
);
recordCoderouterSpan({
@@ -568,19 +573,13 @@ export function createCodexModelsProxy(dependencies: CodexModelsDependencies) {
failureStage = "credential_refresh";
continue;
}
- if (credential.provider !== "codex") continue;
- const upstreamUrl = new URL(CODEX_MODELS_UPSTREAM);
- upstreamUrl.search = new URL(request.url).search;
+ if (!servesResponses(credential)) continue;
+ const models = modelsRequest(credential, request);
const upstreamStartedAt = performance.now();
try {
upstream = await dependencies.providerRead(() =>
- fetch(upstreamUrl, {
- headers: {
- authorization: `Bearer ${credential.accessToken}`,
- "chatgpt-account-id": credential.accountId,
- originator: "codex_cli_rs",
- "user-agent": request.headers.get("user-agent") ?? "coderouter",
- },
+ fetch(models.url, {
+ headers: models.headers,
cache: "no-store",
signal: AbortSignal.timeout(5_000),
}),
@@ -669,29 +668,122 @@ export const proxyCodexModels = createCodexModelsProxy({
providerRead: fetchProviderRead,
});
-async function sendCodex(
+type ResponsesCredential = Extract<
+ CodeRouterCredential,
+ { provider: "codex" | "openai-apikey" | "openrouter-apikey" }
+>;
+
+function servesResponses(credential: CodeRouterCredential): credential is ResponsesCredential {
+ return (RESPONSES_PROVIDERS as readonly string[]).includes(credential.provider);
+}
+
+/**
+ * Forwards one Responses call to the account's own upstream. Codex sign-ins go
+ * to the ChatGPT backend with the account header; an OpenAI key goes to the
+ * public API; an OpenRouter key goes to OpenRouter, whose model catalog is
+ * vendor-prefixed, so a bare OpenAI model id is rewritten to `openai/`.
+ */
+async function sendResponses(
request: Request,
forwardedHeaders: Headers,
- credential: { accessToken: string; accountId: string },
+ credential: ResponsesCredential,
fetchImpl: typeof fetch,
headersTimeoutMs: number,
): Promise {
const headers = new Headers(forwardedHeaders);
- headers.set("authorization", `Bearer ${credential.accessToken}`);
- headers.set("chatgpt-account-id", credential.accountId);
- headers.set("originator", "coderouter");
+ let url = CODEX_UPSTREAM;
+ let body: BodyInit | null = request.body;
+ switch (credential.provider) {
+ case "codex":
+ headers.set("authorization", `Bearer ${credential.accessToken}`);
+ headers.set("chatgpt-account-id", credential.accountId);
+ headers.set("originator", "coderouter");
+ break;
+ case "openai-apikey":
+ url = OPENAI_UPSTREAM;
+ headers.set("authorization", `Bearer ${credential.apiKey}`);
+ headers.delete("session_id");
+ break;
+ case "openrouter-apikey":
+ url = OPENROUTER_UPSTREAM;
+ headers.set("authorization", `Bearer ${credential.apiKey}`);
+ headers.set("http-referer", "https://cmux.com");
+ headers.set("x-title", "cmux coderouter");
+ headers.delete("session_id");
+ headers.delete("openai-beta");
+ body = await openRouterBody(request);
+ break;
+ }
// Bounded to headers only: a hung upstream fails over instead of holding
// the function for the full maxDuration; the body streams unbounded.
- return await fetchWithHeadersTimeout(fetchImpl, CODEX_UPSTREAM, {
+ return await fetchWithHeadersTimeout(fetchImpl, url, {
method: "POST",
headers,
- body: request.body,
+ body,
signal: request.signal,
duplex: "half",
cache: "no-store",
} as RequestInit & { duplex: "half" }, headersTimeoutMs);
}
+/** Rewrites a bare model id to OpenRouter's `openai/`; anything else passes through. */
+async function openRouterBody(request: Request): Promise {
+ const text = await request.text();
+ try {
+ const parsed: unknown = JSON.parse(text);
+ if (
+ parsed && typeof parsed === "object" && !Array.isArray(parsed) &&
+ typeof (parsed as { model?: unknown }).model === "string"
+ ) {
+ return JSON.stringify({ ...parsed, model: openRouterModelId((parsed as { model: string }).model) });
+ }
+ } catch {
+ // Not JSON: forward as received and let OpenRouter answer.
+ }
+ return text;
+}
+
+export function openRouterModelId(model: string): string {
+ return model.includes("/") ? model : `openai/${model}`;
+}
+
+function modelsRequest(
+ credential: ResponsesCredential,
+ request: Request,
+): { readonly url: URL; readonly headers: Record } {
+ const userAgent = request.headers.get("user-agent") ?? "coderouter";
+ switch (credential.provider) {
+ case "codex": {
+ const url = new URL(CODEX_MODELS_UPSTREAM);
+ url.search = new URL(request.url).search;
+ return {
+ url,
+ headers: {
+ authorization: `Bearer ${credential.accessToken}`,
+ "chatgpt-account-id": credential.accountId,
+ originator: "codex_cli_rs",
+ "user-agent": userAgent,
+ },
+ };
+ }
+ case "openai-apikey":
+ return {
+ url: new URL(OPENAI_MODELS_UPSTREAM),
+ headers: { authorization: `Bearer ${credential.apiKey}`, "user-agent": userAgent },
+ };
+ case "openrouter-apikey":
+ return {
+ url: new URL(OPENROUTER_MODELS_UPSTREAM),
+ headers: {
+ authorization: `Bearer ${credential.apiKey}`,
+ "http-referer": "https://cmux.com",
+ "x-title": "cmux coderouter",
+ "user-agent": userAgent,
+ },
+ };
+ }
+}
+
function rateLimitDelay(headers: Headers): number {
const retryAfter = headers.get("retry-after");
if (retryAfter && /^\d+$/.test(retryAfter)) {
diff --git a/web/services/coderouter/encryption.ts b/web/services/coderouter/encryption.ts
index 7c1408ff3f32..4f42c8e02200 100644
--- a/web/services/coderouter/encryption.ts
+++ b/web/services/coderouter/encryption.ts
@@ -285,6 +285,9 @@ function strictBase64(value: string, label: string): Buffer {
function parseCredential(value: unknown): CodeRouterCredential | null {
if (!isRecord(value)) return null;
+ if (value.provider === "openai-apikey" || value.provider === "openrouter-apikey") {
+ return parseApiKeyCredential(value.provider, value);
+ }
const {
accessToken,
refreshToken,
@@ -333,6 +336,15 @@ function parseCredential(value: unknown): CodeRouterCredential | null {
return null;
}
+function parseApiKeyCredential(
+ provider: "openai-apikey" | "openrouter-apikey",
+ value: Record,
+): CodeRouterCredential | null {
+ return string(value.apiKey) && string(value.accountId) && typeof value.label === "string"
+ ? { provider, apiKey: value.apiKey, accountId: value.accountId, label: value.label }
+ : null;
+}
+
function string(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
diff --git a/web/services/coderouter/refresh.ts b/web/services/coderouter/refresh.ts
index 5c5962079169..1ee81b380a0a 100644
--- a/web/services/coderouter/refresh.ts
+++ b/web/services/coderouter/refresh.ts
@@ -10,7 +10,7 @@ import {
encryptCredential,
type EncryptedCredential,
} from "./encryption";
-import type { CodeRouterCredential } from "./types";
+import { isApiKeyCredential, type ApiKeyCredential, type CodeRouterCredential } from "./types";
import { addCoderouterBreadcrumb, reportCoderouterFailure } from "./observability";
const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
@@ -73,6 +73,9 @@ export function createCredentialRefresher(
if (!input.signal?.aborted) reportCoderouterFailure("credential_decrypt", error);
throw error;
}
+ if (isApiKeyCredential(before.credential)) {
+ return await settleApiKeyCredential(dependencies, input, before.credential);
+ }
if (!input.force && before.credential.expiresAt > Date.now() + REFRESH_SKEW_MS) {
return before.credential;
}
@@ -97,7 +100,7 @@ export function createCredentialRefresher(
throwIfAborted(input.signal);
if (
!input.force &&
- current.credential.expiresAt > Date.now() + REFRESH_SKEW_MS
+ credentialExpiryMs(current.credential) > Date.now() + REFRESH_SKEW_MS
) {
await dependencies.release(input.accountId, leaseId, input.signal);
throwIfAborted(input.signal);
@@ -153,6 +156,29 @@ export function createCredentialRefresher(
};
}
+/**
+ * An API key has nothing to refresh. A forced refresh after a 401 means the
+ * provider rejected the key itself, so the account is marked broken (visible
+ * in the dashboard) and the request moves to the next account.
+ */
+async function settleApiKeyCredential(
+ dependencies: CredentialRefreshDependencies,
+ input: FreshCredentialInput,
+ credential: ApiKeyCredential,
+): Promise {
+ if (!input.force) return credential;
+ const leaseId = await dependencies.claim(input.accountId, new Date(), input.signal);
+ if (!leaseId) throw new CodeRouterRefreshBusy("credential refresh already in progress");
+ await dependencies.fail(input.accountId, leaseId, true, "api_key_rejected", input.signal)
+ .catch(() => undefined);
+ throw new CodeRouterCredentialBroken("provider rejected the API key");
+}
+
+/** API keys never expire; only a forced refresh reaches the provider for them. */
+function credentialExpiryMs(credential: CodeRouterCredential): number {
+ return isApiKeyCredential(credential) ? Number.POSITIVE_INFINITY : credential.expiresAt;
+}
+
function currentProvider(credential: CodeRouterCredential): string {
return credential.provider;
}
@@ -185,6 +211,7 @@ export async function refreshProviderCredential(
credential: CodeRouterCredential,
signal?: AbortSignal,
): Promise {
+ if (isApiKeyCredential(credential)) return credential;
if (credential.provider === "codex") {
const token = await postForm("https://auth.openai.com/oauth/token", {
grant_type: "refresh_token",
diff --git a/web/services/coderouter/repository.ts b/web/services/coderouter/repository.ts
index 2b8f6bf3b26c..8547c7ca6798 100644
--- a/web/services/coderouter/repository.ts
+++ b/web/services/coderouter/repository.ts
@@ -10,10 +10,12 @@ import {
coderouterVaultLeases,
} from "../../db/schema";
import type { EncryptedCredential } from "./encryption";
-import type {
- CodeRouterAccountSummary,
- CodeRouterCredential,
- CodeRouterProvider,
+import {
+ credentialExpiresAt,
+ credentialLabel,
+ type CodeRouterAccountSummary,
+ type CodeRouterCredential,
+ type CodeRouterProvider,
} from "./types";
const ROUTE_TOKEN_LIFETIME_MS = 30 * 24 * 60 * 60 * 1_000;
@@ -325,7 +327,7 @@ export async function insertAccountWithCredential(input: {
label,
state: "active",
vaultRevision: input.encrypted.credentialRevision,
- credentialExpiresAt: new Date(input.credential.expiresAt),
+ credentialExpiresAt: credentialExpiresAt(input.credential),
updatedAt: new Date(),
})
.onConflictDoNothing({
@@ -369,7 +371,7 @@ export async function replaceAccountCredential(input: {
label: credentialLabel(input.credential),
state: "active",
vaultRevision: input.encrypted.credentialRevision,
- credentialExpiresAt: new Date(input.credential.expiresAt),
+ credentialExpiresAt: credentialExpiresAt(input.credential),
refreshLeaseId: null,
refreshLeaseExpiresAt: null,
lastFailureCode: null,
@@ -419,7 +421,7 @@ export async function importEncryptedCredential(input: {
.set({
label: credentialLabel(input.credential),
vaultRevision: input.encrypted.credentialRevision,
- credentialExpiresAt: new Date(input.credential.expiresAt),
+ credentialExpiresAt: credentialExpiresAt(input.credential),
updatedAt: new Date(),
})
.where(and(
@@ -437,11 +439,7 @@ export async function upsertAccountMetadata(input: {
readonly vaultRevision: number;
}): Promise {
const providerAccountId = input.credential.accountId;
- const label = input.credential.email ||
- (input.credential.provider === "opencode-go"
- ? input.credential.orgName
- : undefined) ||
- providerAccountId;
+ const label = credentialLabel(input.credential);
await cloudDb()
.insert(coderouterAccounts)
.values({
@@ -452,7 +450,7 @@ export async function upsertAccountMetadata(input: {
label,
state: "active",
vaultRevision: input.vaultRevision,
- credentialExpiresAt: new Date(input.credential.expiresAt),
+ credentialExpiresAt: credentialExpiresAt(input.credential),
updatedAt: new Date(),
})
.onConflictDoUpdate({
@@ -465,7 +463,7 @@ export async function upsertAccountMetadata(input: {
label,
state: "active",
vaultRevision: input.vaultRevision,
- credentialExpiresAt: new Date(input.credential.expiresAt),
+ credentialExpiresAt: credentialExpiresAt(input.credential),
lastFailureCode: null,
updatedAt: new Date(),
},
@@ -495,10 +493,24 @@ export async function findAccountByProviderIdentity(
export type RoutedAccount = {
id: string;
+ provider: CodeRouterProvider;
vaultRevision: number;
credentialExpiresAt: Date | null;
};
+/** One provider or a pool of providers that serve the same API surface. */
+export type ProviderPool = CodeRouterProvider | readonly CodeRouterProvider[];
+
+function providerList(pool: ProviderPool): readonly CodeRouterProvider[] {
+ return typeof pool === "string" ? [pool] : pool;
+}
+
+function providerMatch(column: ReturnType, pool: ProviderPool) {
+ const providers = providerList(pool);
+ if (providers.length === 1) return sql`${column} = ${providers[0]}`;
+ return sql`${column} in (${sql.join(providers.map((provider) => sql`${provider}`), sql`, `)})`;
+}
+
export type StickyRoutedAccount = RoutedAccount & {
/** True when the session's existing account binding was honored. */
sticky: boolean;
@@ -538,7 +550,7 @@ async function sweepExpiredRefreshLeases(
*/
export async function findSessionAccount(
teamId: string,
- provider: CodeRouterProvider,
+ provider: ProviderPool,
sessionKey: string,
excludedAccountIds: readonly string[] = [],
signal?: AbortSignal,
@@ -571,7 +583,7 @@ export async function findSessionAccount(
async function findSessionAccountStatement(
teamId: string,
- provider: CodeRouterProvider,
+ provider: ProviderPool,
sessionKey: string,
excludedAccountIds: readonly string[],
signal?: AbortSignal,
@@ -581,7 +593,7 @@ async function findSessionAccountStatement(
set "last_seen_at" = now()
from "coderouter_accounts" as account
where binding."team_id" = ${teamId}
- and binding."provider" = ${provider}
+ and ${providerMatch(sql`binding."provider"`, provider)}
and binding."session_key" = ${sessionKey}
and account."id" = binding."account_id"
-- 'refreshing' is a healthy account with a credential refresh in
@@ -592,6 +604,7 @@ async function findSessionAccountStatement(
${accountExclusion(sql`account."id"`, excludedAccountIds)}
returning
account."id" as "id",
+ account."provider" as "provider",
account."vault_revision" as "vaultRevision",
account."credential_expires_at" as "credentialExpiresAt"
`));
@@ -607,7 +620,7 @@ async function findSessionAccountStatement(
*/
export async function claimAccountForPlacement(
teamId: string,
- provider: CodeRouterProvider,
+ provider: ProviderPool,
excludedAccountIds: readonly string[] = [],
signal?: AbortSignal,
): Promise {
@@ -623,7 +636,7 @@ export async function claimAccountForPlacement(
async function claimWithOrdering(
teamId: string,
- provider: CodeRouterProvider,
+ provider: ProviderPool,
excludedAccountIds: readonly string[],
withSessionLoad: boolean,
signal?: AbortSignal,
@@ -654,7 +667,7 @@ async function claimWithOrdering(
async function claimStatement(
teamId: string,
- provider: CodeRouterProvider,
+ provider: ProviderPool,
excludedAccountIds: readonly string[],
skipLocked: boolean,
withSessionLoad: boolean,
@@ -665,7 +678,7 @@ async function claimStatement(
select account."id"
from "coderouter_accounts" as account
where account."team_id" = ${teamId}
- and account."provider" = ${provider}
+ and ${providerMatch(sql`account."provider"`, provider)}
and account."state" = 'active'
and (account."cooldown_until" is null or account."cooldown_until" <= now())
${accountExclusion(sql`account."id"`, excludedAccountIds)}
@@ -689,6 +702,7 @@ async function claimStatement(
where claimed."id" = candidate."id"
returning
claimed."id" as "id",
+ claimed."provider" as "provider",
claimed."vault_revision" as "vaultRevision",
claimed."credential_expires_at" as "credentialExpiresAt"
`));
@@ -773,7 +787,7 @@ export function createSessionAccountSelector(
dependencies: SessionAccountSelectorDependencies,
): (input: {
teamId: string;
- provider: CodeRouterProvider;
+ provider: ProviderPool;
sessionKey: string | null;
excludedAccountIds?: readonly string[];
signal?: AbortSignal;
@@ -803,9 +817,11 @@ export function createSessionAccountSelector(
throwIfAborted(input.signal);
if (!placed) return null;
if (input.sessionKey) {
+ // The binding records the account's own provider, so a pooled surface
+ // (Codex sign-ins plus API keys) still finds it under any pool shape.
await dependencies.bind(
input.teamId,
- input.provider,
+ placed.provider,
input.sessionKey,
placed.id,
input.signal,
@@ -830,7 +846,7 @@ export const selectAccountForSession = createSessionAccountSelector({
export async function selectAccountForRequest(
teamId: string,
- provider: CodeRouterProvider,
+ provider: ProviderPool,
excludedAccountIds: readonly string[] = [],
signal?: AbortSignal,
): Promise {
@@ -855,6 +871,7 @@ function accountExclusion(
function routedAccountRow(row: Record): RoutedAccount {
return {
id: String(row.id),
+ provider: String(row.provider) as CodeRouterProvider,
vaultRevision: Number(row.vaultRevision),
credentialExpiresAt: row.credentialExpiresAt instanceof Date
? row.credentialExpiresAt
@@ -939,7 +956,7 @@ export async function completeRefreshLease(input: {
.set({
state: "active",
vaultRevision: input.encrypted.credentialRevision,
- credentialExpiresAt: new Date(input.credential.expiresAt),
+ credentialExpiresAt: credentialExpiresAt(input.credential),
refreshLeaseId: null,
refreshLeaseExpiresAt: null,
lastFailureCode: null,
@@ -1043,12 +1060,6 @@ export async function withVaultLease(
}
}
-function credentialLabel(credential: CodeRouterCredential): string {
- return credential.email ||
- (credential.provider === "opencode-go" ? credential.orgName : undefined) ||
- credential.accountId;
-}
-
function encryptedValues(encrypted: EncryptedCredential) {
return {
accountId: encrypted.accountId,
diff --git a/web/services/coderouter/types.ts b/web/services/coderouter/types.ts
index 390c30485cac..5cb1e7d2432b 100644
--- a/web/services/coderouter/types.ts
+++ b/web/services/coderouter/types.ts
@@ -1,4 +1,26 @@
-export type CodeRouterProvider = "codex" | "opencode-go";
+export type CodeRouterProvider =
+ | "codex"
+ | "opencode-go"
+ | "openai-apikey"
+ | "openrouter-apikey";
+
+/** Providers whose credentials are OAuth tokens that expire and refresh. */
+export type CodeRouterOAuthProvider = "codex" | "opencode-go";
+
+/** Providers whose credential is one long-lived API key. */
+export type CodeRouterApiKeyProvider = "openai-apikey" | "openrouter-apikey";
+
+export const CODEROUTER_API_KEY_PROVIDERS: readonly CodeRouterApiKeyProvider[] = [
+ "openai-apikey",
+ "openrouter-apikey",
+];
+
+/** Every provider that can serve the OpenAI Responses surface (`/v1/responses`, `/v1/models`). */
+export const RESPONSES_PROVIDERS: readonly CodeRouterProvider[] = [
+ "codex",
+ "openai-apikey",
+ "openrouter-apikey",
+];
export type CodexCredential = {
readonly provider: "codex";
@@ -21,7 +43,58 @@ export type OpenCodeGoCredential = {
readonly expiresAt: number;
};
-export type CodeRouterCredential = CodexCredential | OpenCodeGoCredential;
+/**
+ * A pasted OpenAI or OpenRouter API key. `accountId` is a fingerprint of the
+ * key, so the same key added twice is one account and the row never carries
+ * the key itself. `label` is what the dashboard shows; the masked key is the
+ * fallback. API keys have no expiry, so there is no refresh token.
+ */
+export type ApiKeyCredential = {
+ readonly provider: CodeRouterApiKeyProvider;
+ readonly apiKey: string;
+ readonly accountId: string;
+ readonly label: string;
+};
+
+export type OAuthCredential = CodexCredential | OpenCodeGoCredential;
+
+export type CodeRouterCredential = OAuthCredential | ApiKeyCredential;
+
+export function isApiKeyCredential(
+ credential: CodeRouterCredential,
+): credential is ApiKeyCredential {
+ return credential.provider === "openai-apikey" || credential.provider === "openrouter-apikey";
+}
+
+export function isApiKeyProvider(
+ provider: CodeRouterProvider,
+): provider is CodeRouterApiKeyProvider {
+ return provider === "openai-apikey" || provider === "openrouter-apikey";
+}
+
+/** When the stored credential stops working on its own. API keys never do. */
+export function credentialExpiresAt(credential: CodeRouterCredential): Date | null {
+ return isApiKeyCredential(credential) ? null : new Date(credential.expiresAt);
+}
+
+/** The dashboard name for an account: the sign-in email, org, label, or a masked key. */
+export function credentialLabel(credential: CodeRouterCredential): string {
+ if (isApiKeyCredential(credential)) {
+ return credential.label || maskApiKey(credential.apiKey);
+ }
+ return credential.email ||
+ (credential.provider === "opencode-go" ? credential.orgName : undefined) ||
+ credential.accountId;
+}
+
+/** `sk-or-v1-…a1b2`: enough to tell keys apart, never enough to use one. */
+export function maskApiKey(apiKey: string): string {
+ const trimmed = apiKey.trim();
+ if (trimmed.length <= 12) return "…";
+ const prefixEnd = trimmed.lastIndexOf("-", 12);
+ const prefix = prefixEnd > 0 ? trimmed.slice(0, prefixEnd + 1) : trimmed.slice(0, 5);
+ return `${prefix}…${trimmed.slice(-4)}`;
+}
export type VaultAccount = {
readonly revision: number;
diff --git a/web/tests/coderouter-accounts.test.tsx b/web/tests/coderouter-accounts.test.tsx
index 70b75275ca9b..8a7cb17f5bc4 100644
--- a/web/tests/coderouter-accounts.test.tsx
+++ b/web/tests/coderouter-accounts.test.tsx
@@ -113,6 +113,8 @@ describe("coderouter accounts section", () => {
"Anthropic API key",
"Claude Code OAuth",
"Amazon Bedrock",
+ "OpenAI API key",
+ "OpenRouter API key",
"Codex",
"OpenCode",
]);
diff --git a/web/tests/coderouter-api-key-providers.test.ts b/web/tests/coderouter-api-key-providers.test.ts
new file mode 100644
index 000000000000..070e7b99aa6c
--- /dev/null
+++ b/web/tests/coderouter-api-key-providers.test.ts
@@ -0,0 +1,278 @@
+import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
+import { apiKeyFingerprint, parseCredential } from "../services/coderouter/accounts";
+import {
+ CodeRouterCredentialBroken,
+ createCredentialRefresher,
+ type CredentialRefreshDependencies,
+} from "../services/coderouter/refresh";
+import type { EncryptedCredential } from "../services/coderouter/encryption";
+import {
+ credentialExpiresAt,
+ credentialLabel,
+ maskApiKey,
+ type ApiKeyCredential,
+ type CodeRouterCredential,
+} from "../services/coderouter/types";
+
+type UpstreamCall = { url: string; headers: Headers; body: string };
+let upstreamCalls: UpstreamCall[] = [];
+let upstreamStatuses: number[] = [];
+let accountsToServe: { id: string; credential: CodeRouterCredential }[] = [];
+let cooldowns: { accountId: string; durationMs: number }[] = [];
+let forcedRefreshes: string[] = [];
+
+const originalFetch = globalThis.fetch;
+beforeAll(() => {
+ globalThis.fetch = mock(async (...args: unknown[]) => {
+ const input = args[0] as string | URL | Request;
+ const init = args[1] as RequestInit | undefined;
+ const request = input instanceof Request ? input : new Request(input, init);
+ upstreamCalls.push({
+ url: request.url,
+ headers: request.headers,
+ body: request.method === "POST" ? await request.text() : "",
+ });
+ const status = upstreamStatuses.shift() ?? 200;
+ return new Response("data: done\n\n", {
+ status,
+ headers: { "content-type": "text/event-stream" },
+ });
+ }) as typeof fetch;
+});
+afterAll(() => {
+ globalThis.fetch = originalFetch;
+});
+
+const { createCodexModelsProxy, createCodexResponsesProxy, openRouterModelId } = await import(
+ "../services/coderouter/codexProxy"
+);
+
+const openAiKey: ApiKeyCredential = {
+ provider: "openai-apikey",
+ apiKey: "sk-proj-0123456789abcdef0123456789abcdef",
+ accountId: "fp-openai",
+ label: "team openai",
+};
+const openRouterKey: ApiKeyCredential = {
+ provider: "openrouter-apikey",
+ apiKey: "sk-or-v1-0123456789abcdef0123456789abcdef",
+ accountId: "fp-openrouter",
+ label: "",
+};
+const codexSignIn: CodeRouterCredential = {
+ provider: "codex",
+ accessToken: "codex-access",
+ refreshToken: "codex-refresh",
+ idToken: "codex-id",
+ accountId: "chatgpt-account",
+ email: "person@example.com",
+ expiresAt: Date.now() + 60_000,
+};
+
+const authenticate = async () => ({ teamId: "team-1", stackUserId: "user-1", vmId: null });
+const credentialFor = () => {
+ const served = accountsToServe.find((account) => account.id === lastSelected);
+ if (!served) throw new Error("no credential for account");
+ return served.credential;
+};
+let lastSelected = "";
+const nextAccount = () => {
+ const next = accountsToServe[selectIndex++];
+ if (!next) return null;
+ lastSelected = next.id;
+ return { id: next.id, provider: next.credential.provider, vaultRevision: 1, credentialExpiresAt: null };
+};
+let selectIndex = 0;
+
+const responses = createCodexResponsesProxy({
+ authenticate,
+ select: async () => {
+ const account = nextAccount();
+ return account ? { ...account, sticky: false } : null;
+ },
+ credential: async (input) => {
+ const credential = credentialFor();
+ if (input.force) {
+ forcedRefreshes.push(input.accountId);
+ if (credential.provider === "openai-apikey" || credential.provider === "openrouter-apikey") {
+ // What the real refresher does: mark the key broken, then throw.
+ throw new CodeRouterCredentialBroken("provider rejected the API key");
+ }
+ }
+ return credential;
+ },
+ cooldown: async (accountId, durationMs) => {
+ cooldowns.push({ accountId, durationMs });
+ },
+});
+
+const models = createCodexModelsProxy({
+ authenticate,
+ select: async () => nextAccount(),
+ credential: async () => credentialFor(),
+ cooldown: async () => {},
+ providerRead: async (request) => await request(),
+});
+
+beforeEach(() => {
+ upstreamCalls = [];
+ upstreamStatuses = [];
+ accountsToServe = [];
+ cooldowns = [];
+ forcedRefreshes = [];
+ selectIndex = 0;
+ lastSelected = "";
+});
+
+function responsesRequest(body: unknown = { model: "gpt-5.3-codex", input: [] }): Request {
+ return new Request("https://coderouter.dev/v1/responses", {
+ method: "POST",
+ headers: {
+ authorization: "Bearer crt_token",
+ "content-type": "application/json",
+ session_id: "session-1",
+ "openai-beta": "responses=experimental",
+ },
+ body: JSON.stringify(body),
+ });
+}
+
+describe("API key credentials", () => {
+ test("parses a pasted key into a fingerprinted account with no secret in the id", () => {
+ const parsed = parseCredential({
+ provider: "openrouter-apikey",
+ apiKey: " sk-or-v1-0123456789abcdef0123456789abcdef ",
+ label: " personal ",
+ });
+ expect(parsed).toEqual({
+ provider: "openrouter-apikey",
+ apiKey: "sk-or-v1-0123456789abcdef0123456789abcdef",
+ accountId: apiKeyFingerprint("openrouter-apikey", "sk-or-v1-0123456789abcdef0123456789abcdef"),
+ label: "personal",
+ });
+ expect(parsed?.accountId).not.toContain("sk-or");
+ expect(parsed?.accountId).toHaveLength(24);
+ });
+
+ test("rejects short, malformed, or mistyped keys", () => {
+ expect(parseCredential({ provider: "openai-apikey", apiKey: "short" })).toBeNull();
+ expect(parseCredential({ provider: "openai-apikey", apiKey: "sk-proj-with spaces inside the key" })).toBeNull();
+ expect(parseCredential({ provider: "openai-apikey", apiKey: 42 })).toBeNull();
+ expect(parseCredential({ provider: "openai-apikey", apiKey: "sk-proj-0123456789abcdef", label: 1 })).toBeNull();
+ expect(parseCredential({ provider: "anthropic-apikey", apiKey: "sk-ant-0123456789abcdef" })).toBeNull();
+ });
+
+ test("labels fall back to a masked key and API keys never expire", () => {
+ expect(credentialLabel(openAiKey)).toBe("team openai");
+ expect(credentialLabel(openRouterKey)).toBe("sk-or-v1-…cdef");
+ expect(maskApiKey("sk-proj-0123456789abcdef0123456789abcdef")).toBe("sk-proj-…cdef");
+ expect(credentialExpiresAt(openAiKey)).toBeNull();
+ expect(credentialExpiresAt(codexSignIn)).toBeInstanceOf(Date);
+ });
+
+ test("the refresher returns an API key as-is, and a forced refresh marks the account broken", async () => {
+ let claims = 0;
+ let failed: { terminal: boolean; code: string } | null = null;
+ const envelope: EncryptedCredential = {
+ accountId: "00000000-0000-4000-8000-000000000001",
+ teamId: "team-1",
+ provider: "openai-apikey",
+ credentialRevision: 1,
+ algorithm: "aes-256-gcm",
+ ciphertext: "c",
+ nonce: "n",
+ authTag: "t",
+ encryptedDataKey: "k",
+ kmsKeyId: "kms",
+ };
+ const dependencies: CredentialRefreshDependencies = {
+ read: async () => ({ envelope, credential: openAiKey }),
+ decrypt: async () => openAiKey,
+ claim: async () => {
+ claims += 1;
+ return "lease";
+ },
+ release: async () => {},
+ refresh: async (credential) => credential,
+ encrypt: async () => envelope,
+ complete: async () => {},
+ fail: async (_accountId, _leaseId, terminal, code) => {
+ failed = { terminal, code };
+ },
+ isTerminal: () => false,
+ failureCode: () => "n/a",
+ };
+ const refresh = createCredentialRefresher(dependencies);
+ expect(await refresh({ teamId: "team-1", accountId: envelope.accountId, expectedRevision: 1 })).toBe(openAiKey);
+ expect(claims).toBe(0);
+ await expect(
+ refresh({ teamId: "team-1", accountId: envelope.accountId, expectedRevision: 1, force: true }),
+ ).rejects.toBeInstanceOf(CodeRouterCredentialBroken);
+ expect(claims).toBe(1);
+ expect(failed).toEqual({ terminal: true, code: "api_key_rejected" });
+ });
+});
+
+describe("responses routing through API keys", () => {
+ test("sends an OpenAI key to api.openai.com with a bearer header and no ChatGPT account header", async () => {
+ accountsToServe = [{ id: "acct-openai", credential: openAiKey }];
+ const response = await responses(responsesRequest());
+ expect(response.status).toBe(200);
+ expect(upstreamCalls).toHaveLength(1);
+ const call = upstreamCalls[0]!;
+ expect(call.url).toBe("https://api.openai.com/v1/responses");
+ expect(call.headers.get("authorization")).toBe(`Bearer ${openAiKey.apiKey}`);
+ expect(call.headers.get("chatgpt-account-id")).toBeNull();
+ expect(call.headers.get("session_id")).toBeNull();
+ expect(call.headers.get("openai-beta")).toBe("responses=experimental");
+ expect(JSON.parse(call.body)).toEqual({ model: "gpt-5.3-codex", input: [] });
+ });
+
+ test("sends an OpenRouter key to openrouter.ai and vendor-prefixes a bare model id", async () => {
+ accountsToServe = [{ id: "acct-or", credential: openRouterKey }];
+ const response = await responses(responsesRequest({ model: "gpt-5.3-codex", input: [], store: false }));
+ expect(response.status).toBe(200);
+ const call = upstreamCalls[0]!;
+ expect(call.url).toBe("https://openrouter.ai/api/v1/responses");
+ expect(call.headers.get("authorization")).toBe(`Bearer ${openRouterKey.apiKey}`);
+ expect(call.headers.get("x-title")).toBe("cmux coderouter");
+ expect(call.headers.get("openai-beta")).toBeNull();
+ expect(JSON.parse(call.body)).toEqual({ model: "openai/gpt-5.3-codex", input: [], store: false });
+ });
+
+ test("keeps a vendor-prefixed model id untouched for OpenRouter", () => {
+ expect(openRouterModelId("anthropic/claude-sonnet-4")).toBe("anthropic/claude-sonnet-4");
+ expect(openRouterModelId("gpt-5")).toBe("openai/gpt-5");
+ });
+
+ test("a Codex sign-in in the same pool still goes to the ChatGPT backend", async () => {
+ accountsToServe = [{ id: "acct-codex", credential: codexSignIn }];
+ await responses(responsesRequest());
+ const call = upstreamCalls[0]!;
+ expect(call.url).toBe("https://chatgpt.com/backend-api/codex/responses");
+ expect(call.headers.get("chatgpt-account-id")).toBe("chatgpt-account");
+ });
+
+ test("a rejected API key is treated as broken and the request fails over", async () => {
+ accountsToServe = [
+ { id: "acct-or", credential: openRouterKey },
+ { id: "acct-codex", credential: codexSignIn },
+ ];
+ upstreamStatuses = [401, 200];
+ const response = await responses(responsesRequest());
+ expect(response.status).toBe(200);
+ expect(forcedRefreshes).toEqual(["acct-or"]);
+ expect(cooldowns).toEqual([]);
+ expect(upstreamCalls.map((call) => new URL(call.url).host)).toEqual(["openrouter.ai", "chatgpt.com"]);
+ });
+
+ test("model discovery uses each provider's own catalog", async () => {
+ accountsToServe = [{ id: "acct-or", credential: openRouterKey }];
+ const listed = await models(new Request("https://coderouter.dev/v1/models?client_version=1", {
+ headers: { authorization: "Bearer crt_route" },
+ }));
+ expect(listed.status).toBe(200);
+ expect(upstreamCalls[0]!.url).toBe("https://openrouter.ai/api/v1/models");
+ expect(upstreamCalls[0]!.headers.get("authorization")).toBe(`Bearer ${openRouterKey.apiKey}`);
+ });
+});
diff --git a/web/tests/coderouter-models-proxy.test.ts b/web/tests/coderouter-models-proxy.test.ts
index 6e9637fe4708..c0735c73a17b 100644
--- a/web/tests/coderouter-models-proxy.test.ts
+++ b/web/tests/coderouter-models-proxy.test.ts
@@ -32,7 +32,7 @@ const proxyCodexModels = createCodexModelsProxy({
select: async () => {
const id = selectedAccounts.shift();
return id
- ? { id, vaultRevision: 1, credentialExpiresAt: new Date() }
+ ? { id, provider: "codex" as const, vaultRevision: 1, credentialExpiresAt: new Date() }
: null;
},
credential: async ({ accountId }) => {
diff --git a/web/tests/coderouter-opencode-proxy.test.ts b/web/tests/coderouter-opencode-proxy.test.ts
index 42211bba3bb7..ba826ec320a0 100644
--- a/web/tests/coderouter-opencode-proxy.test.ts
+++ b/web/tests/coderouter-opencode-proxy.test.ts
@@ -76,7 +76,7 @@ describe("coderouter OpenCode Go proxy", () => {
selected.push(...(excluded ?? []));
const id = ids.shift();
return id
- ? { id, vaultRevision: 1, credentialExpiresAt: new Date() }
+ ? { id, provider: "codex" as const, vaultRevision: 1, credentialExpiresAt: new Date() }
: null;
},
credential: async ({ accountId }) => {
@@ -119,6 +119,7 @@ describe("coderouter OpenCode Go proxy VM-bound route tokens", () => {
},
select: async () => ({
id: "acct-1",
+ provider: "codex" as const,
vaultRevision: 1,
credentialExpiresAt: new Date(),
}),
diff --git a/web/tests/coderouter-refresh.test.ts b/web/tests/coderouter-refresh.test.ts
index 254da6f21c3d..bd0bbb38f5cf 100644
--- a/web/tests/coderouter-refresh.test.ts
+++ b/web/tests/coderouter-refresh.test.ts
@@ -60,7 +60,7 @@ describe("coderouter credential refresh coordination", () => {
await didClaim;
await expect(refresh(input())).rejects.toBeInstanceOf(CodeRouterRefreshBusy);
releaseProvider();
- expect((await first).refreshToken).toBe("new-refresh");
+ expect(((await first) as CodexCredential).refreshToken).toBe("new-refresh");
});
test("an abandoned lease becomes claimable after expiry", async () => {
@@ -76,7 +76,7 @@ describe("coderouter credential refresh coordination", () => {
const refresh = createCredentialRefresher(dependencies);
await expect(refresh(input())).rejects.toBeInstanceOf(CodeRouterRefreshBusy);
now = 1_001;
- expect((await refresh(input())).accessToken).toBe("new-access");
+ expect(((await refresh(input())) as CodexCredential).accessToken).toBe("new-access");
});
test("persists a rotated provider refresh token at the next revision", async () => {
@@ -88,9 +88,9 @@ describe("coderouter credential refresh coordination", () => {
completed = value;
},
}));
- const result = await refresh(input());
+ const result = (await refresh(input())) as CodexCredential;
expect(result.refreshToken).toBe("new-refresh");
- expect(completed?.credential.refreshToken).toBe("new-refresh");
+ expect((completed?.credential as CodexCredential | undefined)?.refreshToken).toBe("new-refresh");
expect(completed?.encrypted.credentialRevision).toBe(2);
});
diff --git a/web/tests/coderouter-responses-proxy.test.ts b/web/tests/coderouter-responses-proxy.test.ts
index 6f8439297fdc..b7ff29ffd6cd 100644
--- a/web/tests/coderouter-responses-proxy.test.ts
+++ b/web/tests/coderouter-responses-proxy.test.ts
@@ -4,7 +4,7 @@ import { VM_PLACEHOLDER_API_KEY } from "../services/coderouter/routeTokenAuth";
type SelectInput = {
teamId: string;
- provider: string;
+ provider: string | readonly string[];
sessionKey: string | null;
excludedAccountIds?: readonly string[];
signal?: AbortSignal;
@@ -53,6 +53,7 @@ const proxy = createCodexResponsesProxy({
return next
? {
id: next.id,
+ provider: "codex" as const,
vaultRevision: 1,
credentialExpiresAt: null,
sticky: next.sticky,
@@ -113,7 +114,8 @@ describe("codex responses proxy session routing", () => {
expect(selectInputs).toHaveLength(1);
expect(selectInputs[0]?.sessionKey).toBe("session-abc");
expect(selectInputs[0]?.teamId).toBe("team-1");
- expect(selectInputs[0]?.provider).toBe("codex");
+ // The Responses surface pools Codex sign-ins with OpenAI and OpenRouter keys.
+ expect(selectInputs[0]?.provider).toEqual(["codex", "openai-apikey", "openrouter-apikey"]);
});
test("selects without a session key when the header is missing", async () => {
@@ -163,7 +165,7 @@ describe("codex responses proxy session routing", () => {
select: async () => {
const id = `acct-${selected.length + 1}`;
selected.push(id);
- return { id, vaultRevision: 1, credentialExpiresAt: null, sticky: false };
+ return { id, provider: "codex" as const, vaultRevision: 1, credentialExpiresAt: null, sticky: false };
},
credential: async ({ accountId }) => ({
provider: "codex" as const,
@@ -227,7 +229,7 @@ describe("codex responses proxy session routing", () => {
stackUserId: "stack-user-1",
vmId: null,
}),
- select: async () => ({ id: "acct-1", vaultRevision: 1, credentialExpiresAt: null, sticky: false }),
+ select: async () => ({ id: "acct-1", provider: "codex" as const, vaultRevision: 1, credentialExpiresAt: null, sticky: false }),
credential: async (input) => {
credentialSignal = (input as typeof input & { signal?: AbortSignal }).signal;
return await new Promise(() => undefined);
@@ -264,7 +266,7 @@ describe("codex responses proxy session routing", () => {
select: async () => {
const id = `acct-${selected.length + 1}`;
selected.push(id);
- return { id, vaultRevision: 1, credentialExpiresAt: null, sticky: false };
+ return { id, provider: "codex" as const, vaultRevision: 1, credentialExpiresAt: null, sticky: false };
},
credential: async ({ accountId }) => ({
provider: "codex" as const,
@@ -329,7 +331,7 @@ describe("codex models proxy outcomes", () => {
select: async () => {
if (selected) return null;
selected = true;
- return { id: "acct-1", vaultRevision: 1, credentialExpiresAt: null };
+ return { id: "acct-1", provider: "codex" as const, vaultRevision: 1, credentialExpiresAt: null };
},
credential: async () => ({
provider: "codex" as const,
diff --git a/web/tests/coderouter-session-selector.test.ts b/web/tests/coderouter-session-selector.test.ts
index 9f4b5014a744..8e46b631234e 100644
--- a/web/tests/coderouter-session-selector.test.ts
+++ b/web/tests/coderouter-session-selector.test.ts
@@ -5,8 +5,8 @@ import { createSessionAccountSelector } from "../services/coderouter/repository"
type Call = { fn: string; args: unknown[] };
function makeDependencies(options: {
- bound?: { id: string; vaultRevision: number; credentialExpiresAt: Date | null } | null;
- placed?: { id: string; vaultRevision: number; credentialExpiresAt: Date | null } | null;
+ bound?: { id: string; provider: "codex"; vaultRevision: number; credentialExpiresAt: Date | null } | null;
+ placed?: { id: string; provider: "codex"; vaultRevision: number; credentialExpiresAt: Date | null } | null;
}) {
const calls: Call[] = [];
return {
@@ -32,6 +32,7 @@ function makeDependencies(options: {
const account = (id: string) => ({
id,
+ provider: "codex" as const,
vaultRevision: 3,
credentialExpiresAt: null,
});
From 94d88ed71848048f07b753ff15a15383bdab706e Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 8 Sep 2026 00:19:10 -0700
Subject: [PATCH 06/12] web(dashboard): account menu order is settings, theme,
billing, team
Claude-Session: https://claude.ai/code/session_01LMP2oJjwAkLbLxLaWmLaLS
---
.../dashboard/dashboard-account-menu.tsx | 22 +++++++++----------
web/tests/dashboard-account-menu.test.tsx | 12 +++++-----
2 files changed, 18 insertions(+), 16 deletions(-)
diff --git a/web/app/[locale]/dashboard/dashboard-account-menu.tsx b/web/app/[locale]/dashboard/dashboard-account-menu.tsx
index 37a75ad2860d..68f51a9d0e04 100644
--- a/web/app/[locale]/dashboard/dashboard-account-menu.tsx
+++ b/web/app/[locale]/dashboard/dashboard-account-menu.tsx
@@ -72,17 +72,6 @@ export function DashboardAccountMenu() {
{t("settings")}
- } className={menuItemClass}>
-
- {t("billing")}
-
- {teamScope.status === "ready" ? (
-
- ) : null}
{theme.resolvedTheme === "dark" ? t("themeLight") : t("themeDark")}
+ } className={menuItemClass}>
+
+ {t("billing")}
+
+ {teamScope.status === "ready" ? (
+
+ ) : null}
{
resolvedTheme = "light";
const html = renderToStaticMarkup();
expect(html).toContain(">themeDark<");
- expect(html.indexOf(">themeDark<")).toBeGreaterThan(html.indexOf("/dashboard/billing"));
- expect(html.indexOf(">themeDark<")).toBeLessThan(html.indexOf("signOut"));
+ expect(html.indexOf(">themeDark<")).toBeGreaterThan(html.indexOf("/dashboard/team"));
+ expect(html.indexOf(">themeDark<")).toBeLessThan(html.indexOf("/dashboard/billing"));
});
test("lists every permitted team in a submenu and shows the current one on the trigger", () => {
@@ -147,9 +147,11 @@ describe("dashboard account menu", () => {
expect(submenu.match(/aria-checked="false"/g)).toHaveLength(2);
// The trigger row names the current team under the user's name.
expect(html.indexOf("Manaflow")).toBeLessThan(html.indexOf("/dashboard/team"));
- // The team entry sits after settings and billing, before sign out.
- expect(html.indexOf('data-testid="team-submenu"')).toBeGreaterThan(html.indexOf("/dashboard/billing"));
- expect(html.indexOf('data-testid="team-submenu"')).toBeLessThan(html.indexOf("signOut"));
+ // Order: settings, theme, billing, team, then sign out.
+ const order = ["/dashboard/team", ">themeLight<", "/dashboard/billing", 'data-testid="team-submenu"', "signOut"]
+ .map((marker) => html.indexOf(marker));
+ expect(order.every((index) => index >= 0)).toBe(true);
+ expect([...order].sort((a, b) => a - b)).toEqual(order);
});
test("uses the unlocalized auth handler and names the compact sign-in link", () => {
From 98d7ef1227e928084391a3e20b9a2cc9c35675b7 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 8 Sep 2026 00:19:33 -0700
Subject: [PATCH 07/12] web(dashboard): pin the theme in the menu order test
Claude-Session: https://claude.ai/code/session_01LMP2oJjwAkLbLxLaWmLaLS
---
web/tests/dashboard-account-menu.test.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/web/tests/dashboard-account-menu.test.tsx b/web/tests/dashboard-account-menu.test.tsx
index dd7c68a9ded8..1bdf8d4d0bf8 100644
--- a/web/tests/dashboard-account-menu.test.tsx
+++ b/web/tests/dashboard-account-menu.test.tsx
@@ -135,6 +135,7 @@ describe("dashboard account menu", () => {
{ id: "team-3", name: "Side project", personal: false, permissions: { use: true, manageAccounts: false } },
];
teamScope = { status: "ready", teams, selected: teams[1], switchTeam: () => undefined };
+ resolvedTheme = "dark";
const html = renderToStaticMarkup();
teamScope = { status: "unavailable" };
From e9ccefcca001cc4c991508b5f5a26ebf644ea714 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 8 Sep 2026 00:27:11 -0700
Subject: [PATCH 08/12] web(dashboard): address review findings on the accounts
page and menu
Base UI Tabs replace the hand-rolled tab buttons in the add panel, so
arrow keys and roving focus work. Team items in the account menu are a
Menu.RadioGroup, which owns the checked state. The accounts section is
keyed by team so a team switch never keeps a half-filled form. A team
switch drops only the team query parameter. A 503 from a Claude account
route shows the Claude save or remove error, not the shared-account
copy. The hosted-subrouter notice is shown to account managers only and
now says which accounts it is about. Test fixtures reset per test.
Claude-Session: https://claude.ai/code/session_01LMP2oJjwAkLbLxLaWmLaLS
---
.../[locale]/dashboard/coderouter/page.tsx | 1 +
.../components/coderouter-accounts.tsx | 86 ++++++++++---------
.../dashboard/dashboard-account-menu.tsx | 33 ++++---
.../dashboard/dashboard-team-scope.ts | 5 +-
web/messages/en.json | 4 +-
web/messages/ja.json | 4 +-
web/tests/coderouter-accounts.test.tsx | 18 +++-
web/tests/dashboard-account-menu.test.tsx | 9 ++
web/tests/dashboard-team-scope.test.tsx | 8 +-
9 files changed, 108 insertions(+), 60 deletions(-)
diff --git a/web/app/[locale]/dashboard/coderouter/page.tsx b/web/app/[locale]/dashboard/coderouter/page.tsx
index 00cd90f66e29..676b7f56cf14 100644
--- a/web/app/[locale]/dashboard/coderouter/page.tsx
+++ b/web/app/[locale]/dashboard/coderouter/page.tsx
@@ -179,6 +179,7 @@ export async function CoderouterOverviewContent({
/>
- {shared.kind === "notConfigured" ? (
+ {shared.kind === "notConfigured" && canManage ? (
) : null}
{shared.kind === "migrationPending" ? (
@@ -454,7 +455,10 @@ function SharedAccountActions({
{ method: "DELETE" },
);
if (!response.ok) {
- setStatus({ state: "error", message: errorMessageForStatus(response.status, t, t("removeError")) });
+ setStatus({
+ state: "error",
+ message: errorMessageForStatus(response.status, t, t("removeError"), t("notConfiguredTitle")),
+ });
return;
}
setStatus(idleStatus);
@@ -534,41 +538,40 @@ function AddAccountPanel({ teamId }: { readonly teamId: string }) {
return (
{t("addTitle")}
-
- {ADD_KINDS.map((candidate) => (
-
- ))}
-
-
- {kind === "codex" ? (
-
- ) : kind === "opencode" ? (
-
- ) : (
-
- )}
-
+
setKind(value as AddKind)}
+ className="mt-2"
+ >
+
+ {ADD_KINDS.map((candidate) => (
+
+ {addKindLabel(candidate, t)}
+
+ ))}
+
+
+ {kind === "codex" ? (
+
+ ) : kind === "opencode" ? (
+
+ ) : (
+
+ )}
+
+
);
}
@@ -785,9 +788,14 @@ function sharedKindLabel(kind: string, t: Translator): string {
}
}
-function errorMessageForStatus(status: number, t: Translator, fallback: string): string {
+function errorMessageForStatus(
+ status: number,
+ t: Translator,
+ fallback: string,
+ unavailable: string = fallback,
+): string {
if (status === 400) return t("validationError");
if (status === 403) return t("teamAccessError");
- if (status === 503) return t("notConfiguredTitle");
+ if (status === 503) return unavailable;
return fallback;
}
diff --git a/web/app/[locale]/dashboard/dashboard-account-menu.tsx b/web/app/[locale]/dashboard/dashboard-account-menu.tsx
index 68f51a9d0e04..4d4337761ec2 100644
--- a/web/app/[locale]/dashboard/dashboard-account-menu.tsx
+++ b/web/app/[locale]/dashboard/dashboard-account-menu.tsx
@@ -160,20 +160,25 @@ function TeamSubmenu({
- {teams.map((team) => (
- onSelect(team)}
- >
- {team.name}
- {team.personal ? (
- {t("personal")}
- ) : null}
- {team.id === selected.id ? : }
-
- ))}
+ {
+ const team = teams.find((candidate) => candidate.id === value);
+ if (team) onSelect(team);
+ }}
+ >
+ {teams.map((team) => (
+
+ {team.name}
+ {team.personal ? (
+ {t("personal")}
+ ) : null}
+
+
+
+
+ ))}
+
diff --git a/web/app/[locale]/dashboard/dashboard-team-scope.ts b/web/app/[locale]/dashboard/dashboard-team-scope.ts
index 2b71ac2b9e55..6c5fba3a46e5 100644
--- a/web/app/[locale]/dashboard/dashboard-team-scope.ts
+++ b/web/app/[locale]/dashboard/dashboard-team-scope.ts
@@ -70,7 +70,10 @@ export function useDashboardTeamScope(userId: string | null): DashboardTeamScope
);
if (searchParams.has("team")) {
// A deep-linked team in the URL would keep overriding the new scope.
- router.replace(pathname);
+ const next = new URLSearchParams(searchParams.toString());
+ next.delete("team");
+ const query = next.toString();
+ router.replace(query ? `${pathname}?${query}` : pathname);
}
router.refresh();
};
diff --git a/web/messages/en.json b/web/messages/en.json
index d24c9aceefa7..9a38d1767a98 100644
--- a/web/messages/en.json
+++ b/web/messages/en.json
@@ -240,8 +240,8 @@
"accountsCount": "{count, plural, one {# account} other {# accounts}}",
"emptyTitle": "No accounts yet",
"emptyBody": "Requests from this team fail until an account is added.",
- "notConfiguredTitle": "Codex accounts are hidden on this deployment",
- "notConfiguredBody": "The hosted account service is not configured here, so Codex accounts cannot be listed or added. Anthropic and Bedrock accounts still work.",
+ "notConfiguredTitle": "Accounts shared from the cmux app are not listed here",
+ "notConfiguredBody": "The hosted account service is not configured on this deployment. Accounts added with cr add, and Anthropic and Bedrock accounts, still work.",
"migrationPendingTitle": "Shared accounts temporarily unavailable",
"migrationPendingBody": "Shared Codex accounts are temporarily unavailable. Try again shortly.",
"loadErrorTitle": "Some accounts could not load",
diff --git a/web/messages/ja.json b/web/messages/ja.json
index 726875668659..cca7da9bd8ef 100644
--- a/web/messages/ja.json
+++ b/web/messages/ja.json
@@ -240,8 +240,8 @@
"accountsCount": "{count} 件のアカウント",
"emptyTitle": "まだアカウントがありません",
"emptyBody": "アカウントを追加するまで、このチームからのリクエストは失敗します。",
- "notConfiguredTitle": "このデプロイでは Codex アカウントは表示されません",
- "notConfiguredBody": "ホスト型アカウントサービスが設定されていないため、Codex アカウントの一覧表示と追加はできません。Anthropic と Bedrock のアカウントは引き続き使えます。",
+ "notConfiguredTitle": "cmux アプリから共有されたアカウントはここには表示されません",
+ "notConfiguredBody": "このデプロイではホスト型アカウントサービスが設定されていません。cr add で追加したアカウントと、Anthropic および Bedrock のアカウントは引き続き使えます。",
"migrationPendingTitle": "共有アカウントを一時的に利用できません",
"migrationPendingBody": "共有 Codex アカウントは一時的に利用できません。しばらくしてからもう一度お試しください。",
"loadErrorTitle": "一部のアカウントを読み込めませんでした",
diff --git a/web/tests/coderouter-accounts.test.tsx b/web/tests/coderouter-accounts.test.tsx
index 70b75275ca9b..2228ec055987 100644
--- a/web/tests/coderouter-accounts.test.tsx
+++ b/web/tests/coderouter-accounts.test.tsx
@@ -109,6 +109,8 @@ describe("coderouter accounts section", () => {
);
const tabs = [...html.matchAll(/role="tab"[^>]*>([^<]+) match[1]);
+ expect(html).toContain('role="tablist"');
+ expect(html).toContain('role="tabpanel"');
expect(tabs).toEqual([
"Anthropic API key",
"Claude Code OAuth",
@@ -131,11 +133,25 @@ describe("coderouter accounts section", () => {
/>,
);
- expect(html).not.toContain('role="tab"');
+ expect(html).not.toContain('role="tablist"');
expect(html).not.toContain(">Remove<");
expect(html).not.toContain(">Disable<");
});
+ test("hides the deployment notice from members who cannot manage accounts", () => {
+ const render = (canManage: boolean) => renderToStaticMarkup(
+
,
+ );
+ expect(render(true)).toContain("not listed here");
+ expect(render(false)).not.toContain("not listed here");
+ });
+
test("keeps the loaded provider visible when the other one fails", () => {
const html = renderToStaticMarkup(
({
),
}));
+let radioGroupValue = "";
mock.module("@base-ui-components/react/menu", () => ({
Menu: {
Root: ({ children }: { children: React.ReactNode }) => {children}
,
@@ -39,6 +40,14 @@ mock.module("@base-ui-components/react/menu", () => ({
: ,
Separator: () =>
,
SubmenuRoot: ({ children }: { children: React.ReactNode }) => {children}
,
+ RadioGroup: ({ children, value }: { children: React.ReactNode; value: string }) => {
+ radioGroupValue = value;
+ return {children}
;
+ },
+ RadioItem: ({ children, value, ...props }: React.HTMLAttributes & { value: string }) => (
+ {children}
+ ),
+ RadioItemIndicator: ({ children }: { children: React.ReactNode }) => {children},
SubmenuTrigger: ({ children, ...props }: React.HTMLAttributes) => (
),
diff --git a/web/tests/dashboard-team-scope.test.tsx b/web/tests/dashboard-team-scope.test.tsx
index 101fd9975457..0fb0248351e6 100644
--- a/web/tests/dashboard-team-scope.test.tsx
+++ b/web/tests/dashboard-team-scope.test.tsx
@@ -1,4 +1,4 @@
-import { describe, expect, mock, test } from "bun:test";
+import { beforeEach, describe, expect, mock, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
type Catalog = {
@@ -72,6 +72,12 @@ const twoTeams: Catalog = {
};
describe("dashboard team scope", () => {
+ beforeEach(() => {
+ catalog = twoTeams;
+ pending = false;
+ searchTeam = null;
+ });
+
test("exposes the persisted team as current and only permitted teams", () => {
catalog = twoTeams;
pending = false;
From e4598ca49dabbd5e4f82ff2068b4f97df28c9b0f Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 8 Sep 2026 00:32:52 -0700
Subject: [PATCH 09/12] web(dashboard): style the active add tab with Base UI's
data-active
Claude-Session: https://claude.ai/code/session_01LMP2oJjwAkLbLxLaWmLaLS
---
web/app/[locale]/dashboard/components/coderouter-accounts.tsx | 2 +-
web/tests/coderouter-accounts.test.tsx | 4 ++++
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/web/app/[locale]/dashboard/components/coderouter-accounts.tsx b/web/app/[locale]/dashboard/components/coderouter-accounts.tsx
index add5694af551..3ef9919a26e1 100644
--- a/web/app/[locale]/dashboard/components/coderouter-accounts.tsx
+++ b/web/app/[locale]/dashboard/components/coderouter-accounts.tsx
@@ -548,7 +548,7 @@ function AddAccountPanel({ teamId }: { readonly teamId: string }) {
{addKindLabel(candidate, t)}
diff --git a/web/tests/coderouter-accounts.test.tsx b/web/tests/coderouter-accounts.test.tsx
index 2228ec055987..d344da15103c 100644
--- a/web/tests/coderouter-accounts.test.tsx
+++ b/web/tests/coderouter-accounts.test.tsx
@@ -111,6 +111,10 @@ describe("coderouter accounts section", () => {
const tabs = [...html.matchAll(/role="tab"[^>]*>([^<]+) match[1]);
expect(html).toContain('role="tablist"');
expect(html).toContain('role="tabpanel"');
+ // Base UI marks the current tab with data-active; the selected styles key off it.
+ const activeTab = html.match(/