diff --git a/drizzle/migrations/0010_lumpy_sentinels.sql b/drizzle/migrations/0010_lumpy_sentinels.sql
new file mode 100644
index 0000000..3070666
--- /dev/null
+++ b/drizzle/migrations/0010_lumpy_sentinels.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `provider_accounts` ADD `enabled` integer DEFAULT true NOT NULL;--> statement-breakpoint
+CREATE INDEX `provider_accounts_enabled_idx` ON `provider_accounts` (`provider`,`enabled`);
\ No newline at end of file
diff --git a/drizzle/migrations/meta/0010_snapshot.json b/drizzle/migrations/meta/0010_snapshot.json
new file mode 100644
index 0000000..1e84a8a
--- /dev/null
+++ b/drizzle/migrations/meta/0010_snapshot.json
@@ -0,0 +1,506 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "fc6d4302-8d10-4426-9c31-23a01a8769ae",
+ "prevId": "3a673cab-a634-493e-8839-ecf31d02774c",
+ "tables": {
+ "api_keys": {
+ "name": "api_keys",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "models_discovery_token": {
+ "name": "models_discovery_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider_scope_json": {
+ "name": "provider_scope_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "model_scope_json": {
+ "name": "model_scope_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "account_scope_json": {
+ "name": "account_scope_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "api_keys_key_unique": {
+ "name": "api_keys_key_unique",
+ "columns": ["key"],
+ "isUnique": true
+ },
+ "api_keys_models_discovery_token_unique": {
+ "name": "api_keys_models_discovery_token_unique",
+ "columns": ["models_discovery_token"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "oauth_states": {
+ "name": "oauth_states",
+ "columns": {
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "pkce_verifier": {
+ "name": "pkce_verifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata_json": {
+ "name": "metadata_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "provider_accounts": {
+ "name": "provider_accounts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "is_primary": {
+ "name": "is_primary",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_lock_token": {
+ "name": "refresh_lock_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_lock_expires_at": {
+ "name": "refresh_lock_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metadata_json": {
+ "name": "metadata_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_refresh_at": {
+ "name": "last_refresh_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_refresh_status": {
+ "name": "last_refresh_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "provider_accounts_provider_idx": {
+ "name": "provider_accounts_provider_idx",
+ "columns": ["provider"],
+ "isUnique": false
+ },
+ "provider_accounts_enabled_idx": {
+ "name": "provider_accounts_enabled_idx",
+ "columns": ["provider", "enabled"],
+ "isUnique": false
+ },
+ "provider_accounts_primary_idx": {
+ "name": "provider_accounts_primary_idx",
+ "columns": ["provider", "is_primary"],
+ "isUnique": false
+ },
+ "provider_accounts_primary_unique": {
+ "name": "provider_accounts_primary_unique",
+ "columns": ["provider"],
+ "isUnique": true,
+ "where": "\"provider_accounts\".\"is_primary\" = 1"
+ },
+ "provider_accounts_provider_account_unique": {
+ "name": "provider_accounts_provider_account_unique",
+ "columns": ["provider", "account_id"],
+ "isUnique": true,
+ "where": "\"provider_accounts\".\"account_id\" is not null"
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "request_usage_buckets": {
+ "name": "request_usage_buckets",
+ "columns": {
+ "bucket_start": {
+ "name": "bucket_start",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "api_key_id": {
+ "name": "api_key_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_account_id": {
+ "name": "provider_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "request_count": {
+ "name": "request_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "success_count": {
+ "name": "success_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "client_error_count": {
+ "name": "client_error_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "server_error_count": {
+ "name": "server_error_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "auth_error_count": {
+ "name": "auth_error_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "rate_limit_count": {
+ "name": "rate_limit_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "proxy_error_count": {
+ "name": "proxy_error_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "upstream_error_count": {
+ "name": "upstream_error_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "total_latency_ms": {
+ "name": "total_latency_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "max_latency_ms": {
+ "name": "max_latency_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "input_tokens": {
+ "name": "input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "output_tokens": {
+ "name": "output_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "cache_read_tokens": {
+ "name": "cache_read_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "cache_write_tokens": {
+ "name": "cache_write_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "last_request_at": {
+ "name": "last_request_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "request_usage_buckets_key_bucket_idx": {
+ "name": "request_usage_buckets_key_bucket_idx",
+ "columns": ["api_key_id", "bucket_start"],
+ "isUnique": false
+ },
+ "request_usage_buckets_account_bucket_idx": {
+ "name": "request_usage_buckets_account_bucket_idx",
+ "columns": ["provider_account_id", "bucket_start"],
+ "isUnique": false
+ },
+ "request_usage_buckets_bucket_idx": {
+ "name": "request_usage_buckets_bucket_idx",
+ "columns": ["bucket_start"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "request_usage_buckets_bucket_start_api_key_id_provider_account_id_provider_endpoint_model_pk": {
+ "columns": [
+ "bucket_start",
+ "api_key_id",
+ "provider_account_id",
+ "provider",
+ "endpoint",
+ "model"
+ ],
+ "name": "request_usage_buckets_bucket_start_api_key_id_provider_account_id_provider_endpoint_model_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json
index fb077e6..cbf8f7d 100644
--- a/drizzle/migrations/meta/_journal.json
+++ b/drizzle/migrations/meta/_journal.json
@@ -71,6 +71,13 @@
"when": 1773857211769,
"tag": "0009_jittery_silver_centurion",
"breakpoints": true
+ },
+ {
+ "idx": 10,
+ "version": "6",
+ "when": 1784655400876,
+ "tag": "0010_lumpy_sentinels",
+ "breakpoints": true
}
]
}
diff --git a/public/admin/app-data.js b/public/admin/app-data.js
index f34570d..eac9df1 100644
--- a/public/admin/app-data.js
+++ b/public/admin/app-data.js
@@ -26,6 +26,7 @@ const state = {
token: readPersistedToken(),
accounts: [],
accountsById: new Map(),
+ providerStatuses: [],
accountUsageById: new Map(),
accountUsageWindowMs: DEFAULT_KEY_USAGE_WINDOW_MS,
keys: [],
@@ -510,7 +511,7 @@ function renderAccountScopeOptions(mode, selectedAccountIds = []) {
${escapeHtml(account.label || account.accountId || shortId(account.id))}
${account.isPrimary ? 'primary' : ""}
- ${escapeHtml([shortId(account.id), account.expiresAt ? expiryCountdown(account.expiresAt) : ""].filter(Boolean).join(" · "))}
+ ${escapeHtml([shortId(account.id), account.enabled === false ? "disabled" : "", account.expiresAt ? expiryCountdown(account.expiresAt) : ""].filter(Boolean).join(" · "))}
`
)
@@ -621,6 +622,7 @@ async function loadAccounts() {
state.accounts = accountsResult.value.accounts || [];
state.accountsById = usageMapFromList(state.accounts, "id");
+ state.providerStatuses = accountsResult.value.providers || [];
if (usageResult.status === "fulfilled") {
if (typeof usageResult.value.windowMs === "number") {
@@ -648,6 +650,8 @@ async function loadAccounts() {
state.accounts = [];
state.accountsById = new Map();
state.accountUsageById = new Map();
+ state.providerStatuses = [];
+ $("#providers-status").innerHTML = "";
$("#accounts-list").innerHTML =
`
`;
toast(e.message, "error");
@@ -716,6 +720,39 @@ async function setPrimary(id) {
}
}
+async function toggleProvider(provider, enabled) {
+ if (!enabled) {
+ const confirmed = await showConfirm(
+ "Disable Provider",
+ `Disable ${provider}? All ${provider} accounts will stop receiving proxy traffic and its models will disappear from model registries. You can re-enable it at any time.`,
+ "disable"
+ );
+ if (!confirmed) return;
+ }
+
+ const btn = document.querySelector(
+ `[data-action="toggle-provider"][data-provider="${provider}"]`
+ );
+ if (btn) {
+ btn.disabled = true;
+ btn.innerHTML = '';
+ }
+ try {
+ await api(`/admin/accounts/providers/${provider}`, {
+ method: "PATCH",
+ body: JSON.stringify({ enabled }),
+ });
+ toast(enabled ? `${provider} enabled` : `${provider} disabled`);
+ await loadAccounts();
+ } catch (e) {
+ toast(e.message, "error");
+ if (btn) {
+ btn.disabled = false;
+ btn.textContent = enabled ? "enable" : "disable";
+ }
+ }
+}
+
async function deleteAccount(id) {
const account = accountById(id);
const name = account?.label || account?.accountId || id;
@@ -1229,6 +1266,7 @@ function logout() {
state.token = "";
state.accounts = [];
state.accountsById = new Map();
+ state.providerStatuses = [];
state.accountUsageById = new Map();
state.accountUsageWindowMs = DEFAULT_KEY_USAGE_WINDOW_MS;
state.keys = [];
@@ -1245,6 +1283,7 @@ function logout() {
state.dashboardRequestSeq = 0;
$("#oauth-flow-active").style.display = "none";
$("#oauth-flow-active").innerHTML = "";
+ $("#providers-status").innerHTML = "";
$("#dash-content").innerHTML = "";
syncDashboardWindowButtons();
syncAccountWindowButtons();
@@ -1307,6 +1346,7 @@ export {
syncDashboardWindowButtons,
syncScopedAccountAvailability,
toast,
+ toggleProvider,
tokenStatus,
updateOAuthProviderUI,
usageForKey,
diff --git a/public/admin/app-render.js b/public/admin/app-render.js
index 56d0036..67e4dd5 100644
--- a/public/admin/app-render.js
+++ b/public/admin/app-render.js
@@ -27,8 +27,45 @@ import {
usageWindowLabel,
} from "./app-data.js";
+function providerStatusItemHtml(status) {
+ const { provider, enabled, accountCount, enabledAccountCount } = status;
+ const countLabel =
+ enabled && enabledAccountCount < accountCount
+ ? `${enabledAccountCount}/${accountCount} enabled`
+ : `${accountCount} account${accountCount === 1 ? "" : "s"}`;
+
+ return `
+ ${provider}
+
+
+ ${enabled ? "enabled" : "disabled"}
+
+ ${countLabel}
+
+
+
`;
+}
+
+function renderProviderStatuses() {
+ const statuses = (state.providerStatuses || []).filter(
+ (status) => status.accountCount > 0
+ );
+ $("#providers-status").innerHTML = statuses.length
+ ? statuses.map(providerStatusItemHtml).join("")
+ : "";
+}
+
function accountCardHtml(account) {
- const s = tokenStatus(account.expiresAt);
+ const disabled = account.enabled === false;
+ const s = disabled
+ ? { label: "disabled", class: "unknown" }
+ : tokenStatus(account.expiresAt);
const name = account.label || account.accountId || account.id;
const usage = accountUsageForId(account.id);
const windowLabel = usageWindowLabel(state.accountUsageWindowMs);
@@ -58,12 +95,13 @@ function accountCardHtml(account) {
const meta = metadataHtml(account.metadata);
- return `
+ return `
${account.provider}
${escapeHtml(name)}
${account.isPrimary ? 'primary' : ""}
+ ${disabled ? 'disabled' : ""}
${editBtn}
@@ -88,6 +126,7 @@ function accountCardHtml(account) {
function renderAccounts() {
const { accounts } = state;
+ renderProviderStatuses();
$("#accounts-count").textContent = accounts.length
? `(${accounts.length})`
: "";
diff --git a/public/admin/app.js b/public/admin/app.js
index b9c37f3..29544f9 100644
--- a/public/admin/app.js
+++ b/public/admin/app.js
@@ -34,6 +34,7 @@ import {
syncKeyWindowButtons,
syncScopedAccountAvailability,
toast,
+ toggleProvider,
updateOAuthProviderUI,
verifyToken,
} from "./app-data.js";
@@ -45,6 +46,12 @@ import {
setupSnippet,
} from "./app-render.js";
+$("#providers-status").addEventListener("click", (e) => {
+ const button = e.target.closest('button[data-action="toggle-provider"]');
+ if (!button) return;
+ toggleProvider(button.dataset.provider, button.dataset.enabled === "true");
+});
+
$("#accounts-list").addEventListener("click", (e) => {
const button = e.target.closest("button[data-action]");
if (button) {
diff --git a/public/admin/index.html b/public/admin/index.html
index 4bd8673..3da7663 100644
--- a/public/admin/index.html
+++ b/public/admin/index.html
@@ -172,6 +172,7 @@
+
diff --git a/public/admin/styles.css b/public/admin/styles.css
index 1571a25..fcf0444 100644
--- a/public/admin/styles.css
+++ b/public/admin/styles.css
@@ -442,6 +442,48 @@ body {
color: var(--text-tertiary);
background: var(--bg-elevated);
}
+.badge-disabled {
+ color: var(--red);
+ background: var(--red-dim);
+}
+
+.provider-status-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.provider-status-row:not(:empty) {
+ margin-bottom: 16px;
+}
+.provider-status-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 12px;
+ background: var(--bg-surface);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-lg);
+}
+.provider-status-item.disabled {
+ border-color: var(--red-dim);
+}
+.provider-status-item.disabled .badge {
+ opacity: 0.6;
+}
+.provider-status-meta {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 11px;
+ color: var(--text-secondary);
+}
+
+.card-disabled {
+ opacity: 0.65;
+}
+.card-disabled:hover {
+ opacity: 1;
+}
.status-dot {
width: 7px;
diff --git a/src/db/repositories/provider-accounts.ts b/src/db/repositories/provider-accounts.ts
index 50967ec..db4caa2 100644
--- a/src/db/repositories/provider-accounts.ts
+++ b/src/db/repositories/provider-accounts.ts
@@ -1,7 +1,7 @@
import { and, desc, eq, gt, inArray, isNull, lte, or } from "drizzle-orm";
import type { Database } from "../index";
-import { providerAccounts, type Provider } from "../schema";
+import { providerAccounts, providers, type Provider } from "../schema";
import {
parseProviderAccountMetadata,
serializeProviderAccountMetadata,
@@ -14,6 +14,7 @@ export type ProviderAccountRecord = {
label: string | null;
accountId: string | null;
isPrimary: boolean;
+ enabled: boolean;
accessToken: string;
refreshToken: string;
refreshLockToken: string | null;
@@ -34,6 +35,7 @@ const toRecord = (
label: row.label,
accountId: row.accountId,
isPrimary: row.isPrimary,
+ enabled: row.enabled,
accessToken: row.accessToken,
refreshToken: row.refreshToken,
refreshLockToken: row.refreshLockToken,
@@ -67,7 +69,12 @@ export const listConfiguredProviders = async (
provider: providerAccounts.provider,
})
.from(providerAccounts)
- .where(eq(providerAccounts.isPrimary, true));
+ .where(
+ and(
+ eq(providerAccounts.isPrimary, true),
+ eq(providerAccounts.enabled, true)
+ )
+ );
return rows.map((row) => row.provider);
};
@@ -119,7 +126,8 @@ export const findPrimaryProviderAccount = async (
const row = await database.query.providerAccounts.findFirst({
where: and(
eq(providerAccounts.provider, provider),
- eq(providerAccounts.isPrimary, true)
+ eq(providerAccounts.isPrimary, true),
+ eq(providerAccounts.enabled, true)
),
orderBy: desc(providerAccounts.createdAt),
});
@@ -163,6 +171,16 @@ const hasPrimaryProviderAccount = async (
return Boolean(primary);
};
+const isProviderEnabledForNewAccount = async (
+ database: Database,
+ provider: Provider
+): Promise
=> {
+ const account = await database.query.providerAccounts.findFirst({
+ where: eq(providerAccounts.provider, provider),
+ });
+ return account?.enabled ?? true;
+};
+
const isUniqueConstraintError = (error: unknown): boolean =>
error instanceof Error &&
error.message.toLowerCase().includes("unique constraint failed");
@@ -236,12 +254,17 @@ export const upsertProviderAccount = async (
database,
input.provider
));
+ const enabled = await isProviderEnabledForNewAccount(
+ database,
+ input.provider
+ );
const insertValues: typeof providerAccounts.$inferInsert = {
id: crypto.randomUUID(),
provider: input.provider,
label: input.label === undefined ? null : input.label,
accountId: input.accountId,
isPrimary,
+ enabled,
accessToken: input.accessToken,
refreshToken: input.refreshToken,
refreshLockToken: null,
@@ -620,3 +643,53 @@ export const setPrimaryProviderAccount = async (
throw error;
}
};
+
+export type ProviderStatus = {
+ provider: Provider;
+ enabled: boolean;
+ accountCount: number;
+ enabledAccountCount: number;
+};
+
+export const listProviderStatuses = async (
+ database: Database
+): Promise => {
+ const rows = await database
+ .select({
+ provider: providerAccounts.provider,
+ enabled: providerAccounts.enabled,
+ })
+ .from(providerAccounts);
+
+ return providers.map((provider) => {
+ const accounts = rows.filter((row) => row.provider === provider);
+ const enabledAccountCount = accounts.filter((row) => row.enabled).length;
+ return {
+ provider,
+ enabled: enabledAccountCount > 0,
+ accountCount: accounts.length,
+ enabledAccountCount,
+ };
+ });
+};
+
+export const setProviderAccountsEnabled = async (
+ database: Database,
+ provider: Provider,
+ enabled: boolean,
+ now: number
+): Promise => {
+ await database.transaction(async (tx) => {
+ await tx
+ .update(providerAccounts)
+ .set({ enabled, updatedAt: now })
+ .where(eq(providerAccounts.provider, provider));
+ });
+
+ const statuses = await listProviderStatuses(database);
+ const status = statuses.find((item) => item.provider === provider);
+ if (!status) {
+ throw new Error(`Unknown provider: ${provider}`);
+ }
+ return status;
+};
diff --git a/src/db/schema.ts b/src/db/schema.ts
index c383886..7bc14f0 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -21,6 +21,7 @@ export const providerAccounts = sqliteTable(
isPrimary: integer("is_primary", { mode: "boolean" })
.notNull()
.default(false),
+ enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
accessToken: text("access_token").notNull(),
refreshToken: text("refresh_token").notNull(),
refreshLockToken: text("refresh_lock_token"),
@@ -36,6 +37,7 @@ export const providerAccounts = sqliteTable(
},
(table) => [
index("provider_accounts_provider_idx").on(table.provider),
+ index("provider_accounts_enabled_idx").on(table.provider, table.enabled),
index("provider_accounts_primary_idx").on(table.provider, table.isPrimary),
uniqueIndex("provider_accounts_primary_unique")
.on(table.provider)
diff --git a/src/domain/providers/provider-service.ts b/src/domain/providers/provider-service.ts
index f477216..9500754 100644
--- a/src/domain/providers/provider-service.ts
+++ b/src/domain/providers/provider-service.ts
@@ -356,7 +356,7 @@ export const getRoutableProviderAccount = async (
const account = pickPreferredProviderAccount(
(await findProviderAccountsByIds(database, allowedAccountIds)).filter(
- (candidate) => candidate.provider === provider
+ (candidate) => candidate.provider === provider && candidate.enabled
)
);
if (!account) {
diff --git a/src/http/routes/admin-accounts.ts b/src/http/routes/admin-accounts.ts
index 44afdf2..15660ac 100644
--- a/src/http/routes/admin-accounts.ts
+++ b/src/http/routes/admin-accounts.ts
@@ -17,6 +17,8 @@ import {
deleteProviderAccount,
findProviderAccountById,
listProviderAccounts,
+ listProviderStatuses,
+ setProviderAccountsEnabled,
setPrimaryProviderAccount,
type ProviderAccountRecord,
updateProviderAccountProfile,
@@ -40,6 +42,10 @@ const oauthProviderParamsSchema = z.strictObject({
provider: z.enum(providers),
});
+const updateProviderStatusBodySchema = z.strictObject({
+ enabled: z.boolean(),
+});
+
const oauthStartBodySchema = z.strictObject({
options: z.record(z.string(), z.unknown()).optional(),
});
@@ -116,6 +122,7 @@ const toAdminAccountView = (
label: account.label,
accountId: account.accountId,
isPrimary: account.isPrimary,
+ enabled: account.enabled,
metadata: account.metadata,
expiresAt: account.expiresAt,
lastRefreshAt: account.lastRefreshAt,
@@ -126,9 +133,35 @@ const toAdminAccountView = (
export const adminAccountsRoutes = new Hono()
.get("/", async (context) => {
- const accounts = await listProviderAccounts(db);
- return context.json({ accounts: accounts.map(toAdminAccountView) });
+ const [accounts, providerStatuses] = await Promise.all([
+ listProviderAccounts(db),
+ listProviderStatuses(db),
+ ]);
+ return context.json({
+ accounts: accounts.map(toAdminAccountView),
+ providers: providerStatuses,
+ });
+ })
+ .get("/providers", async (context) => {
+ const providerStatuses = await listProviderStatuses(db);
+ return context.json({ providers: providerStatuses });
})
+ .patch(
+ "/providers/:provider",
+ zValidator("param", oauthProviderParamsSchema),
+ zValidator("json", updateProviderStatusBodySchema),
+ async (context) => {
+ const { provider } = context.req.valid("param");
+ const { enabled } = context.req.valid("json");
+ const status = await setProviderAccountsEnabled(
+ db,
+ provider,
+ enabled,
+ Date.now()
+ );
+ return context.json({ provider: status, updated: true });
+ }
+ )
.get(
"/usage",
zValidator("query", usageWindowQuerySchema),
diff --git a/tests/db/provider-accounts.test.ts b/tests/db/provider-accounts.test.ts
new file mode 100644
index 0000000..944465e
--- /dev/null
+++ b/tests/db/provider-accounts.test.ts
@@ -0,0 +1,138 @@
+import { createClient } from "@libsql/client";
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { drizzle } from "drizzle-orm/libsql";
+import { migrate } from "drizzle-orm/libsql/migrator";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import type { Database } from "../../src/db";
+import {
+ findPrimaryProviderAccount,
+ listConfiguredProviders,
+ listProviderAccounts,
+ listProviderStatuses,
+ setProviderAccountsEnabled,
+ upsertProviderAccount,
+} from "../../src/db/repositories/provider-accounts";
+import { providerAccounts } from "../../src/db/schema";
+import * as schema from "../../src/db/schema";
+import { getRoutableProviderAccount } from "../../src/domain/providers/provider-service";
+
+describe("provider account enablement", () => {
+ let client: ReturnType | undefined;
+ let database: Database;
+ let databaseDirectory: string;
+
+ beforeEach(async () => {
+ databaseDirectory = await mkdtemp(
+ join(tmpdir(), "kleis-provider-accounts-")
+ );
+ const databasePath = join(databaseDirectory, "test.db");
+ client = createClient({ url: `file:${databasePath}` });
+ database = drizzle(client, { schema });
+ await migrate(database, { migrationsFolder: "./drizzle/migrations" });
+
+ const now = Date.now();
+ await database.insert(providerAccounts).values([
+ {
+ id: "copilot-primary",
+ provider: "copilot",
+ isPrimary: true,
+ accessToken: "access-primary",
+ refreshToken: "refresh-primary",
+ expiresAt: now + 60_000,
+ createdAt: now,
+ updatedAt: now,
+ },
+ {
+ id: "copilot-secondary",
+ provider: "copilot",
+ isPrimary: false,
+ accessToken: "access-secondary",
+ refreshToken: "refresh-secondary",
+ expiresAt: now + 60_000,
+ createdAt: now - 1,
+ updatedAt: now,
+ },
+ {
+ id: "codex-primary",
+ provider: "codex",
+ isPrimary: true,
+ accessToken: "access-codex",
+ refreshToken: "refresh-codex",
+ expiresAt: now + 60_000,
+ createdAt: now,
+ updatedAt: now,
+ },
+ ]);
+ });
+
+ afterEach(async () => {
+ client?.close();
+ await rm(databaseDirectory, { recursive: true, force: true }).catch(
+ () => undefined
+ );
+ });
+
+ test("disables every account and excludes the provider from discovery and routing", async () => {
+ const now = Date.now();
+ const status = await setProviderAccountsEnabled(
+ database,
+ "copilot",
+ false,
+ now
+ );
+
+ expect(status).toEqual({
+ provider: "copilot",
+ enabled: false,
+ accountCount: 2,
+ enabledAccountCount: 0,
+ });
+ expect(
+ (await listProviderAccounts(database))
+ .filter((account) => account.provider === "copilot")
+ .every((account) => !account.enabled)
+ ).toBe(true);
+ expect(await listConfiguredProviders(database)).toEqual(["codex"]);
+ expect(await findPrimaryProviderAccount(database, "copilot")).toBeNull();
+ expect(
+ await getRoutableProviderAccount(database, "copilot", now)
+ ).toBeNull();
+ expect(
+ await getRoutableProviderAccount(database, "copilot", now, {
+ allowedAccountIds: ["copilot-secondary"],
+ })
+ ).toBeNull();
+ });
+
+ test("new accounts inherit disabled state and re-enabling restores routing", async () => {
+ const now = Date.now();
+ await setProviderAccountsEnabled(database, "copilot", false, now);
+
+ const created = await upsertProviderAccount(database, {
+ provider: "copilot",
+ accountId: "new-account",
+ accessToken: "access-new",
+ refreshToken: "refresh-new",
+ expiresAt: now + 60_000,
+ metadata: null,
+ now,
+ });
+ expect(created.enabled).toBe(false);
+
+ const status = await setProviderAccountsEnabled(
+ database,
+ "copilot",
+ true,
+ now + 1
+ );
+ expect(status.enabledAccountCount).toBe(3);
+ expect(await listConfiguredProviders(database)).toContain("copilot");
+ expect(
+ (await getRoutableProviderAccount(database, "copilot", now + 1))?.id
+ ).toBe("copilot-primary");
+ expect(await listProviderStatuses(database)).toContainEqual(status);
+ });
+});