diff --git a/.env.example b/.env.example index 437f1f4bb..2ec0d886a 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,7 @@ DB_NAME=claude_code_hub # 应用配置 APP_PORT=23000 +NODE_OPTIONS=--max-old-space-size=1536 APP_URL= # 应用访问地址(留空自动检测,生产环境建议显式配置) # 示例:https://your-domain.com 或 http://192.168.1.100:23000 diff --git a/.gitignore b/.gitignore index 1ec9ea7d4..755fd7983 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,5 @@ tmp/ .omx/ .opencode/ bunx-*/ +# Local runtime artifacts +.runtime/ diff --git a/Dockerfile b/Dockerfile index 8576fbc54..263413ff8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,10 @@ ENV NODE_ENV=production ENV PORT=3000 EXPOSE 3000 +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + # 关键:确保复制了所有必要的文件,特别是 drizzle 文件夹 COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ diff --git a/README.en.md b/README.en.md index aa19ab2cc..bf42bc25c 100644 --- a/README.en.md +++ b/README.en.md @@ -208,6 +208,7 @@ Once started: - **Admin Dashboard**: `http://localhost:23000` (login with `ADMIN_TOKEN` from `.env`) - **API Docs (Scalar UI)**: `http://localhost:23000/api/actions/scalar` - **API Docs (Swagger UI)**: `http://localhost:23000/api/actions/docs` +- **Temporary key group API examples**: [docs/examples/temporary-key-groups.md](docs/examples/temporary-key-groups.md) > 💡 **Tip**: To change the port, edit the `ports` section in `docker-compose.yml`. diff --git a/README.md b/README.md index 5a1a96ac4..b5b2054d3 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,7 @@ Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -Force - **API 文档(Swagger UI)**:`http://localhost:23000/api/actions/docs` - **公开状态 API**:[docs/public-status-api.md](docs/public-status-api.md) - **API 认证指南**:[docs/api-authentication-guide.md](docs/api-authentication-guide.md) +- **临时 Key 分组 API 示例**:[docs/examples/temporary-key-groups.md](docs/examples/temporary-key-groups.md) > 💡 **提示**: > - 如需修改端口,请编辑 `docker-compose.yml` 中的 `ports` 配置。 diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index 7162c413f..5f418e753 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -52,6 +52,8 @@ services: condition: service_healthy environment: NODE_ENV: production + HOST: 0.0.0.0 + HOSTNAME: 0.0.0.0 DSN: postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-claude_code_hub} REDIS_URL: redis://redis:6379 AUTO_MIGRATE: ${AUTO_MIGRATE:-true} @@ -62,7 +64,13 @@ services: ports: - "${APP_PORT:-23000}:3000" healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:3000/api/actions/health || exit 1"] + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/api/actions/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] interval: 15s timeout: 5s retries: 20 diff --git a/docker-compose.yaml b/docker-compose.yaml index a1fff2abe..3b3112a9b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -56,6 +56,9 @@ services: - ./.env environment: NODE_ENV: production + NODE_OPTIONS: ${NODE_OPTIONS:---max-old-space-size=1536} + HOST: 0.0.0.0 + HOSTNAME: 0.0.0.0 # 容器内使用 Dockerfile 默认端口 3000,对外通过 APP_PORT 暴露(默认 23000) DSN: postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-claude_code_hub} REDIS_URL: redis://redis:6379 @@ -77,7 +80,7 @@ services: "CMD", "node", "-e", - "fetch('http://' + (process.env.HOSTNAME || '127.0.0.1') + ':3000/api/actions/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + "fetch('http://127.0.0.1:3000/api/actions/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", ] interval: 30s timeout: 5s diff --git a/docs/examples/api-key-quota-extractor-compatible.js b/docs/examples/api-key-quota-extractor-compatible.js new file mode 100644 index 000000000..03b956c67 --- /dev/null +++ b/docs/examples/api-key-quota-extractor-compatible.js @@ -0,0 +1,90 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const toNumber = function(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + const round2 = function(value) { + return Math.round(value * 100) / 100; + }; + + const percent = function(used, total) { + return total > 0 ? round2((used / total) * 100) : null; + }; + + const quotaWindows = data.quotaWindows && typeof data.quotaWindows === "object" + ? data.quotaWindows + : {}; + const fiveHour = quotaWindows.fiveHour || {}; + const daily = quotaWindows.daily || {}; + const weekly = quotaWindows.weekly || {}; + const monthly = quotaWindows.monthly || {}; + const total = quotaWindows.total || {}; + + const limitMonthlyUsd = toNumber(data.limitMonthlyUsd, null); + const limitTotalUsd = toNumber(data.limitTotalUsd, limitMonthlyUsd); + const usedTotalUsd = toNumber(data.usedTotalUsd, toNumber(data.usedMonthlyUsd, 0)); + const remainingTotalUsd = limitTotalUsd === null ? null : round2(Math.max(limitTotalUsd - usedTotalUsd, 0)); + + const limitDailyUsd = toNumber(data.limitDailyUsd, null); + const usedDailyUsd = toNumber(data.usedDailyUsd, 0); + const remainingDailyUsd = limitDailyUsd === null ? null : round2(Math.max(limitDailyUsd - usedDailyUsd, 0)); + + const limit5hUsd = toNumber(data.limit5hUsd, null); + const used5hUsd = toNumber(data.used5hUsd, 0); + const remaining5hUsd = limit5hUsd === null ? null : round2(Math.max(limit5hUsd - used5hUsd, 0)); + + const isValid = + response && + response.ok === true && + toBoolean(data.keyIsEnabled, true) && + toBoolean(data.userIsEnabled, true); + + return { + isValid: !!isValid, + invalidMessage: isValid ? undefined : "套餐不可用", + planName: "Total Quota", + unit: typeof data.unit === "string" ? data.unit : "USD", + remaining: toNumber(total.remainingUsd, remainingTotalUsd), + total: toNumber(total.limitUsd, limitTotalUsd), + used: toNumber(total.usedUsd, usedTotalUsd), + todayUsed: toNumber(data.todayUsedUsd, toNumber(daily.usedUsd, usedDailyUsd)), + todayRemaining: toNumber(data.todayRemainingUsd, toNumber(daily.remainingUsd, remainingDailyUsd)), + todayUsedPercent: toNumber(data.todayUsedPercent, toNumber(daily.usedPercent, percent(usedDailyUsd, limitDailyUsd))), + todayRemainingPercent: toNumber( + data.todayRemainingPercent, + toNumber(daily.remainingPercent, percent(remainingDailyUsd || 0, limitDailyUsd)) + ), + remaining5h: toNumber(fiveHour.remainingUsd, remaining5hUsd), + remainingDaily: toNumber(daily.remainingUsd, remainingDailyUsd), + remainingWeekly: toNumber(weekly.remainingUsd, toNumber(data.remainingWeeklyUsd, null)), + remainingMonthly: toNumber(monthly.remainingUsd, toNumber(data.remainingMonthlyUsd, null)), + remainingTotal: toNumber(total.remainingUsd, remainingTotalUsd), + remainingPercent: toNumber(total.remainingPercent, data.remainingPercent), + extra: "5H剩余:" + toNumber(fiveHour.remainingPercent, percent(remaining5hUsd || 0, limit5hUsd)) + "%" + + "/日剩余:" + toNumber(daily.remainingPercent, percent(remainingDailyUsd || 0, limitDailyUsd)) + "%" + + "/周剩余:" + toNumber(weekly.remainingPercent, null) + "%" + + "/月剩余:" + toNumber(monthly.remainingPercent, null) + "%" + + "/总剩余:" + toNumber(total.remainingPercent, data.remainingPercent) + "%" + }; + } +}) diff --git a/docs/examples/api-key-quota-extractor-daily.js b/docs/examples/api-key-quota-extractor-daily.js new file mode 100644 index 000000000..ad84ce24f --- /dev/null +++ b/docs/examples/api-key-quota-extractor-daily.js @@ -0,0 +1,48 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const toNumber = function(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + const quotaWindows = data.quotaWindows && typeof data.quotaWindows === "object" + ? data.quotaWindows + : {}; + const daily = quotaWindows.daily || {}; + + return { + ok: response && response.ok === true, + isValid: toBoolean(data.keyIsEnabled, true) && toBoolean(data.userIsEnabled, true), + planName: "Daily Quota", + remaining: toNumber(daily.remainingUsd, toNumber(data.remainingDailyUsd, null)), + total: toNumber(daily.limitUsd, toNumber(data.limitDailyUsd, null)), + used: toNumber(daily.usedUsd, toNumber(data.usedDailyUsd, 0)), + usedPercent: toNumber(daily.usedPercent, data.todayUsedPercent), + remainingPercent: toNumber(daily.remainingPercent, data.todayRemainingPercent), + unit: typeof data.unit === "string" ? data.unit : "USD", + keyName: typeof data.keyName === "string" ? data.keyName : null, + userName: typeof data.userName === "string" ? data.userName : null, + providerGroup: typeof data.providerGroup === "string" ? data.providerGroup : null, + resetMode: typeof data.resetMode === "string" ? data.resetMode : null, + resetTime: typeof data.resetTime === "string" ? data.resetTime : null + }; + } +}) diff --git a/docs/examples/api-key-quota-extractor-direct.js b/docs/examples/api-key-quota-extractor-direct.js new file mode 100644 index 000000000..405f849f3 --- /dev/null +++ b/docs/examples/api-key-quota-extractor-direct.js @@ -0,0 +1,61 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + const quotaWindows = data.quotaWindows && typeof data.quotaWindows === "object" + ? data.quotaWindows + : {}; + const fiveHour = quotaWindows.fiveHour || {}; + const daily = quotaWindows.daily || {}; + const weekly = quotaWindows.weekly || {}; + const monthly = quotaWindows.monthly || {}; + const total = quotaWindows.total || {}; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + const isValid = + response && + response.ok === true && + toBoolean(data.keyIsEnabled, true) && + toBoolean(data.userIsEnabled, true); + + return { + isValid: !!isValid, + invalidMessage: isValid ? undefined : "套餐不可用", + planName: "Total Quota", + unit: typeof data.unit === "string" ? data.unit : "USD", + remaining: total.remainingUsd, + total: total.limitUsd, + used: total.usedUsd, + todayUsed: data.todayUsedUsd, + todayRemaining: data.todayRemainingUsd, + todayUsedPercent: data.todayUsedPercent, + todayRemainingPercent: data.todayRemainingPercent, + remaining5h: fiveHour.remainingUsd, + remainingDaily: daily.remainingUsd, + remainingWeekly: weekly.remainingUsd, + remainingMonthly: monthly.remainingUsd, + remainingTotal: total.remainingUsd, + remainingPercent: data.remainingPercent, + extra: "5H剩余:" + fiveHour.remainingPercent + "%" + + "/日剩余:" + daily.remainingPercent + "%" + + "/周剩余:" + weekly.remainingPercent + "%" + + "/月剩余:" + monthly.remainingPercent + "%" + + "/总剩余:" + total.remainingPercent + "%" + }; + } +}) diff --git a/docs/examples/api-key-quota-extractor-total.js b/docs/examples/api-key-quota-extractor-total.js new file mode 100644 index 000000000..119741f56 --- /dev/null +++ b/docs/examples/api-key-quota-extractor-total.js @@ -0,0 +1,48 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const toNumber = function(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + const quotaWindows = data.quotaWindows && typeof data.quotaWindows === "object" + ? data.quotaWindows + : {}; + const total = quotaWindows.total || {}; + + return { + ok: response && response.ok === true, + isValid: toBoolean(data.keyIsEnabled, true) && toBoolean(data.userIsEnabled, true), + planName: "Total Quota", + remaining: toNumber(total.remainingUsd, toNumber(data.remainingTotalUsd, null)), + total: toNumber(total.limitUsd, toNumber(data.limitTotalUsd, null)), + used: toNumber(total.usedUsd, toNumber(data.usedTotalUsd, 0)), + usedPercent: toNumber(total.usedPercent, null), + remainingPercent: toNumber(total.remainingPercent, data.remainingPercent), + unit: typeof data.unit === "string" ? data.unit : "USD", + keyName: typeof data.keyName === "string" ? data.keyName : null, + userName: typeof data.userName === "string" ? data.userName : null, + providerGroup: typeof data.providerGroup === "string" ? data.providerGroup : null, + resetMode: typeof data.resetMode === "string" ? data.resetMode : null, + resetTime: typeof data.resetTime === "string" ? data.resetTime : null + }; + } +}) diff --git a/docs/examples/api-key-quota-extractor-weekly.js b/docs/examples/api-key-quota-extractor-weekly.js new file mode 100644 index 000000000..d6222ec4a --- /dev/null +++ b/docs/examples/api-key-quota-extractor-weekly.js @@ -0,0 +1,48 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const toNumber = function(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + const quotaWindows = data.quotaWindows && typeof data.quotaWindows === "object" + ? data.quotaWindows + : {}; + const weekly = quotaWindows.weekly || {}; + + return { + ok: response && response.ok === true, + isValid: toBoolean(data.keyIsEnabled, true) && toBoolean(data.userIsEnabled, true), + planName: "Weekly Quota", + remaining: toNumber(weekly.remainingUsd, toNumber(data.remainingWeeklyUsd, null)), + total: toNumber(weekly.limitUsd, toNumber(data.limitWeeklyUsd, null)), + used: toNumber(weekly.usedUsd, toNumber(data.usedWeeklyUsd, 0)), + usedPercent: toNumber(weekly.usedPercent, null), + remainingPercent: toNumber(weekly.remainingPercent, null), + unit: typeof data.unit === "string" ? data.unit : "USD", + keyName: typeof data.keyName === "string" ? data.keyName : null, + userName: typeof data.userName === "string" ? data.userName : null, + providerGroup: typeof data.providerGroup === "string" ? data.providerGroup : null, + resetMode: typeof data.resetMode === "string" ? data.resetMode : null, + resetTime: typeof data.resetTime === "string" ? data.resetTime : null + }; + } +}) diff --git a/docs/examples/api-key-quota-extractor.js b/docs/examples/api-key-quota-extractor.js new file mode 100644 index 000000000..be8470731 --- /dev/null +++ b/docs/examples/api-key-quota-extractor.js @@ -0,0 +1,63 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const toNumber = function(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + const quotaWindows = data.quotaWindows && typeof data.quotaWindows === "object" + ? data.quotaWindows + : {}; + const fiveHour = quotaWindows.fiveHour || {}; + const daily = quotaWindows.daily || {}; + const weekly = quotaWindows.weekly || {}; + const monthly = quotaWindows.monthly || {}; + const total = quotaWindows.total || {}; + + const isValid = + response && + response.ok === true && + toBoolean(data.keyIsEnabled, true) && + toBoolean(data.userIsEnabled, true); + + return { + isValid: !!isValid, + invalidMessage: isValid ? undefined : "套餐不可用", + remaining: toNumber(total.remainingUsd, toNumber(data.remainingTotalUsd, null)), + unit: typeof data.unit === "string" ? data.unit : "USD", + planName: "Total Quota", + total: toNumber(total.limitUsd, toNumber(data.limitTotalUsd, null)), + used: toNumber(total.usedUsd, toNumber(data.usedTotalUsd, 0)), + todayUsed: toNumber(data.todayUsedUsd, toNumber(daily.usedUsd, 0)), + todayRemaining: toNumber(data.todayRemainingUsd, toNumber(daily.remainingUsd, null)), + remainingWeekly: toNumber(weekly.remainingUsd, toNumber(data.remainingWeeklyUsd, null)), + remainingMonthly: toNumber(monthly.remainingUsd, toNumber(data.remainingMonthlyUsd, null)), + remainingTotal: toNumber(total.remainingUsd, toNumber(data.remainingTotalUsd, null)), + remaining5h: toNumber(fiveHour.remainingUsd, toNumber(data.remaining5hUsd, null)), + remainingDaily: toNumber(daily.remainingUsd, toNumber(data.remainingDailyUsd, null)), + extra: "5H剩余:" + toNumber(fiveHour.remainingPercent, data.todayRemainingPercent) + "%" + + "/日剩余:" + toNumber(daily.remainingPercent, data.todayRemainingPercent) + "%" + + "/周剩余:" + toNumber(weekly.remainingPercent, null) + "%" + + "/月剩余:" + toNumber(monthly.remainingPercent, null) + "%" + + "/总剩余:" + toNumber(total.remainingPercent, data.remainingPercent) + "%" + }; + } +}) diff --git a/docs/examples/temporary-key-groups.md b/docs/examples/temporary-key-groups.md new file mode 100644 index 000000000..ae4260f5c --- /dev/null +++ b/docs/examples/temporary-key-groups.md @@ -0,0 +1,78 @@ +# Temporary Key Groups API Examples + +These examples use the `/api/actions/*` endpoints. Authenticate with an admin token or an +admin session cookie. + +## Public Status + +No auth is required for public status. + +```bash +curl -sS http://localhost:23000/api/system-status +curl -sS http://localhost:23000/api/status +``` + +## Create A Temporary Key Group + +`baseKeyId` must belong to `userId`. The created keys inherit routing and limits from the base +key. The group name is derived from the user's provider group. + +```bash +curl -sS -X POST http://localhost:23000/api/actions/keys/createTemporaryKeysBatch \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer YOUR_ADMIN_TOKEN' \ + -d '{ + "userId": 10, + "baseKeyId": 100, + "count": 5, + "customLimitTotalUsd": 20 + }' +``` + +## Download A Temporary Key Group + +The response data is plain text with one key per line. + +```bash +curl -sS -X POST http://localhost:23000/api/actions/keys/downloadTemporaryKeyGroup \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer YOUR_ADMIN_TOKEN' \ + -d '{ + "userId": 10, + "groupName": "default" + }' +``` + +## Remove A Temporary Key Group + +The action refuses to remove the group if it would delete the user's last enabled key. + +```bash +curl -sS -X POST http://localhost:23000/api/actions/keys/removeTemporaryKeyGroup \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer YOUR_ADMIN_TOKEN' \ + -d '{ + "userId": 10, + "groupName": "default" + }' +``` + +## Sync User Limits To Keys + +This saves the user fields and distributes user-level limits across all undeleted keys for that +user. + +```bash +curl -sS -X POST http://localhost:23000/api/actions/users/syncUserConfigToKeys \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer YOUR_ADMIN_TOKEN' \ + -d '{ + "userId": 10, + "dailyQuota": 100, + "limitTotalUsd": 900, + "limitConcurrentSessions": 3, + "providerGroup": "default", + "dailyResetMode": "rolling", + "dailyResetTime": "18:30" + }' +``` diff --git a/drizzle/0099_powerful_micromax.sql b/drizzle/0099_powerful_micromax.sql new file mode 100644 index 000000000..3915d47b0 --- /dev/null +++ b/drizzle/0099_powerful_micromax.sql @@ -0,0 +1,3 @@ +ALTER TABLE "keys" ADD COLUMN IF NOT EXISTS "temporary_group_name" varchar(120);--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN IF NOT EXISTS "cost_multiplier_correction" numeric(10, 4) DEFAULT '0' NOT NULL;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_keys_user_temporary_group" ON "keys" USING btree ("user_id","temporary_group_name") WHERE "keys"."deleted_at" IS NULL AND "keys"."temporary_group_name" IS NOT NULL; diff --git a/drizzle/meta/0099_snapshot.json b/drizzle/meta/0099_snapshot.json new file mode 100644 index 000000000..99e8d3775 --- /dev/null +++ b/drizzle/meta/0099_snapshot.json @@ -0,0 +1,4506 @@ +{ + "id": "056fdf38-2928-4662-a847-448055aa2634", + "prevId": "6014bb32-638d-4ca1-bb4b-16d9f3fe0e01", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "action_category": { + "name": "action_category", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "before_value": { + "name": "before_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_value": { + "name": "after_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operator_user_id": { + "name": "operator_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_user_name": { + "name": "operator_user_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_key_id": { + "name": "operator_key_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_key_name": { + "name": "operator_key_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_ip": { + "name": "operator_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_log_category_created_at": { + "name": "idx_audit_log_category_created_at", + "columns": [ + { + "expression": "action_category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_user_created_at": { + "name": "idx_audit_log_operator_user_created_at", + "columns": [ + { + "expression": "operator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_ip_created_at": { + "name": "idx_audit_log_operator_ip_created_at", + "columns": [ + { + "expression": "operator_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_target": { + "name": "idx_audit_log_target", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"target_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_created_at_id": { + "name": "idx_audit_log_created_at_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "temporary_group_name": { + "name": "temporary_group_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_user_temporary_group": { + "name": "idx_keys_user_temporary_group", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "temporary_group_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"keys\".\"deleted_at\" IS NULL AND \"keys\".\"temporary_group_name\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "cost_breakdown": { + "name": "cost_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "actual_response_model": { + "name": "actual_response_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_finalized_active": { + "name": "idx_message_request_provider_created_at_finalized_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_user_info": { + "name": "idx_message_request_session_user_info", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_client_ip_created_at": { + "name": "idx_message_request_client_ip_created_at", + "columns": [ + { + "expression": "client_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"client_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "cache_hit_rate_alert_enabled": { + "name": "cache_hit_rate_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cache_hit_rate_alert_webhook": { + "name": "cache_hit_rate_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cache_hit_rate_alert_window_mode": { + "name": "cache_hit_rate_alert_window_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "cache_hit_rate_alert_check_interval": { + "name": "cache_hit_rate_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cache_hit_rate_alert_historical_lookback_days": { + "name": "cache_hit_rate_alert_historical_lookback_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "cache_hit_rate_alert_min_eligible_requests": { + "name": "cache_hit_rate_alert_min_eligible_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 20 + }, + "cache_hit_rate_alert_min_eligible_tokens": { + "name": "cache_hit_rate_alert_min_eligible_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_hit_rate_alert_abs_min": { + "name": "cache_hit_rate_alert_abs_min", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "cache_hit_rate_alert_drop_rel": { + "name": "cache_hit_rate_alert_drop_rel", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.3'" + }, + "cache_hit_rate_alert_drop_abs": { + "name": "cache_hit_rate_alert_drop_abs", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.1'" + }, + "cache_hit_rate_alert_cooldown_minutes": { + "name": "cache_hit_rate_alert_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cache_hit_rate_alert_top_n": { + "name": "cache_hit_rate_alert_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_groups": { + "name": "provider_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_groups_name_unique": { + "name": "provider_groups_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disable_session_reuse": { + "name": "disable_session_reuse", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "active_time_start": { + "name": "active_time_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "active_time_end": { + "name": "active_time_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_service_tier_preference": { + "name": "codex_service_tier_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rule_mode": { + "name": "rule_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'simple'" + }, + "execution_phase": { + "name": "execution_phase", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'guard'" + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_phase": { + "name": "idx_request_filters_phase", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_phase", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "codex_priority_billing_source": { + "name": "codex_priority_billing_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "cost_multiplier_correction": { + "name": "cost_multiplier_correction", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pass_through_upstream_error_message": { + "name": "pass_through_upstream_error_message", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_high_concurrency_mode": { + "name": "enable_high_concurrency_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_input_rectifier": { + "name": "enable_response_input_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_non_conversation_endpoint_provider_fallback": { + "name": "allow_non_conversation_endpoint_provider_fallback", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "ip_extraction_config": { + "name": "ip_extraction_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_geo_lookup_enabled": { + "name": "ip_geo_lookup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "public_status_window_hours": { + "name": "public_status_window_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "public_status_aggregation_interval_minutes": { + "name": "public_status_aggregation_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "final_provider_id": { + "name": "final_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "actual_response_model": { + "name": "actual_response_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_success": { + "name": "is_success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "success_rate_outcome": { + "name": "success_rate_outcome", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_usage_ledger_request_id": { + "name": "idx_usage_ledger_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_created_at": { + "name": "idx_usage_ledger_user_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at": { + "name": "idx_usage_ledger_key_created_at", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_created_at": { + "name": "idx_usage_ledger_provider_created_at", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_minute": { + "name": "idx_usage_ledger_created_at_minute", + "columns": [ + { + "expression": "date_trunc('minute', \"created_at\" AT TIME ZONE 'UTC')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_desc_id": { + "name": "idx_usage_ledger_created_at_desc_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_session_id": { + "name": "idx_usage_ledger_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_model": { + "name": "idx_usage_ledger_model", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_cost": { + "name": "idx_usage_ledger_key_cost", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_cost_cover": { + "name": "idx_usage_ledger_user_cost_cover", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_cost_cover": { + "name": "idx_usage_ledger_provider_cost_cover", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at_desc_cover": { + "name": "idx_usage_ledger_key_created_at_desc_cover", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_5h_cost_reset_at": { + "name": "limit_5h_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert", + "cache_hit_rate_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 579a42619..5e91690e6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -694,6 +694,13 @@ "when": 1776965161943, "tag": "0098_equal_selene", "breakpoints": true + }, + { + "idx": 99, + "version": "7", + "when": 1777122360882, + "tag": "0099_powerful_micromax", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 21db4cc9d..81c652d2b 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -778,6 +778,7 @@ "usageLogs": "Usage Logs", "leaderboard": "Leaderboard", "availability": "Availability", + "systemStatus": "System Status", "myQuota": "My Quota", "quotasManagement": "Quotas", "userManagement": "Users", @@ -1539,7 +1540,8 @@ "disabled": "Disabled" }, "actions": { - "addKey": "Add Key" + "addKey": "Add Key", + "addTemporaryKey": "Create Temp Keys" } }, "keyFullDisplay": { @@ -1622,6 +1624,68 @@ "createKeyTitle": "Create Key", "editKeyTitle": "Edit Key" }, + "temporaryKeys": { + "createDialog": { + "title": "Generate Temporary Keys", + "description": "Select a base key and generate temporary API keys under the current user group.", + "baseKeyLabel": "Base Key", + "baseKeyPlaceholder": "Select a base key", + "baseKeyRequired": "Please select a base key", + "groupNameLabel": "User Group", + "groupNamePlaceholder": "For example: campaign-april", + "quantityLabel": "Quantity", + "quantityDescription": "Quick generate 5 / 10 / 20 / 50 / 100 keys, or enter a custom amount.", + "customLimitLabel": "Per-key Total Limit (USD)", + "customLimitPlaceholder": "Leave blank to keep the base key total limit", + "customLimitDescription": "Only the total limit is overridden. All other settings follow the base key.", + "baseKeyRequiredHint": "Select a base key before generating.", + "baseKeyRequiredShort": "Select a base key", + "submit": "Generate", + "submitting": "Generating...", + "noKeys": "This user has no available base key", + "invalidCount": "Please enter a valid quantity", + "invalidLimit": "Please enter a valid total limit", + "genericError": "Failed to generate temporary keys" + }, + "success": { + "title": "Temporary Keys Ready", + "description": "Group {group} now contains {count} temporary keys. Download the full keys now and store them safely.", + "groupName": "User Group", + "sourceKey": "Base Key", + "previewTitle": "Preview (first 5)", + "download": "Download Keys" + }, + "listActions": { + "showMore": "Show remaining {count}", + "showLess": "Collapse" + }, + "sections": { + "standard": { + "title": "Standard Keys", + "description": "Primary business keys with the existing behavior unchanged.", + "empty": "No standard keys yet" + }, + "temporary": { + "title": "Temporary Keys", + "description": "Extra temporary CDKey groups for batch create, download, and delete under the current user group.", + "empty": "No temporary key groups yet" + } + }, + "groups": { + "title": "Temporary Key Groups", + "groupBadge": "Temp", + "count": "{count} keys", + "download": "Download Keys", + "delete": "Delete Group" + }, + "toasts": { + "createSuccess": "Generated {count} temporary keys", + "createFailed": "Failed to generate temporary keys: {error}", + "deleteSuccess": "Deleted group {group} ({count} keys)", + "deleteFailed": "Failed to delete temporary key group: {error}", + "downloadFailed": "Failed to download temporary keys: {error}" + } + }, "editDialog": { "title": "Edit user", "description": "Edit user information", @@ -1639,6 +1703,12 @@ "deleteFailed": "Failed to delete user", "userDeleted": "User has been deleted", "saving": "Saving...", + "syncKeys": { + "button": "Sync to Keys", + "loading": "Syncing...", + "success": "Synced to {count} keys", + "error": "Failed to sync keys" + }, "resetSection": { "title": "Reset Options" }, @@ -1697,6 +1767,8 @@ "title": "Confirm Batch Update", "description": "This will update {users} users and {keys} keys. This action cannot be undone.", "userFields": "User Fields", + "syncKeys": "Sync to Keys", + "syncKeysDescription": "This will overwrite all undeleted keys for these {users} users from their current user settings.", "keyFields": "Key Fields", "goBack": "Go Back", "update": "Confirm Update", @@ -1705,8 +1777,10 @@ "toast": { "usersUpdated": "Updated {count} users", "keysUpdated": "Updated {count} keys", + "keysSynced": "Synced {keys} keys for {users} users", "usersFailed": "User update failed: {error}", "keysFailed": "Key update failed: {error}", + "syncFailed": "Sync to keys failed: {error}", "batchFailed": "Batch update failed" }, "validation": { @@ -1728,12 +1802,14 @@ "limit5h": "5h Limit (USD)", "limitDaily": "Daily Limit (USD)", "limitWeekly": "Weekly Limit (USD)", - "limitMonthly": "Monthly Limit (USD)" + "limitMonthly": "Monthly Limit (USD)", + "syncKeys": "Sync to Keys" }, "placeholders": { "emptyToClear": "Leave empty to clear", "tagsPlaceholder": "Press enter to add, comma-separated", - "emptyNoLimit": "Leave empty for no limit" + "emptyNoLimit": "Leave empty for no limit", + "syncKeysDescription": "When enabled, user fields from this batch edit are saved first, then all undeleted keys for each user are overwritten from that user's settings." } }, "key": { diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json index 0a102133b..0f9cd092c 100644 --- a/messages/en/settings/config.json +++ b/messages/en/settings/config.json @@ -13,6 +13,10 @@ "redirected": "After Redirection (Actual Model)" }, "billingModelSourcePlaceholder": "Select billing model source", + "billingCorrection": { + "title": "Billing and Rate Correction", + "description": "Configure billing model source, Codex Priority billing rate source, and the Claude billing header rectifier in one place." + }, "codexPriorityBillingSource": "Codex Priority Billing Source", "codexPriorityBillingSourceDesc": "Controls which service_tier is used for Codex Priority (Fast Mode) surcharge billing. The default is Requested Service Tier; if Actual Service Tier is selected, the response value is used first and falls back to the request value when the response omits it.", "codexPriorityBillingSourceOptions": { @@ -20,6 +24,8 @@ "actual": "Actual Service Tier (Fallback to Requested)" }, "codexPriorityBillingSourcePlaceholder": "Select Codex Priority billing source", + "costMultiplierCorrection": "Global Cost Multiplier Correction", + "costMultiplierCorrectionDesc": "Value added to every provider cost multiplier at runtime. Default 0 means no change; 0.1 makes every effective provider multiplier 0.1 higher.", "cleanupBatchSize": "Batch Size", "cleanupBatchSizeDesc": "Number of records to delete per batch (range: 1000-100000, recommended 10000)", "cleanupBatchSizePlaceholder": "10000", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index f3b3a9561..970cdaae5 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -776,6 +776,7 @@ "usageLogs": "使用ログ", "leaderboard": "ランキング", "availability": "可用性監視", + "systemStatus": "システム状態", "myQuota": "自分のクォータ", "quotasManagement": "クォータ管理", "userManagement": "ユーザー", @@ -1521,7 +1522,8 @@ "disabled": "無効" }, "actions": { - "addKey": "キーを追加" + "addKey": "キーを追加", + "addTemporaryKey": "一時キー" } }, "keyFullDisplay": { @@ -1617,6 +1619,12 @@ "deleteFailed": "ユーザーの削除に失敗しました", "userDeleted": "ユーザーが削除されました", "saving": "保存しています...", + "syncKeys": { + "button": "Key に同期", + "loading": "同期中...", + "success": "{count} 個の Key に同期しました", + "error": "Key の同期に失敗しました" + }, "resetSection": { "title": "リセットオプション" }, @@ -1656,6 +1664,52 @@ "success": "すべての統計がリセットされました" } }, + "temporaryKeys": { + "createDialog": { + "title": "一時キーを一括生成", + "description": "ベースキーを選択し、専用グループに一時 API キーをまとめて生成します。", + "baseKeyLabel": "ベースキー", + "baseKeyPlaceholder": "ベースキーを選択", + "baseKeyRequired": "ベースキーを選択してください", + "groupNameLabel": "グループ名", + "groupNamePlaceholder": "例:campaign-april", + "quantityLabel": "生成数", + "quantityDescription": "5 / 10 / 20 / 50 / 100 の即時生成、または任意の数を指定できます。", + "customLimitLabel": "各キーの総上限 (USD)", + "customLimitPlaceholder": "空欄でベースキーの総上限を使用", + "customLimitDescription": "総上限のみ上書きします。その他の設定はベースキーに従います。", + "baseKeyRequiredHint": "生成前にベースキーを選択してください。", + "baseKeyRequiredShort": "ベースキーを選択してください", + "submit": "生成", + "submitting": "生成中...", + "noKeys": "利用可能なベースキーがありません", + "invalidCount": "有効な生成数を入力してください", + "invalidLimit": "有効な総上限を入力してください", + "genericError": "一時キーの生成に失敗しました" + }, + "success": { + "title": "一時キーを生成しました", + "description": "グループ {group} に {count} 件の一時キーを生成しました。完全なキーはすぐにダウンロードして安全に保管してください。", + "groupName": "グループ名", + "sourceKey": "ベースキー", + "previewTitle": "プレビュー(先頭 5 件)", + "download": "CSV をダウンロード" + }, + "groups": { + "title": "一時キーグループ", + "groupBadge": "Temp", + "count": "{count} keys", + "download": "ダウンロード", + "delete": "グループ削除" + }, + "toasts": { + "createSuccess": "{count} 件の一時キーを生成しました", + "createFailed": "一時キーの生成に失敗しました: {error}", + "deleteSuccess": "グループ {group} を削除しました({count} keys)", + "deleteFailed": "一時キーグループの削除に失敗しました: {error}", + "downloadFailed": "一時キーのダウンロードに失敗しました: {error}" + } + }, "batchEdit": { "enterMode": "一括編集", "exitMode": "終了", @@ -1675,6 +1729,8 @@ "title": "一括更新を確認", "description": "{users} ユーザーと {keys} キーを更新します。この操作は元に戻せません。", "userFields": "ユーザーフィールド", + "syncKeys": "Sync to Keys", + "syncKeysDescription": "This will overwrite all undeleted keys for these {users} users from their current user settings.", "keyFields": "キーフィールド", "goBack": "戻って修正", "update": "更新を確定", @@ -1683,8 +1739,10 @@ "toast": { "usersUpdated": "{count} ユーザーを更新しました", "keysUpdated": "{count} キーを更新しました", + "keysSynced": "Synced {keys} keys for {users} users", "usersFailed": "ユーザーの更新に失敗しました: {error}", "keysFailed": "キーの更新に失敗しました: {error}", + "syncFailed": "Sync to keys failed: {error}", "batchFailed": "一括更新に失敗しました" }, "validation": { @@ -1706,12 +1764,14 @@ "limit5h": "5時間上限 (USD)", "limitDaily": "日次上限 (USD)", "limitWeekly": "週次上限 (USD)", - "limitMonthly": "月次上限 (USD)" + "limitMonthly": "月次上限 (USD)", + "syncKeys": "Sync to Keys" }, "placeholders": { "emptyToClear": "空欄でクリア", "tagsPlaceholder": "Enterで追加、カンマ区切り対応", - "emptyNoLimit": "空欄で制限なし" + "emptyNoLimit": "空欄で制限なし", + "syncKeysDescription": "When enabled, user fields from this batch edit are saved first, then all undeleted keys for each user are overwritten from that user's settings." } }, "key": { diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json index bc47d2580..6254f6ae9 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -13,6 +13,10 @@ "redirected": "リダイレクト後(実際のモデル)" }, "billingModelSourcePlaceholder": "課金モデルソースを選択", + "billingCorrection": { + "title": "課金とレート補正", + "description": "課金モデルソース、Codex Priority の課金参照元、Claude 課金ヘッダー整流をまとめて設定します。" + }, "codexPriorityBillingSource": "Codex Priority 課金参照元", "codexPriorityBillingSourceDesc": "Codex Priority(Fast Mode)の追加課金に使う service_tier を制御します。デフォルトは Requested Service Tier です。Actual Service Tier を選ぶとレスポンス値を優先し、レスポンスに無い場合はリクエスト値へフォールバックします。", "codexPriorityBillingSourceOptions": { @@ -20,6 +24,8 @@ "actual": "Actual Service Tier(無い場合は Requested へフォールバック)" }, "codexPriorityBillingSourcePlaceholder": "Codex Priority の課金参照元を選択", + "costMultiplierCorrection": "グローバルコスト倍率補正", + "costMultiplierCorrectionDesc": "実行時にすべての provider のコスト倍率へ加算する値です。既定値 0 は変更なし、0.1 は全 provider の実効倍率を 0.1 上げます。", "cleanupBatchSize": "バッチサイズ", "cleanupBatchSizeDesc": "バッチごとに削除するレコード数(範囲:1000-100000、推奨10000)", "cleanupBatchSizePlaceholder": "10000", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 8543647fc..23412199c 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -779,6 +779,7 @@ "usageLogs": "Журналы", "leaderboard": "Лидеры", "availability": "Мониторинг", + "systemStatus": "Статус системы", "myQuota": "Моя квота", "quotasManagement": "Квоты", "userManagement": "Пользователи", @@ -1522,7 +1523,8 @@ "disabled": "Отключен" }, "actions": { - "addKey": "Добавить ключ" + "addKey": "Добавить ключ", + "addTemporaryKey": "Временные ключи" } }, "keyFullDisplay": { @@ -1605,6 +1607,52 @@ "createKeyTitle": "Создать ключ", "editKeyTitle": "Редактировать ключ" }, + "temporaryKeys": { + "createDialog": { + "title": "Пакетная генерация временных ключей", + "description": "Выберите базовый ключ и массово создайте временные API-ключи в отдельной группе.", + "baseKeyLabel": "Базовый ключ", + "baseKeyPlaceholder": "Выберите базовый ключ", + "baseKeyRequired": "Выберите базовый ключ", + "groupNameLabel": "Имя группы", + "groupNamePlaceholder": "Например: campaign-april", + "quantityLabel": "Количество", + "quantityDescription": "Быстрая генерация 5 / 10 / 20 / 50 / 100 ключей или ввод своего количества.", + "customLimitLabel": "Общий лимит на ключ (USD)", + "customLimitPlaceholder": "Оставьте пустым, чтобы использовать лимит базового ключа", + "customLimitDescription": "Переопределяется только общий лимит. Остальные настройки копируются с базового ключа.", + "baseKeyRequiredHint": "Перед генерацией выберите базовый ключ.", + "baseKeyRequiredShort": "Выберите базовый ключ", + "submit": "Сгенерировать", + "submitting": "Генерация...", + "noKeys": "У пользователя нет доступного базового ключа", + "invalidCount": "Введите корректное количество", + "invalidLimit": "Введите корректный общий лимит", + "genericError": "Не удалось сгенерировать временные ключи" + }, + "success": { + "title": "Временные ключи готовы", + "description": "В группе {group} создано {count} временных ключей. Сразу скачайте полный список ключей и сохраните его.", + "groupName": "Имя группы", + "sourceKey": "Базовый ключ", + "previewTitle": "Предпросмотр (первые 5)", + "download": "Скачать CSV" + }, + "groups": { + "title": "Группы временных ключей", + "groupBadge": "Temp", + "count": "{count} keys", + "download": "Скачать", + "delete": "Удалить группу" + }, + "toasts": { + "createSuccess": "Сгенерировано временных ключей: {count}", + "createFailed": "Не удалось сгенерировать временные ключи: {error}", + "deleteSuccess": "Группа {group} удалена ({count} keys)", + "deleteFailed": "Не удалось удалить группу временных ключей: {error}", + "downloadFailed": "Не удалось скачать временные ключи: {error}" + } + }, "editDialog": { "title": "Редактировать пользователя", "description": "Редактирование данных пользователя", @@ -1622,6 +1670,12 @@ "deleteFailed": "Не удалось удалить пользователя", "userDeleted": "Пользователь удален", "saving": "Сохранение...", + "syncKeys": { + "button": "Синхронизировать с Key", + "loading": "Синхронизация...", + "success": "Синхронизировано ключей: {count}", + "error": "Не удалось синхронизировать ключи" + }, "resetSection": { "title": "Параметры сброса" }, @@ -1680,6 +1734,8 @@ "title": "Подтвердить массовое обновление", "description": "Будут обновлены {users} пользователей и {keys} ключей. Это действие нельзя отменить.", "userFields": "Поля пользователя", + "syncKeys": "Sync to Keys", + "syncKeysDescription": "This will overwrite all undeleted keys for these {users} users from their current user settings.", "keyFields": "Поля ключа", "goBack": "Вернуться и изменить", "update": "Подтвердить обновление", @@ -1688,8 +1744,10 @@ "toast": { "usersUpdated": "Обновлено {count} пользователей", "keysUpdated": "Обновлено {count} ключей", + "keysSynced": "Synced {keys} keys for {users} users", "usersFailed": "Не удалось обновить пользователей: {error}", "keysFailed": "Не удалось обновить ключи: {error}", + "syncFailed": "Sync to keys failed: {error}", "batchFailed": "Массовое обновление не удалось" }, "validation": { @@ -1711,12 +1769,14 @@ "limit5h": "Лимит за 5 часов (USD)", "limitDaily": "Дневной лимит (USD)", "limitWeekly": "Недельный лимит (USD)", - "limitMonthly": "Месячный лимит (USD)" + "limitMonthly": "Месячный лимит (USD)", + "syncKeys": "Sync to Keys" }, "placeholders": { "emptyToClear": "Оставьте пустым, чтобы очистить", "tagsPlaceholder": "Нажмите Enter, чтобы добавить, можно разделять запятыми", - "emptyNoLimit": "Оставьте пустым для безлимита" + "emptyNoLimit": "Оставьте пустым для безлимита", + "syncKeysDescription": "When enabled, user fields from this batch edit are saved first, then all undeleted keys for each user are overwritten from that user's settings." } }, "key": { diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index d6920235a..6e0a53422 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -13,6 +13,10 @@ "redirected": "После перенаправления (фактическая модель)" }, "billingModelSourcePlaceholder": "Выберите источник модели для тарификации", + "billingCorrection": { + "title": "Billing and Rate Correction", + "description": "Configure billing model source, Codex Priority billing rate source, and the Claude billing header rectifier in one place." + }, "codexPriorityBillingSource": "Источник тарификации Codex Priority", "codexPriorityBillingSourceDesc": "Определяет, какой service_tier использовать для отдельной тарификации Codex Priority (Fast Mode). По умолчанию используется Requested Service Tier; если выбран Actual Service Tier, сначала берется значение из ответа, а при его отсутствии используется значение из запроса.", "codexPriorityBillingSourceOptions": { @@ -20,6 +24,8 @@ "actual": "Actual Service Tier (с откатом к Requested)" }, "codexPriorityBillingSourcePlaceholder": "Выберите источник тарификации Codex Priority", + "costMultiplierCorrection": "Глобальная поправка множителя стоимости", + "costMultiplierCorrectionDesc": "Значение добавляется к множителю стоимости каждого provider во время выполнения. 0 не меняет значение; 0.1 увеличивает эффективный множитель каждого provider на 0.1.", "cleanupBatchSize": "Размер пакета", "cleanupBatchSizeDesc": "Количество записей для удаления за раз (диапазон: 1000-100000, рекомендуется 10000)", "cleanupBatchSizePlaceholder": "10000", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 347b01908..2b9ab749c 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -779,6 +779,7 @@ "usageLogs": "使用记录", "leaderboard": "排行榜", "availability": "可用性监控", + "systemStatus": "系统状态", "myQuota": "我的配额", "quotasManagement": "限额管理", "providers": "供应商管理", @@ -1540,7 +1541,8 @@ "disabled": "已禁用" }, "actions": { - "addKey": "新增密钥" + "addKey": "新增密钥", + "addTemporaryKey": "创建临时 Key" } }, "keyFullDisplay": { @@ -1623,6 +1625,68 @@ "createKeyTitle": "创建 Key", "editKeyTitle": "编辑 Key" }, + "temporaryKeys": { + "createDialog": { + "title": "批量生成临时 Key", + "description": "选择一个基础 Key,并按当前用户组批量生成临时 API Key。", + "baseKeyLabel": "基础 Key", + "baseKeyPlaceholder": "请选择基础 Key", + "baseKeyRequired": "请选择基础 Key", + "groupNameLabel": "用户组别", + "groupNamePlaceholder": "例如:渠道A-4月活动", + "quantityLabel": "生成数量", + "quantityDescription": "支持快捷生成 5 / 10 / 20 / 50 / 100,也可自定义数量。", + "customLimitLabel": "每个 Key 总额度 (USD)", + "customLimitPlaceholder": "留空则沿用基础 Key 的总额度配置", + "customLimitDescription": "仅覆写总额度,其余配置保持和基础 Key 一致。", + "baseKeyRequiredHint": "请选择一个基础 Key 后再生成。", + "baseKeyRequiredShort": "请选择基础 Key", + "submit": "立即生成", + "submitting": "生成中...", + "noKeys": "当前用户暂无可用的基础 Key", + "invalidCount": "请输入有效的生成数量", + "invalidLimit": "请输入有效的总额度", + "genericError": "批量生成失败" + }, + "success": { + "title": "临时 Key 生成完成", + "description": "分组 {group} 已成功生成 {count} 个临时 Key,请立即下载保存完整密钥。", + "groupName": "用户组别", + "sourceKey": "基础 Key", + "previewTitle": "预览(前 5 条)", + "download": "下载纯 Key" + }, + "listActions": { + "showMore": "展开其余 {count} 个", + "showLess": "收起" + }, + "sections": { + "standard": { + "title": "常规 Key", + "description": "主业务 Key 管理区,保持原有逻辑不变。", + "empty": "暂无常规 Key" + }, + "temporary": { + "title": "临时 Key", + "description": "额外的临时 CDKey 分组,按当前用户组批量创建、下载和删除。", + "empty": "暂无临时 Key 分组" + } + }, + "groups": { + "title": "临时 Key 分组", + "groupBadge": "临时", + "count": "{count} 个 Key", + "download": "下载纯 Key", + "delete": "删除分组" + }, + "toasts": { + "createSuccess": "已生成 {count} 个临时 Key", + "createFailed": "生成临时 Key 失败:{error}", + "deleteSuccess": "已删除分组 {group}({count} 个 Key)", + "deleteFailed": "删除临时 Key 分组失败:{error}", + "downloadFailed": "下载临时 Key 失败:{error}" + } + }, "editDialog": { "title": "编辑用户", "description": "编辑用户信息", @@ -1640,6 +1704,12 @@ "deleteFailed": "删除用户失败", "userDeleted": "用户已删除", "saving": "保存中...", + "syncKeys": { + "button": "同步到 Key", + "loading": "同步中...", + "success": "已同步到 {count} 把 Key", + "error": "同步 Key 失败" + }, "resetSection": { "title": "重置选项" }, @@ -1698,6 +1768,8 @@ "title": "确认批量更新", "description": "此操作将更新 {users} 个用户和 {keys} 个密钥,操作不可撤销。", "userFields": "用户字段", + "syncKeys": "同步到 Key", + "syncKeysDescription": "会按当前用户配置覆盖这 {users} 个用户的全部未删除 Key。", "keyFields": "密钥字段", "goBack": "返回修改", "update": "确认更新", @@ -1706,8 +1778,10 @@ "toast": { "usersUpdated": "已更新 {count} 个用户", "keysUpdated": "已更新 {count} 个密钥", + "keysSynced": "已同步 {users} 个用户的 {keys} 把 Key", "usersFailed": "用户更新失败:{error}", "keysFailed": "密钥更新失败:{error}", + "syncFailed": "同步到 Key 失败:{error}", "batchFailed": "批量更新失败" }, "validation": { @@ -1729,12 +1803,14 @@ "limit5h": "5h 限额 (USD)", "limitDaily": "每日限额 (USD)", "limitWeekly": "周限额 (USD)", - "limitMonthly": "月限额 (USD)" + "limitMonthly": "月限额 (USD)", + "syncKeys": "同步到 Key" }, "placeholders": { "emptyToClear": "留空表示清空", "tagsPlaceholder": "输入后回车添加,支持逗号分隔", - "emptyNoLimit": "留空表示不限额" + "emptyNoLimit": "留空表示不限额", + "syncKeysDescription": "启用后,会先保存本批量编辑里的用户字段,再按用户配置同步该用户全部未删除 Key。" } }, "key": { diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json index 8b95c5bdf..84bfc0d81 100644 --- a/messages/zh-CN/settings/config.json +++ b/messages/zh-CN/settings/config.json @@ -32,6 +32,10 @@ "original": "重定向前(原始模型)", "redirected": "重定向后(实际模型)" }, + "billingCorrection": { + "title": "计费与费率矫正", + "description": "集中配置模型计费来源、Codex Priority 费率口径,以及 Claude 计费标头整流开关。" + }, "codexPriorityBillingSource": "Codex Priority 计费来源", "codexPriorityBillingSourcePlaceholder": "选择 Codex Priority 计费来源", "codexPriorityBillingSourceDesc": "控制 Codex Priority(Fast Mode)单独计费使用哪个 service_tier。默认按 Requested Service Tier 计费;若选择 Actual Service Tier,则优先使用响应返回值,响应未返回时回退到请求值。", @@ -39,6 +43,8 @@ "requested": "Requested Service Tier(默认)", "actual": "Actual Service Tier(缺失时回退 Requested)" }, + "costMultiplierCorrection": "全局成本倍率修正", + "costMultiplierCorrectionDesc": "运行时加到所有 provider 成本倍率上的数值。默认 0 表示不修改;填写 0.1 表示所有 provider 的实际倍率都加 0.1。", "allowGlobalView": "允许查看全站使用量", "allowGlobalViewDesc": "关闭后,普通用户在仪表盘仅能查看自己密钥的使用统计。", "verboseProviderError": "详细供应商错误信息", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index b2a41eebb..4b772107f 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -776,6 +776,7 @@ "usageLogs": "使用記錄", "leaderboard": "排行", "availability": "可用性監控", + "systemStatus": "系統狀態", "myQuota": "我的額度", "quotasManagement": "額度管理", "userManagement": "使用者管理", @@ -1525,7 +1526,8 @@ "disabled": "已停用" }, "actions": { - "addKey": "新增金鑰" + "addKey": "新增金鑰", + "addTemporaryKey": "臨時 Key" } }, "keyFullDisplay": { @@ -1608,6 +1610,52 @@ "createKeyTitle": "建立 Key", "editKeyTitle": "編輯 Key" }, + "temporaryKeys": { + "createDialog": { + "title": "批量產生臨時 Key", + "description": "選擇一個基礎 Key,並按分組批量產生臨時 API Key。", + "baseKeyLabel": "基礎 Key", + "baseKeyPlaceholder": "請選擇基礎 Key", + "baseKeyRequired": "請選擇基礎 Key", + "groupNameLabel": "分組名稱", + "groupNamePlaceholder": "例如:渠道A-4月活動", + "quantityLabel": "產生數量", + "quantityDescription": "支援快速產生 5 / 10 / 20 / 50 / 100,也可自訂數量。", + "customLimitLabel": "每個 Key 總額度 (USD)", + "customLimitPlaceholder": "留空則沿用基礎 Key 的總額度設定", + "customLimitDescription": "僅覆寫總額度,其餘設定保持和基礎 Key 一致。", + "baseKeyRequiredHint": "請先選擇基礎 Key 再產生。", + "baseKeyRequiredShort": "請選擇基礎 Key", + "submit": "立即產生", + "submitting": "產生中...", + "noKeys": "目前使用者沒有可用的基礎 Key", + "invalidCount": "請輸入有效的產生數量", + "invalidLimit": "請輸入有效的總額度", + "genericError": "批量產生失敗" + }, + "success": { + "title": "臨時 Key 已產生", + "description": "分組 {group} 已成功產生 {count} 個臨時 Key,請立即下載保存完整金鑰。", + "groupName": "分組名稱", + "sourceKey": "基礎 Key", + "previewTitle": "預覽(前 5 條)", + "download": "下載 CSV" + }, + "groups": { + "title": "臨時 Key 分組", + "groupBadge": "臨時", + "count": "{count} 個 Key", + "download": "下載", + "delete": "刪除分組" + }, + "toasts": { + "createSuccess": "已產生 {count} 個臨時 Key", + "createFailed": "產生臨時 Key 失敗:{error}", + "deleteSuccess": "已刪除分組 {group}({count} 個 Key)", + "deleteFailed": "刪除臨時 Key 分組失敗:{error}", + "downloadFailed": "下載臨時 Key 失敗:{error}" + } + }, "editDialog": { "title": "編輯使用者", "description": "編輯使用者資訊", @@ -1625,6 +1673,12 @@ "deleteFailed": "刪除使用者失敗", "userDeleted": "使用者已刪除", "saving": "儲存中...", + "syncKeys": { + "button": "同步到 Key", + "loading": "同步中...", + "success": "已同步到 {count} 把 Key", + "error": "同步 Key 失敗" + }, "resetSection": { "title": "重置選項" }, @@ -1683,6 +1737,8 @@ "title": "確認批量更新", "description": "此操作將更新 {users} 位使用者和 {keys} 個金鑰,操作不可撤銷。", "userFields": "使用者欄位", + "syncKeys": "同步到 Key", + "syncKeysDescription": "會按目前使用者設定覆蓋這 {users} 位使用者的全部未刪除 Key。", "keyFields": "金鑰欄位", "goBack": "返回編輯", "update": "確認更新", @@ -1691,8 +1747,10 @@ "toast": { "usersUpdated": "已更新 {count} 位使用者", "keysUpdated": "已更新 {count} 個金鑰", + "keysSynced": "已同步 {users} 位使用者的 {keys} 個 Key", "usersFailed": "使用者更新失敗:{error}", "keysFailed": "金鑰更新失敗:{error}", + "syncFailed": "同步到 Key 失敗:{error}", "batchFailed": "批量更新失敗" }, "validation": { @@ -1714,12 +1772,14 @@ "limit5h": "5h 限額(USD)", "limitDaily": "每日限額(USD)", "limitWeekly": "週限額(USD)", - "limitMonthly": "月限額(USD)" + "limitMonthly": "月限額(USD)", + "syncKeys": "同步到 Key" }, "placeholders": { "emptyToClear": "留空表示清除", "tagsPlaceholder": "輸入後按 Enter 新增,支援逗號分隔", - "emptyNoLimit": "留空表示不限額" + "emptyNoLimit": "留空表示不限額", + "syncKeysDescription": "啟用後,會先儲存本次批量編輯中的使用者欄位,再按使用者設定同步該使用者全部未刪除 Key。" } }, "key": { diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index cfeb43fd2..cc70bf729 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -13,6 +13,10 @@ "redirected": "重新導向後(實際模型)" }, "billingModelSourcePlaceholder": "選擇計費模型來源", + "billingCorrection": { + "title": "計費與費率矯正", + "description": "集中設定模型計費來源、Codex Priority 費率口徑,以及 Claude 計費標頭整流開關。" + }, "codexPriorityBillingSource": "Codex Priority 計費來源", "codexPriorityBillingSourceDesc": "控制 Codex Priority(Fast Mode)單獨計費使用哪個 service_tier。預設按 Requested Service Tier 計費;若選擇 Actual Service Tier,則優先使用回應返回值,回應未返回時回退到請求值。", "codexPriorityBillingSourceOptions": { @@ -20,6 +24,8 @@ "actual": "Actual Service Tier(缺失時回退 Requested)" }, "codexPriorityBillingSourcePlaceholder": "選擇 Codex Priority 計費來源", + "costMultiplierCorrection": "全域成本倍率修正", + "costMultiplierCorrectionDesc": "執行時加到所有 provider 成本倍率上的數值。預設 0 表示不修改;填寫 0.1 表示所有 provider 的實際倍率都加 0.1。", "cleanupBatchSize": "批次大小", "cleanupBatchSizeDesc": "每批刪除的記錄數(範圍:1000-100000,建議 10000)", "cleanupBatchSizePlaceholder": "10000", diff --git a/plan/2026-04-25_18-53-35-cch.md b/plan/2026-04-25_18-53-35-cch.md new file mode 100644 index 000000000..776fb850c --- /dev/null +++ b/plan/2026-04-25_18-53-35-cch.md @@ -0,0 +1,113 @@ +--- +mode: plan +task: CCH 二开后续全部合并任务 +created_at: "2026-04-25T18:53:35+08:00" +complexity: complex +--- + +# Plan: CCH 二开后续全部合并任务 + +## Goal +- 以 `upstream/main` 为唯一基线,继续按功能组把 fork 核心能力回灌到最新源码。 +- 维持“少量稳定挂点 + 独立 helper/policy 模块”的薄扩展层做法,不恢复大面积 fork 差异。 +- 在每一组进入下一组前,完成本地验证、回归检查和范围收口,避免把未审完的旧逻辑带进后续阶段。 + +## Scope +- In: +- 第二组剩余工作:`public/system status` 兼容入口、公开 API/页面契约、导航与测试整理。 +- 第三组:`temporary key groups` 与 `user -> key sync`,基于 upstream 现有用户、key、reset、audit 契约重做。 +- 第四组:deploy/examples 等外围资产回灌。 +- 第五组:已通过功能组对应的 OpenAPI、文案、测试、约束消息与少量配套契约收尾。 +- 规划期内需要把每一组的范围、验收口径、回退方式和提交边界写清楚。 +- Out: +- 不在计划里要求一次性恢复旧 fork 的全部页面、迁移、管理按钮。 +- 不把未审核功能直接整包拷回 upstream 最新文件。 +- 不把 temporary key groups 之前的旧迁移直接搬进当前基线。 +- 不在当前规划阶段直接写业务代码或提交代码。 + +## Assumptions / Dependencies +- 现有第一组“配额与只读”已经完成,作为后续功能组的基线。 +- 第二组当前已有 `system-status` 页面和 `/api/system-status`,兼容 `/status` 只是挂点整理,不需要重做数据核心。 +- 本地测试环境可继续使用 `claude_code_hub_test`、本地 Postgres 和临时 `ADMIN_TOKEN`。 +- 仓库当前缺少 `issues/README.md` 与 `docs/mcp-tools.md`,后续若要正式写计划文件和 Issue CSV,需要先确认这两个约束文件的替代来源,或补齐它们。 +- Git 提交仍按“每个功能组单独提交、单独验证”的方式执行。 + +## Phases +1. 第二组收尾:public/system status +2. 第三组重建:temporary key groups 与 user->key sync +3. 第四组外围资产:deploy/examples +4. 第五组配套契约:文案、OpenAPI、测试、约束消息 +5. 最终整理:分组 review、文档同步、提交序列整理 + +## Tests & Verification +- 第二组公开状态页兼容不回归 -> 跑 `/api/system-status`、`/api/status`、`/{locale}/system-status`、`/{locale}/status` 定向测试与公共路径测试 +- 第二组公开数据契约不变 -> 验证 `src/lib/system-status.ts` 聚合测试、API route 测试、metadata/page alias 测试 +- 第三组用户与 key 同步正确 -> 针对 user create/update/reset、temporary group create/apply/remove、审计记录、权限边界写集成测试 +- 第三组不破坏现有 key/user 流程 -> 跑 `keys`、`users`、`auth`、quota、audit 相关定向测试 +- 第四组部署资产可用 -> 校验示例配置、启动脚本、compose/dev 说明与 smoke 命令 +- 第五组契约同步完整 -> 跑 OpenAPI 完整性测试、相关 UI/route 单测、必要的 i18n 或字符串质量检查 +- 每组完成后都要先过 typecheck,再跑该组最小必要测试集,再决定是否进入下一组 + +## Issue CSV +- Path: issues/2026-04-25_18-53-35-cch-fork-merge-full.csv +- Must share the same timestamp/slug as this plan. + +## Tools / MCP +- 当前仓库缺少 `docs/mcp-tools.md`,正式写 Issue CSV 前需要先确认可用工具名来源。 +- 规划阶段默认使用仓库内源码、现有测试、Git 历史和本地命令作为事实来源。 +- 执行阶段继续优先用已有测试文件和集中 helper 模块定位实现挂点。 + +## Acceptance Checklist +- [x] 第二组的公开 status 能力只通过集中挂点接入,不向别处扩散判断 +- [x] 第三组不直接照搬旧 fork 的 `users.ts`、`keys.ts` 全文件 +- [x] 第三组所有核心行为都落在 upstream 当前契约上 +- [x] 第四组 deploy/examples 独立成外围资产,不阻塞核心业务组验收 +- [x] 第五组只同步已经审核通过功能组对应的文案、测试和契约 +- [x] 每个功能组都有单独的验证命令、提交边界和回退方法 +- [ ] 最终提交序列能清楚区分功能组,不混入无关改动 + +## Execution Notes +- A1: `/api/status` 与 `/{locale}/status` 复用 system status 入口;Next 16 构建要求 `dynamic/runtime` 在 alias 文件内静态导出,不能转导。 +- A2: temporary key groups 的命名、编号、限额校验和创建 payload 已集中到 `src/lib/keys/temporary-key-groups.ts`,`src/actions/keys.ts` 只保留权限、仓储调用和刷新。 +- A3: user -> key sync 已补齐 total/concurrent/providerGroup/daily reset 字段,用户同步与 reset action 已注册到 OpenAPI,并保留 admin-only 约束。 +- A4: 外围 API 示例落在 `docs/examples/temporary-key-groups.md`,README 与 README.en 已加入口链接;deploy 与 compose 参数未改变。 +- A5: OpenAPI 完整性测试覆盖了新增 users/keys action,CSV 记录各组验证结果;最终提交还需要在脏树里按功能组分开整理。 + +## Risks / Blockers +- 第三组耦合最高,最容易把旧 fork 结构性差异重新带回来。 +- 旧 fork 中 temporary key groups 若依赖过时表结构或旧 action 参数,重做时可能需要先抽一层 helper 才能挂回 upstream。 +- 若没有 `issues/README.md` 与 `docs/mcp-tools.md`,正式落计划文件和 CSV 时会缺少格式约束与工具命名依据。 +- 仓库当前有用户未提交改动,后续施工时要继续避开无关文件。 + +## Rollback / Recovery +- 每个功能组独立分支或独立 commit 序列推进,出问题按组回退,不跨组回滚。 +- 对高风险组先做定向测试和最小挂点提交,再逐步扩展。 +- 若第三组实现中发现需要大于 3 个核心入口同时重改,立即停下,先抽集中 helper 后再继续。 +- 对外兼容入口如 `/status` 必须保留旧入口到新实现的单跳映射,避免撤回时影响主路径。 + +## Checkpoints +- Commit after: 第二组公开 status 收尾 +- Commit after: 第三组 temporary key groups 核心数据流打通 +- Commit after: 第三组 user -> key sync 与 audit/权限边界补齐 +- Commit after: 第四组 deploy/examples 整理完成 +- Commit after: 第五组契约与测试收尾 +- Commit after: 最终 review 与文档同步 + +## References +- `src/lib/auth/public-path-policy.ts:1` +- `src/app/api/system-status/route.ts:1` +- `src/lib/system-status.ts:1` +- `src/app/[locale]/system-status/page.tsx:1` +- `src/app/[locale]/system-status/layout.tsx:1` +- `src/actions/my-usage.ts:1` +- `src/app/v1/_lib/proxy/auth-guard.ts:1` +- `src/lib/auth.ts:1` +- `tests/api/my-usage-readonly.test.ts:1` +- `tests/integration/auth.test.ts:1` +- `tests/unit/lib/system-status.test.ts:1` +- `tests/unit/api/system-status-route.test.ts:1` +- `tests/unit/proxy/proxy-auth-cookie-passthrough.test.ts:1` +- `src/lib/keys/temporary-key-groups.ts:1` +- `tests/unit/actions/temporary-keys.test.ts:1` +- `tests/unit/actions/users-key-sync.test.ts:1` +- `docs/examples/temporary-key-groups.md:1` diff --git a/public/examples/api-key-quota-extractor-compatible.js b/public/examples/api-key-quota-extractor-compatible.js new file mode 100644 index 000000000..03b956c67 --- /dev/null +++ b/public/examples/api-key-quota-extractor-compatible.js @@ -0,0 +1,90 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const toNumber = function(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + const round2 = function(value) { + return Math.round(value * 100) / 100; + }; + + const percent = function(used, total) { + return total > 0 ? round2((used / total) * 100) : null; + }; + + const quotaWindows = data.quotaWindows && typeof data.quotaWindows === "object" + ? data.quotaWindows + : {}; + const fiveHour = quotaWindows.fiveHour || {}; + const daily = quotaWindows.daily || {}; + const weekly = quotaWindows.weekly || {}; + const monthly = quotaWindows.monthly || {}; + const total = quotaWindows.total || {}; + + const limitMonthlyUsd = toNumber(data.limitMonthlyUsd, null); + const limitTotalUsd = toNumber(data.limitTotalUsd, limitMonthlyUsd); + const usedTotalUsd = toNumber(data.usedTotalUsd, toNumber(data.usedMonthlyUsd, 0)); + const remainingTotalUsd = limitTotalUsd === null ? null : round2(Math.max(limitTotalUsd - usedTotalUsd, 0)); + + const limitDailyUsd = toNumber(data.limitDailyUsd, null); + const usedDailyUsd = toNumber(data.usedDailyUsd, 0); + const remainingDailyUsd = limitDailyUsd === null ? null : round2(Math.max(limitDailyUsd - usedDailyUsd, 0)); + + const limit5hUsd = toNumber(data.limit5hUsd, null); + const used5hUsd = toNumber(data.used5hUsd, 0); + const remaining5hUsd = limit5hUsd === null ? null : round2(Math.max(limit5hUsd - used5hUsd, 0)); + + const isValid = + response && + response.ok === true && + toBoolean(data.keyIsEnabled, true) && + toBoolean(data.userIsEnabled, true); + + return { + isValid: !!isValid, + invalidMessage: isValid ? undefined : "套餐不可用", + planName: "Total Quota", + unit: typeof data.unit === "string" ? data.unit : "USD", + remaining: toNumber(total.remainingUsd, remainingTotalUsd), + total: toNumber(total.limitUsd, limitTotalUsd), + used: toNumber(total.usedUsd, usedTotalUsd), + todayUsed: toNumber(data.todayUsedUsd, toNumber(daily.usedUsd, usedDailyUsd)), + todayRemaining: toNumber(data.todayRemainingUsd, toNumber(daily.remainingUsd, remainingDailyUsd)), + todayUsedPercent: toNumber(data.todayUsedPercent, toNumber(daily.usedPercent, percent(usedDailyUsd, limitDailyUsd))), + todayRemainingPercent: toNumber( + data.todayRemainingPercent, + toNumber(daily.remainingPercent, percent(remainingDailyUsd || 0, limitDailyUsd)) + ), + remaining5h: toNumber(fiveHour.remainingUsd, remaining5hUsd), + remainingDaily: toNumber(daily.remainingUsd, remainingDailyUsd), + remainingWeekly: toNumber(weekly.remainingUsd, toNumber(data.remainingWeeklyUsd, null)), + remainingMonthly: toNumber(monthly.remainingUsd, toNumber(data.remainingMonthlyUsd, null)), + remainingTotal: toNumber(total.remainingUsd, remainingTotalUsd), + remainingPercent: toNumber(total.remainingPercent, data.remainingPercent), + extra: "5H剩余:" + toNumber(fiveHour.remainingPercent, percent(remaining5hUsd || 0, limit5hUsd)) + "%" + + "/日剩余:" + toNumber(daily.remainingPercent, percent(remainingDailyUsd || 0, limitDailyUsd)) + "%" + + "/周剩余:" + toNumber(weekly.remainingPercent, null) + "%" + + "/月剩余:" + toNumber(monthly.remainingPercent, null) + "%" + + "/总剩余:" + toNumber(total.remainingPercent, data.remainingPercent) + "%" + }; + } +}) diff --git a/public/examples/api-key-quota-extractor-direct.js b/public/examples/api-key-quota-extractor-direct.js new file mode 100644 index 000000000..405f849f3 --- /dev/null +++ b/public/examples/api-key-quota-extractor-direct.js @@ -0,0 +1,61 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + const quotaWindows = data.quotaWindows && typeof data.quotaWindows === "object" + ? data.quotaWindows + : {}; + const fiveHour = quotaWindows.fiveHour || {}; + const daily = quotaWindows.daily || {}; + const weekly = quotaWindows.weekly || {}; + const monthly = quotaWindows.monthly || {}; + const total = quotaWindows.total || {}; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + const isValid = + response && + response.ok === true && + toBoolean(data.keyIsEnabled, true) && + toBoolean(data.userIsEnabled, true); + + return { + isValid: !!isValid, + invalidMessage: isValid ? undefined : "套餐不可用", + planName: "Total Quota", + unit: typeof data.unit === "string" ? data.unit : "USD", + remaining: total.remainingUsd, + total: total.limitUsd, + used: total.usedUsd, + todayUsed: data.todayUsedUsd, + todayRemaining: data.todayRemainingUsd, + todayUsedPercent: data.todayUsedPercent, + todayRemainingPercent: data.todayRemainingPercent, + remaining5h: fiveHour.remainingUsd, + remainingDaily: daily.remainingUsd, + remainingWeekly: weekly.remainingUsd, + remainingMonthly: monthly.remainingUsd, + remainingTotal: total.remainingUsd, + remainingPercent: data.remainingPercent, + extra: "5H剩余:" + fiveHour.remainingPercent + "%" + + "/日剩余:" + daily.remainingPercent + "%" + + "/周剩余:" + weekly.remainingPercent + "%" + + "/月剩余:" + monthly.remainingPercent + "%" + + "/总剩余:" + total.remainingPercent + "%" + }; + } +}) diff --git a/public/examples/api-key-quota-extractor.js b/public/examples/api-key-quota-extractor.js new file mode 100644 index 000000000..be8470731 --- /dev/null +++ b/public/examples/api-key-quota-extractor.js @@ -0,0 +1,63 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const toNumber = function(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + const quotaWindows = data.quotaWindows && typeof data.quotaWindows === "object" + ? data.quotaWindows + : {}; + const fiveHour = quotaWindows.fiveHour || {}; + const daily = quotaWindows.daily || {}; + const weekly = quotaWindows.weekly || {}; + const monthly = quotaWindows.monthly || {}; + const total = quotaWindows.total || {}; + + const isValid = + response && + response.ok === true && + toBoolean(data.keyIsEnabled, true) && + toBoolean(data.userIsEnabled, true); + + return { + isValid: !!isValid, + invalidMessage: isValid ? undefined : "套餐不可用", + remaining: toNumber(total.remainingUsd, toNumber(data.remainingTotalUsd, null)), + unit: typeof data.unit === "string" ? data.unit : "USD", + planName: "Total Quota", + total: toNumber(total.limitUsd, toNumber(data.limitTotalUsd, null)), + used: toNumber(total.usedUsd, toNumber(data.usedTotalUsd, 0)), + todayUsed: toNumber(data.todayUsedUsd, toNumber(daily.usedUsd, 0)), + todayRemaining: toNumber(data.todayRemainingUsd, toNumber(daily.remainingUsd, null)), + remainingWeekly: toNumber(weekly.remainingUsd, toNumber(data.remainingWeeklyUsd, null)), + remainingMonthly: toNumber(monthly.remainingUsd, toNumber(data.remainingMonthlyUsd, null)), + remainingTotal: toNumber(total.remainingUsd, toNumber(data.remainingTotalUsd, null)), + remaining5h: toNumber(fiveHour.remainingUsd, toNumber(data.remaining5hUsd, null)), + remainingDaily: toNumber(daily.remainingUsd, toNumber(data.remainingDailyUsd, null)), + extra: "5H剩余:" + toNumber(fiveHour.remainingPercent, data.todayRemainingPercent) + "%" + + "/日剩余:" + toNumber(daily.remainingPercent, data.todayRemainingPercent) + "%" + + "/周剩余:" + toNumber(weekly.remainingPercent, null) + "%" + + "/月剩余:" + toNumber(monthly.remainingPercent, null) + "%" + + "/总剩余:" + toNumber(total.remainingPercent, data.remainingPercent) + "%" + }; + } +}) diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index edc8c1c1c..38bec454c 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -424,6 +424,8 @@ services: - ./.env environment: NODE_ENV: production + HOST: 0.0.0.0 + HOSTNAME: 0.0.0.0 PORT: `${APP_PORT:-$($script:APP_PORT)} DSN: postgresql://`${DB_USER:-postgres}:`${DB_PASSWORD:-postgres}@claude-code-hub-db-${SUFFIX}:5432/`${DB_NAME:-claude_code_hub} REDIS_URL: redis://claude-code-hub-redis-${SUFFIX}:6379 @@ -436,7 +438,7 @@ $appPortsSection networks: - claude-code-hub-net-$SUFFIX healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:`${APP_PORT:-$($script:APP_PORT)}/api/actions/health || exit 1"] + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:' + (process.env.PORT || '$($script:APP_PORT)') + '/api/actions/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 30s timeout: 5s retries: 3 diff --git a/scripts/deploy.sh b/scripts/deploy.sh index e94183708..6249d83b4 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -509,6 +509,8 @@ services: - ./.env environment: NODE_ENV: production + HOST: 0.0.0.0 + HOSTNAME: 0.0.0.0 PORT: \${APP_PORT:-${APP_PORT}} DSN: postgresql://\${DB_USER:-postgres}:\${DB_PASSWORD:-postgres}@claude-code-hub-db-${SUFFIX}:5432/\${DB_NAME:-claude_code_hub} REDIS_URL: redis://claude-code-hub-redis-${SUFFIX}:6379 @@ -531,7 +533,7 @@ EOF networks: - claude-code-hub-net-${SUFFIX} healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:\${APP_PORT:-${APP_PORT}}/api/actions/health || exit 1"] + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:' + (process.env.PORT || \${APP_PORT:-${APP_PORT}}) + '/api/actions/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 30s timeout: 5s retries: 3 diff --git a/scripts/server-zero-downtime-rollout.sh b/scripts/server-zero-downtime-rollout.sh new file mode 100644 index 000000000..a5365adc6 --- /dev/null +++ b/scripts/server-zero-downtime-rollout.sh @@ -0,0 +1,660 @@ +#!/usr/bin/env bash + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { + echo -e "${BLUE}[INFO]${NC} $*" +} + +log_success() { + echo -e "${GREEN}[OK]${NC} $*" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $*" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $*" >&2 +} + +usage() { + cat <<'EOF' +Zero-downtime Docker rollout for server-side deployments. + +Usage: + server-zero-downtime-rollout.sh --image-tag [options] + +Required: + --image-tag Image tag already available on the server + +Optional: + --deploy-dir Deployment directory containing docker-compose.run.yml and .env + --compose-file Compose file path + --env-file Environment file path + --nginx-config Nginx config file containing the target domain block + --domain Domain served by the target Nginx block + --health-path Health path, default: /api/actions/health + --standard-port Standard local port, default: APP_PORT from .env or 23000 + --green-port Temporary green port for fallback path, default: standard+1 + --app-service Compose app service name, default: app + --backup-root Backup root, default: /opt/backups/claude-code-hub + --current-tag Runtime tag to retag for compose app, default: claude-code-hub-local:current + --green-name Manual green container name for fallback path + --keep-old-running Keep old live container running after cutover (default) + --stop-old-after-cutover Stop old live container after successful cutover + --local-timeout Local health timeout, default: 90 + --public-timeout Public health timeout, default: 45 + --dry-run Print the rollout plan without changing runtime state + -h, --help Show this help + +Behavior: + 1. Detect current live port from nginx and verify current public health + 2. Backup nginx / compose / env and current image tag + 3. Retag current image to the requested image tag + 4. Prefer compose on the standard port when it is free or can be reclaimed safely + 5. Otherwise, start a manual green container on a temporary port and cut nginx there + 6. Keep the old live container by default so rollback stays fast and low-risk +EOF +} + +IMAGE_TAG="" +DEPLOY_DIR="/opt/apps/claude-code-hub-local" +COMPOSE_FILE="" +ENV_FILE="" +NGINX_CONFIG="/etc/nginx/sites-enabled/fkcodex-apps.conf" +DOMAIN="cch.fkcodex.com" +HEALTH_PATH="/api/actions/health" +STANDARD_PORT="" +GREEN_PORT="" +APP_SERVICE="app" +BACKUP_ROOT="/opt/backups/claude-code-hub" +CURRENT_TAG="claude-code-hub-local:current" +GREEN_NAME="" +KEEP_OLD_RUNNING=true +COMPOSE_OVERRIDE_FILE="" +LOCAL_HEALTH_TIMEOUT=90 +PUBLIC_HEALTH_TIMEOUT=45 +DRY_RUN=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --image-tag) + IMAGE_TAG="${2:-}" + shift 2 + ;; + --deploy-dir) + DEPLOY_DIR="${2:-}" + shift 2 + ;; + --compose-file) + COMPOSE_FILE="${2:-}" + shift 2 + ;; + --env-file) + ENV_FILE="${2:-}" + shift 2 + ;; + --nginx-config) + NGINX_CONFIG="${2:-}" + shift 2 + ;; + --domain) + DOMAIN="${2:-}" + shift 2 + ;; + --health-path) + HEALTH_PATH="${2:-}" + shift 2 + ;; + --standard-port) + STANDARD_PORT="${2:-}" + shift 2 + ;; + --green-port) + GREEN_PORT="${2:-}" + shift 2 + ;; + --app-service) + APP_SERVICE="${2:-}" + shift 2 + ;; + --backup-root) + BACKUP_ROOT="${2:-}" + shift 2 + ;; + --current-tag) + CURRENT_TAG="${2:-}" + shift 2 + ;; + --green-name) + GREEN_NAME="${2:-}" + shift 2 + ;; + --keep-old-running) + KEEP_OLD_RUNNING=true + shift + ;; + --stop-old-after-cutover) + KEEP_OLD_RUNNING=false + shift + ;; + --local-timeout) + LOCAL_HEALTH_TIMEOUT="${2:-}" + shift 2 + ;; + --public-timeout) + PUBLIC_HEALTH_TIMEOUT="${2:-}" + shift 2 + ;; + --dry-run) + DRY_RUN=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + log_error "Unknown option: $1" + usage + exit 1 + ;; + esac +done + +if [[ -z "$IMAGE_TAG" ]]; then + log_error "--image-tag is required" + usage + exit 1 +fi + +if [[ -z "$COMPOSE_FILE" ]]; then + COMPOSE_FILE="$DEPLOY_DIR/docker-compose.run.yml" +fi + +if [[ -z "$ENV_FILE" ]]; then + ENV_FILE="$DEPLOY_DIR/.env" +fi + +for cmd in docker curl python3 nginx ss; do + command -v "$cmd" >/dev/null 2>&1 || { + log_error "Required command not found: $cmd" + exit 1 + } +done + +docker compose version >/dev/null 2>&1 || { + log_error "docker compose plugin is required" + exit 1 +} + +for path in "$DEPLOY_DIR" "$COMPOSE_FILE" "$ENV_FILE" "$NGINX_CONFIG"; do + [[ -e "$path" ]] || { + log_error "Required path not found: $path" + exit 1 + } +done + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +if [[ -z "$STANDARD_PORT" ]]; then + STANDARD_PORT="${APP_PORT:-23000}" +fi + +if [[ -z "$GREEN_PORT" ]]; then + GREEN_PORT="$((STANDARD_PORT + 1))" +fi + +if [[ -z "$GREEN_NAME" ]]; then + GREEN_NAME="${COMPOSE_PROJECT_NAME:-claude-code-hub-local}_rollout_green" +fi + +PROJECT_NAME="${COMPOSE_PROJECT_NAME:-claude-code-hub-local}" +DOCKER_NETWORK="${PROJECT_NAME}_default" +BACKUP_TIMESTAMP="$(date +%Y%m%dT%H%M%S)" +BACKUP_DIR="${BACKUP_ROOT}/${BACKUP_TIMESTAMP}" +NGINX_BACKUP="${BACKUP_DIR}/$(basename "$NGINX_CONFIG")" +COMPOSE_OVERRIDE_FILE="${BACKUP_DIR}/docker-compose.rollout.override.yml" +CUTOVER_DONE=false +LOCK_DIR="/tmp/${PROJECT_NAME//[^a-zA-Z0-9_.-]/_}.${DOMAIN//[^a-zA-Z0-9_.-]/_}.rollout.lock" +ROLLBACK_ATTEMPTED=false + +acquire_lock() { + if mkdir "$LOCK_DIR" 2>/dev/null; then + return 0 + fi + log_error "Another rollout appears to be running: $LOCK_DIR" + exit 1 +} + +release_lock() { + rmdir "$LOCK_DIR" >/dev/null 2>&1 || true +} + +get_domain_proxy_port() { + python3 - "$NGINX_CONFIG" "$DOMAIN" <<'PY' +import sys +from pathlib import Path + +config = Path(sys.argv[1]).read_text().splitlines(keepends=True) +domain = sys.argv[2] + +def iter_server_blocks(lines): + in_server = False + depth = 0 + block = [] + for line in lines: + stripped = line.strip() + if not in_server and stripped.startswith("server") and "{" in stripped: + in_server = True + block = [line] + depth = line.count("{") - line.count("}") + if depth == 0: + yield "".join(block) + in_server = False + continue + if in_server: + block.append(line) + depth += line.count("{") - line.count("}") + if depth == 0: + yield "".join(block) + in_server = False + +for block in iter_server_blocks(config): + if f"server_name {domain};" in block: + for line in block.splitlines(): + if "proxy_pass http://127.0.0.1:" in line: + value = line.split("proxy_pass http://127.0.0.1:", 1)[1].split(";", 1)[0].strip() + print(value) + sys.exit(0) + +sys.exit(1) +PY +} + +set_domain_proxy_port() { + local new_port="$1" + python3 - "$NGINX_CONFIG" "$DOMAIN" "$new_port" <<'PY' +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +domain = sys.argv[2] +new_port = sys.argv[3] +lines = path.read_text().splitlines(keepends=True) + +output = [] +block = [] +in_server = False +depth = 0 +updated = False + +def rewrite_block(block_text): + global updated + if f"server_name {domain};" not in block_text: + return block_text + if "proxy_pass http://127.0.0.1:" not in block_text: + return block_text + if updated: + raise SystemExit("multiple matching server blocks found") + rewritten_lines = [] + replaced = False + for line in block_text.splitlines(keepends=True): + if "proxy_pass http://127.0.0.1:" in line and not replaced: + prefix = line.split("proxy_pass http://127.0.0.1:", 1)[0] + suffix = "\n" if line.endswith("\n") else "" + rewritten_lines.append(f"{prefix}proxy_pass http://127.0.0.1:{new_port};{suffix}") + replaced = True + else: + rewritten_lines.append(line) + if not replaced: + raise SystemExit("failed to update target server block") + updated = True + return "".join(rewritten_lines) + +for line in lines: + stripped = line.strip() + if not in_server and stripped.startswith("server") and "{" in stripped: + in_server = True + block = [line] + depth = line.count("{") - line.count("}") + if depth == 0: + output.append(rewrite_block("".join(block))) + in_server = False + continue + if in_server: + block.append(line) + depth += line.count("{") - line.count("}") + if depth == 0: + output.append(rewrite_block("".join(block))) + in_server = False + continue + output.append(line) + +if in_server: + raise SystemExit("unterminated server block") +if not updated: + raise SystemExit("target domain block not found") + +path.write_text("".join(output)) +PY +} + +find_container_by_host_port() { + local port="$1" + docker ps --format '{{.Names}}\t{{.Ports}}' | awk -v port="$port" ' + $0 ~ ("127.0.0.1:" port "->") {print $1; exit} + ' +} + +port_is_free() { + local port="$1" + ! ss -ltnH "( sport = :${port} )" | grep -q . +} + +wait_http_ok() { + local url="$1" + local timeout="${2:-90}" + local start + start="$(date +%s)" + while true; do + if curl -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + if (( "$(date +%s)" - start >= timeout )); then + return 1 + fi + sleep 1 + done +} + +backup_runtime() { + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would create runtime backup: $BACKUP_DIR" + return 0 + fi + mkdir -p "$BACKUP_DIR" + cp "$NGINX_CONFIG" "$NGINX_BACKUP" + cp "$COMPOSE_FILE" "$BACKUP_DIR/" + cp "$ENV_FILE" "$BACKUP_DIR/" + docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Status}}' >"$BACKUP_DIR/docker-ps.txt" + docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.Size}}' >"$BACKUP_DIR/docker-images.txt" + printf '%s\n' "$IMAGE_TAG" >"$BACKUP_DIR/requested-image-tag.txt" + printf '%s\n' "$CURRENT_TAG" >"$BACKUP_DIR/current-runtime-tag.txt" + if docker image inspect "$CURRENT_TAG" >/dev/null 2>&1; then + docker tag "$CURRENT_TAG" "${CURRENT_TAG%:*}:rollback-${BACKUP_TIMESTAMP}" + fi +} + +restore_proxy_backup() { + if [[ ! -f "$NGINX_BACKUP" ]]; then + log_error "Nginx backup not found: $NGINX_BACKUP" + exit 1 + fi + cp "$NGINX_BACKUP" "$NGINX_CONFIG" + if ! nginx -t >/dev/null 2>&1; then + log_error "Failed to restore nginx backup cleanly: $NGINX_BACKUP" + exit 1 + fi + nginx -s reload >/dev/null +} + +cutover_proxy() { + local target_port="$1" + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would switch nginx traffic to 127.0.0.1:${target_port}" + return 0 + fi + set_domain_proxy_port "$target_port" + if ! nginx -t >/dev/null 2>&1; then + cp "$NGINX_BACKUP" "$NGINX_CONFIG" + log_error "nginx -t failed after editing config, restored backup" + exit 1 + fi + nginx -s reload >/dev/null + CUTOVER_DONE=true +} + +stop_old_live_container() { + local name="$1" + if [[ -n "$name" && "$KEEP_OLD_RUNNING" == false ]]; then + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would stop previous live container: $name" + return 0 + fi + docker stop "$name" >/dev/null || true + log_info "Stopped previous live container: $name" + elif [[ -n "$name" ]]; then + log_info "Keeping previous live container for rollback: $name" + fi +} + +stop_non_live_standard_holder() { + local name="$1" + if [[ -z "$name" ]]; then + return 0 + fi + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would stop non-live container occupying standard port: $name" + return 0 + fi + docker stop "$name" >/dev/null + log_info "Stopped non-live container occupying standard port: $name" +} + +start_compose_on_standard_port() { + log_info "Starting compose app service on standard port ${STANDARD_PORT}" + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would start compose app ${APP_SERVICE} on standard port ${STANDARD_PORT} using ${CURRENT_TAG}" + return 0 + fi + cat >"$COMPOSE_OVERRIDE_FILE" </dev/null +} + +start_manual_green() { + local candidate_port="$1" + local dsn="postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-claude_code_hub}" + local redis_url="redis://redis:6379" + + log_info "Starting manual green container ${GREEN_NAME} on port ${candidate_port}" + if [[ "$DRY_RUN" == true ]]; then + log_info "[dry-run] Would start manual green container ${GREEN_NAME} on ${candidate_port}" + return 0 + fi + docker rm -f "$GREEN_NAME" >/dev/null 2>&1 || true + docker run -d \ + --name "$GREEN_NAME" \ + --restart unless-stopped \ + --network "$DOCKER_NETWORK" \ + --env-file "$ENV_FILE" \ + -e HOST=0.0.0.0 \ + -e HOSTNAME=0.0.0.0 \ + -e NODE_ENV=production \ + -e DSN="$dsn" \ + -e REDIS_URL="$redis_url" \ + -e AUTO_MIGRATE=false \ + -e APP_PORT="$candidate_port" \ + -p "127.0.0.1:${candidate_port}:3000" \ + --health-cmd "node -e \"fetch('http://127.0.0.1:3000${HEALTH_PATH}').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" \ + --health-interval 30s \ + --health-timeout 5s \ + --health-retries 3 \ + --health-start-period 30s \ + "$IMAGE_TAG" >/dev/null +} + +verify_public_health() { + local url="https://${DOMAIN}${HEALTH_PATH}" + wait_http_ok "$url" "$PUBLIC_HEALTH_TIMEOUT" || { + log_error "Public health check failed: $url" + if [[ "$CUTOVER_DONE" == true && "$DRY_RUN" == false ]]; then + log_warn "Rolling nginx back to previous live port" + restore_proxy_backup + ROLLBACK_ATTEMPTED=true + fi + exit 1 + } +} + +verify_live_public_before_rollout() { + local url="https://${DOMAIN}${HEALTH_PATH}" + wait_http_ok "$url" "$PUBLIC_HEALTH_TIMEOUT" || { + log_error "Current public health is not healthy, aborting rollout: $url" + exit 1 + } +} + +print_plan_and_exit() { + local mode="$1" + echo + log_info "Dry run only. No changes were made." + echo " requested image: $IMAGE_TAG" + echo " current live: ${LIVE_CONTAINER:-} on ${LIVE_PORT}" + echo " standard port: $STANDARD_PORT" + echo " standard holder: ${STANDARD_PORT_CONTAINER:-}" + echo " selected mode: $mode" + echo " keep old live: $KEEP_OLD_RUNNING" + echo " backup dir: $BACKUP_DIR" + exit 0 +} + +handle_exit() { + local status="$1" + release_lock + if [[ "$status" -ne 0 && "$CUTOVER_DONE" == true && "$DRY_RUN" == false && "$ROLLBACK_ATTEMPTED" == false && -f "$NGINX_BACKUP" ]]; then + log_warn "Failure detected after cutover, restoring nginx backup" + cp "$NGINX_BACKUP" "$NGINX_CONFIG" >/dev/null 2>&1 || true + nginx -t >/dev/null 2>&1 && nginx -s reload >/dev/null 2>&1 || true + fi +} + +acquire_lock +trap 'handle_exit $?' EXIT + +LIVE_PORT="$(get_domain_proxy_port)" +LIVE_CONTAINER="$(find_container_by_host_port "$LIVE_PORT" || true)" +STANDARD_PORT_CONTAINER="$(find_container_by_host_port "$STANDARD_PORT" || true)" + +log_info "Current live domain: ${DOMAIN}" +log_info "Current live port: ${LIVE_PORT}" +log_info "Current live container: ${LIVE_CONTAINER:-}" +log_info "Standard port: ${STANDARD_PORT}" +log_info "Green fallback port: ${GREEN_PORT}" +log_info "Keep old live container after cutover: ${KEEP_OLD_RUNNING}" + +docker image inspect "$IMAGE_TAG" >/dev/null 2>&1 || { + log_error "Image tag not found on server: $IMAGE_TAG" + exit 1 +} + +verify_live_public_before_rollout + +if [[ "$DRY_RUN" == true ]]; then + if [[ "$LIVE_PORT" != "$STANDARD_PORT" ]] && port_is_free "$STANDARD_PORT"; then + print_plan_and_exit "compose-standard-free" + elif [[ "$LIVE_PORT" != "$STANDARD_PORT" ]] && [[ -n "$STANDARD_PORT_CONTAINER" && "$STANDARD_PORT_CONTAINER" != "$LIVE_CONTAINER" ]]; then + print_plan_and_exit "compose-standard-reclaim" + elif [[ "$LIVE_PORT" != "$STANDARD_PORT" ]] && wait_http_ok "http://127.0.0.1:${STANDARD_PORT}${HEALTH_PATH}" 2; then + print_plan_and_exit "cut-back-to-running-standard" + else + print_plan_and_exit "manual-green" + fi +fi + +backup_runtime +log_success "Runtime backup created: $BACKUP_DIR" + +docker tag "$IMAGE_TAG" "$CURRENT_TAG" +log_info "Retagged ${IMAGE_TAG} -> ${CURRENT_TAG}" + +if [[ "$LIVE_PORT" != "$STANDARD_PORT" ]] && port_is_free "$STANDARD_PORT"; then + start_compose_on_standard_port + wait_http_ok "http://127.0.0.1:${STANDARD_PORT}${HEALTH_PATH}" "$LOCAL_HEALTH_TIMEOUT" || { + log_error "Compose app failed local health check on standard port ${STANDARD_PORT}" + exit 1 + } + cutover_proxy "$STANDARD_PORT" + verify_public_health + stop_old_live_container "$LIVE_CONTAINER" + log_success "Traffic switched to compose app on standard port ${STANDARD_PORT}" + log_info "Runtime ownership: compose-managed" +elif [[ "$LIVE_PORT" != "$STANDARD_PORT" ]] && [[ -n "$STANDARD_PORT_CONTAINER" && "$STANDARD_PORT_CONTAINER" != "$LIVE_CONTAINER" ]]; then + log_info "Standard port ${STANDARD_PORT} is occupied by non-live container ${STANDARD_PORT_CONTAINER}, reclaiming it" + stop_non_live_standard_holder "$STANDARD_PORT_CONTAINER" + port_is_free "$STANDARD_PORT" || { + log_error "Standard port is still in use after reclaim attempt: ${STANDARD_PORT}" + exit 1 + } + start_compose_on_standard_port + wait_http_ok "http://127.0.0.1:${STANDARD_PORT}${HEALTH_PATH}" "$LOCAL_HEALTH_TIMEOUT" || { + log_error "Compose app failed local health check on reclaimed standard port ${STANDARD_PORT}" + exit 1 + } + cutover_proxy "$STANDARD_PORT" + verify_public_health + stop_old_live_container "$LIVE_CONTAINER" + log_success "Traffic switched to compose app on reclaimed standard port ${STANDARD_PORT}" + log_info "Runtime ownership: compose-managed" +elif [[ "$LIVE_PORT" != "$STANDARD_PORT" ]] && wait_http_ok "http://127.0.0.1:${STANDARD_PORT}${HEALTH_PATH}" 2; then + log_info "Standard port ${STANDARD_PORT} is already healthy, cutting traffic back without restarting app" + cutover_proxy "$STANDARD_PORT" + verify_public_health + stop_old_live_container "$LIVE_CONTAINER" + log_success "Traffic switched to already-running app on standard port ${STANDARD_PORT}" + log_info "Runtime ownership: compose-managed" +else + if [[ "$GREEN_PORT" == "$LIVE_PORT" || "$GREEN_PORT" == "$STANDARD_PORT" ]]; then + GREEN_PORT="$((STANDARD_PORT + 2))" + fi + port_is_free "$GREEN_PORT" || { + log_error "Green port is already in use: $GREEN_PORT" + exit 1 + } + start_manual_green "$GREEN_PORT" + wait_http_ok "http://127.0.0.1:${GREEN_PORT}${HEALTH_PATH}" "$LOCAL_HEALTH_TIMEOUT" || { + log_error "Manual green container failed local health check on ${GREEN_PORT}" + exit 1 + } + cutover_proxy "$GREEN_PORT" + verify_public_health + stop_old_live_container "$LIVE_CONTAINER" + if [[ "$LIVE_PORT" == "$STANDARD_PORT" ]]; then + log_warn "Traffic is healthy on temporary green port ${GREEN_PORT}, but standard port normalization is still pending" + else + log_warn "Standard port ${STANDARD_PORT} was unavailable, kept traffic on temporary green port ${GREEN_PORT}" + fi + log_info "Runtime ownership: manual green container" +fi + +FINAL_PORT="$(get_domain_proxy_port)" +FINAL_CONTAINER="$(find_container_by_host_port "$FINAL_PORT" || true)" + +echo +log_success "Rollout complete" +echo " image tag: $IMAGE_TAG" +echo " current tag: $CURRENT_TAG" +echo " live port: $FINAL_PORT" +echo " live container: ${FINAL_CONTAINER:-}" +echo " nginx backup: $NGINX_BACKUP" +echo " runtime backup: $BACKUP_DIR" diff --git a/src/actions/keys.ts b/src/actions/keys.ts index 6d3caf8e8..21a798ac9 100644 --- a/src/actions/keys.ts +++ b/src/actions/keys.ts @@ -10,6 +10,15 @@ import { emitActionAudit } from "@/lib/audit/emit"; import { getSession } from "@/lib/auth"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { logger } from "@/lib/logger"; +import { + buildTemporaryKeyCreatePayloads, + buildTemporaryKeyGroupText, + normalizeTemporaryGroupName, + resolveTemporaryGroupName, + TEMPORARY_GROUP_NAME_MAX_LENGTH, + TEMPORARY_KEY_BATCH_MAX_COUNT, + validateTemporaryKeyLimitsAgainstUser, +} from "@/lib/keys/temporary-key-groups"; import { resolveKeyConcurrentSessionLimit } from "@/lib/rate-limit/concurrent-session-limit"; import { resolveKeyCostResetAt } from "@/lib/rate-limit/cost-reset-utils"; import { invalidateCachedKey } from "@/lib/security/api-key-auth-cache"; @@ -22,7 +31,9 @@ import { toKey } from "@/repository/_shared/transformers"; import type { KeyStatistics } from "@/repository/key"; import { countActiveKeysByUser, + createKeysBatch, createKey, + deleteKeysBatch, deleteKey, findActiveKeyByUserIdAndName, findKeyById, @@ -828,6 +839,249 @@ export async function getKeysWithStatistics( } } +export interface CreateTemporaryKeysBatchParams { + userId: number; + baseKeyId: number; + count: number; + customLimitTotalUsd?: number; +} + +export interface TemporaryKeyBatchItem { + name: string; + key: string; + createdAt: string; + expiresAt: string | null; + limitTotalUsd: number | null; +} + +export interface CreateTemporaryKeysBatchResult { + groupName: string; + createdCount: number; + sourceKeyName: string; + keys: TemporaryKeyBatchItem[]; +} + +export async function createTemporaryKeysBatch( + params: CreateTemporaryKeysBatchParams +): Promise> { + try { + const tError = await getTranslations("errors"); + + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { + ok: false, + error: tError("PERMISSION_DENIED"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const count = Number.isFinite(params.count) ? Math.trunc(params.count) : 0; + if (count < 1 || count > TEMPORARY_KEY_BATCH_MAX_COUNT) { + return { + ok: false, + error: `单次最多生成 ${TEMPORARY_KEY_BATCH_MAX_COUNT} 个临时 Key`, + errorCode: ERROR_CODES.INVALID_FORMAT, + }; + } + + const { findUserById } = await import("@/repository/user"); + const user = await findUserById(params.userId); + if (!user) { + return { + ok: false, + error: tError("USER_NOT_FOUND"), + errorCode: ERROR_CODES.NOT_FOUND, + }; + } + + const normalizedGroupName = resolveTemporaryGroupName(user.providerGroup); + if (normalizedGroupName.length > TEMPORARY_GROUP_NAME_MAX_LENGTH) { + return { + ok: false, + error: `临时分组名称不能超过 ${TEMPORARY_GROUP_NAME_MAX_LENGTH} 个字符`, + errorCode: ERROR_CODES.INVALID_FORMAT, + }; + } + + const baseKey = await findKeyById(params.baseKeyId); + if (!baseKey || baseKey.userId !== params.userId) { + return { + ok: false, + error: tError("KEY_NOT_FOUND"), + errorCode: ERROR_CODES.NOT_FOUND, + }; + } + + const limitValidationError = validateTemporaryKeyLimitsAgainstUser( + user, + { + limit5hUsd: baseKey.limit5hUsd, + limitDailyUsd: baseKey.limitDailyUsd, + limitWeeklyUsd: baseKey.limitWeeklyUsd, + limitMonthlyUsd: baseKey.limitMonthlyUsd, + limitTotalUsd: + params.customLimitTotalUsd !== undefined + ? params.customLimitTotalUsd + : (baseKey.limitTotalUsd ?? null), + limitConcurrentSessions: baseKey.limitConcurrentSessions, + }, + tError + ); + if (limitValidationError) { + return { ok: false, error: limitValidationError }; + } + + const existingKeys = await findKeyList(params.userId); + const createPayloads = buildTemporaryKeyCreatePayloads({ + userId: params.userId, + baseKey, + existingKeys, + groupName: normalizedGroupName, + count, + customLimitTotalUsd: params.customLimitTotalUsd, + createKeyString: () => `sk-${randomBytes(16).toString("hex")}`, + }); + + const createdKeys = await createKeysBatch(createPayloads); + + revalidatePath("/dashboard"); + + return { + ok: true, + data: { + groupName: normalizedGroupName, + createdCount: createdKeys.length, + sourceKeyName: baseKey.name, + keys: createdKeys.map((key) => ({ + name: key.name, + key: key.key, + createdAt: key.createdAt.toISOString(), + expiresAt: key.expiresAt?.toISOString() ?? null, + limitTotalUsd: key.limitTotalUsd ?? null, + })), + }, + }; + } catch (error) { + logger.error("批量创建临时 Key 失败:", error); + const message = error instanceof Error ? error.message : "批量创建临时 Key 失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.CREATE_FAILED }; + } +} + +export async function removeTemporaryKeyGroup(params: { + userId: number; + groupName: string; +}): Promise> { + try { + const tError = await getTranslations("errors"); + + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { + ok: false, + error: tError("PERMISSION_DENIED"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const normalizedGroupName = normalizeTemporaryGroupName(params.groupName); + if (!normalizedGroupName) { + return { + ok: false, + error: "临时分组名称不能为空", + errorCode: ERROR_CODES.REQUIRED_FIELD, + }; + } + + const userKeys = await findKeyList(params.userId); + const groupKeys = userKeys.filter((key) => key.temporaryGroupName === normalizedGroupName); + if (groupKeys.length === 0) { + return { + ok: false, + error: "临时 Key 分组不存在", + errorCode: ERROR_CODES.NOT_FOUND, + }; + } + + const enabledCountInGroup = groupKeys.filter((key) => key.isEnabled).length; + if (enabledCountInGroup > 0) { + const activeKeyCount = await countActiveKeysByUser(params.userId); + if (activeKeyCount - enabledCountInGroup < 1) { + return { + ok: false, + error: tError("CANNOT_DISABLE_LAST_KEY"), + errorCode: ERROR_CODES.OPERATION_FAILED, + }; + } + } + + const deletedCount = await deleteKeysBatch(groupKeys.map((key) => key.id)); + revalidatePath("/dashboard"); + + return { + ok: true, + data: { + deletedCount, + groupName: normalizedGroupName, + }, + }; + } catch (error) { + logger.error("删除临时 Key 分组失败:", error); + const message = error instanceof Error ? error.message : "删除临时 Key 分组失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.DELETE_FAILED }; + } +} + +export async function downloadTemporaryKeyGroup(params: { + userId: number; + groupName: string; +}): Promise> { + try { + const tError = await getTranslations("errors"); + + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { + ok: false, + error: tError("PERMISSION_DENIED"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const normalizedGroupName = normalizeTemporaryGroupName(params.groupName); + if (!normalizedGroupName) { + return { + ok: false, + error: "临时分组名称不能为空", + errorCode: ERROR_CODES.REQUIRED_FIELD, + }; + } + + const userKeys = await findKeyList(params.userId); + const groupKeys = userKeys + .filter((key) => key.temporaryGroupName === normalizedGroupName) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + + if (groupKeys.length === 0) { + return { + ok: false, + error: "临时 Key 分组不存在", + errorCode: ERROR_CODES.NOT_FOUND, + }; + } + + return { + ok: true, + data: buildTemporaryKeyGroupText(groupKeys), + }; + } catch (error) { + logger.error("下载临时 Key 分组失败:", error); + const message = error instanceof Error ? error.message : "下载临时 Key 分组失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.INTERNAL_ERROR }; + } +} + /** * 获取密钥的限额使用情况(实时数据) */ diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index a1536f2dc..d4b8c64c9 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -8,6 +8,7 @@ import { messageRequest, usageLedger } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; import { lookupIp } from "@/lib/ip-geo/client"; import { logger } from "@/lib/logger"; +import { redactReadonlyLogs, redactReadonlyQuota } from "@/lib/my-usage/readonly-redaction"; import { resolveKeyConcurrentSessionLimit } from "@/lib/rate-limit/concurrent-session-limit"; import { clipStartByResetAt, @@ -171,6 +172,17 @@ export interface MyUsageMetadata { billingModelSource: BillingModelSource; } +export interface MyUsageQuotaWindow { + period: "5h" | "daily" | "weekly" | "monthly" | "total"; + limitUsd: number | null; + usedUsd: number; + remainingUsd: number | null; + usedPercent: number | null; + remainingPercent: number | null; + isUnlimited: boolean; + isExhausted: boolean; +} + export interface MyUsageQuota { keyLimit5hUsd: number | null; keyLimitDailyUsd: number | null; @@ -208,12 +220,153 @@ export interface MyUsageQuota { keyName: string; keyIsEnabled: boolean; + providerGroup: string | null; + + limit5hUsd: number | null; + used5hUsd: number; + remaining5hUsd: number | null; + + limitDailyUsd: number | null; + usedDailyUsd: number; + remainingDailyUsd: number | null; + + limitWeeklyUsd: number | null; + usedWeeklyUsd: number; + remainingWeeklyUsd: number | null; + + limitMonthlyUsd: number | null; + usedMonthlyUsd: number; + remainingMonthlyUsd: number | null; + + limitTotalUsd: number | null; + usedTotalUsd: number; + remainingTotalUsd: number | null; + + quotaWindows: { + fiveHour: MyUsageQuotaWindow; + daily: MyUsageQuotaWindow; + weekly: MyUsageQuotaWindow; + monthly: MyUsageQuotaWindow; + total: MyUsageQuotaWindow; + }; + todayUsedUsd: number; + todayRemainingUsd: number | null; + todayUsedPercent: number | null; + todayRemainingPercent: number | null; + remainingPercent: number | null; + + rpmLimit: number | null; + concurrentSessions: number; + concurrentSessionsLimit: number | null; + userAllowedModels: string[]; userAllowedClients: string[]; expiresAt: Date | null; dailyResetMode: "fixed" | "rolling"; dailyResetTime: string; + resetMode: "fixed" | "rolling"; + resetTime: string; + remaining: number | null; + unit: "USD"; +} + +type EffectiveQuotaWindow = { + limit: number | null; + used: number; + remaining: number | null; +}; + +function clampRemaining(limit: number, used: number): number { + return Math.max(limit - used, 0); +} + +function resolveEffectiveQuotaWindow( + candidates: Array<{ limit: number | null | undefined; used: number }> +): EffectiveQuotaWindow { + const boundedCandidates = candidates + .filter((candidate): candidate is { limit: number; used: number } => candidate.limit != null) + .map((candidate) => ({ + limit: candidate.limit, + used: candidate.used, + remaining: clampRemaining(candidate.limit, candidate.used), + })); + + if (boundedCandidates.length === 0) { + return { + limit: null, + used: Math.max(...candidates.map((candidate) => candidate.used), 0), + remaining: null, + }; + } + + const mostRestrictive = boundedCandidates.reduce((current, candidate) => { + if (candidate.remaining < current.remaining) { + return candidate; + } + + if (candidate.remaining === current.remaining && candidate.limit < current.limit) { + return candidate; + } + + return current; + }); + + return mostRestrictive; +} + +function resolveOverallRemaining(values: Array): number | null { + const boundedValues = values.filter((value): value is number => value != null); + if (boundedValues.length === 0) { + return null; + } + + return Math.max(Math.min(...boundedValues), 0); +} + +function round2(value: number): number { + return Math.round((value + Number.EPSILON) * 100) / 100; +} + +function buildQuotaWindow( + period: MyUsageQuotaWindow["period"], + window: EffectiveQuotaWindow +): MyUsageQuotaWindow { + const limitUsd = window.limit == null ? null : round2(window.limit); + const usedUsd = round2(window.used); + const remainingUsd = window.remaining == null ? null : round2(window.remaining); + const hasPositiveLimit = limitUsd != null && limitUsd > 0; + + return { + period, + limitUsd, + usedUsd, + remainingUsd, + usedPercent: hasPositiveLimit ? round2((window.used / limitUsd) * 100) : null, + remainingPercent: + hasPositiveLimit && remainingUsd != null ? round2((remainingUsd / limitUsd) * 100) : null, + isUnlimited: limitUsd == null, + isExhausted: remainingUsd != null && remainingUsd <= 0, + }; +} + +function resolveOverallRemainingPercent(windows: MyUsageQuotaWindow[]): number | null { + const values = windows + .map((window) => window.remainingPercent) + .filter((value): value is number => value != null); + + if (values.length === 0) { + return null; + } + + return Math.max(Math.min(...values), 0); +} + +function resolveTotalLimitWithMonthlyFallback(params: { + totalLimit: number | null | undefined; + monthlyLimit: number | null | undefined; +}): number | null { + return params.totalLimit ?? params.monthlyLimit ?? null; } export interface MyTodayStats { @@ -475,13 +628,59 @@ export async function getMyQuota(): Promise> { } = userCosts; const resolvedKeyCurrent5hUsd = keyFixed5hUsd ?? keyCurrent5hUsd; const resolvedUserCurrent5hUsd = userFixed5hUsd ?? userCurrent5hUsd; + const keyLimitTotalUsd = resolveTotalLimitWithMonthlyFallback({ + totalLimit: key.limitTotalUsd, + monthlyLimit: key.limitMonthlyUsd, + }); + const userLimitTotalUsd = resolveTotalLimitWithMonthlyFallback({ + totalLimit: user.limitTotalUsd, + monthlyLimit: user.limitMonthlyUsd, + }); + + const effective5h = resolveEffectiveQuotaWindow([ + { limit: key.limit5hUsd, used: resolvedKeyCurrent5hUsd }, + { limit: user.limit5hUsd, used: resolvedUserCurrent5hUsd }, + ]); + const effectiveDaily = resolveEffectiveQuotaWindow([ + { limit: key.limitDailyUsd, used: keyCostDaily }, + { limit: user.dailyQuota, used: userCostDaily }, + ]); + const effectiveWeekly = resolveEffectiveQuotaWindow([ + { limit: key.limitWeeklyUsd, used: keyCostWeekly }, + { limit: user.limitWeeklyUsd, used: userCostWeekly }, + ]); + const effectiveMonthly = resolveEffectiveQuotaWindow([ + { limit: key.limitMonthlyUsd, used: keyCostMonthly }, + { limit: user.limitMonthlyUsd, used: userCostMonthly }, + ]); + const effectiveTotal = resolveEffectiveQuotaWindow([ + { limit: keyLimitTotalUsd, used: keyTotalCost }, + { limit: userLimitTotalUsd, used: userTotalCost }, + ]); + const overallRemaining = resolveOverallRemaining([ + effective5h.remaining, + effectiveDaily.remaining, + effectiveWeekly.remaining, + effectiveMonthly.remaining, + effectiveTotal.remaining, + ]); + const quotaWindows = { + fiveHour: buildQuotaWindow("5h", effective5h), + daily: buildQuotaWindow("daily", effectiveDaily), + weekly: buildQuotaWindow("weekly", effectiveWeekly), + monthly: buildQuotaWindow("monthly", effectiveMonthly), + total: buildQuotaWindow("total", effectiveTotal), + }; + const concurrentSessions = Math.max(keyConcurrent, userKeyConcurrent); + const concurrentSessionsLimit = + effectiveKeyConcurrentLimit > 0 ? effectiveKeyConcurrentLimit : null; const quota: MyUsageQuota = { keyLimit5hUsd: key.limit5hUsd ?? null, keyLimitDailyUsd: key.limitDailyUsd ?? null, keyLimitWeeklyUsd: key.limitWeeklyUsd ?? null, keyLimitMonthlyUsd: key.limitMonthlyUsd ?? null, - keyLimitTotalUsd: key.limitTotalUsd ?? null, + keyLimitTotalUsd, keyLimitConcurrentSessions: effectiveKeyConcurrentLimit, keyCurrent5hUsd: resolvedKeyCurrent5hUsd, keyCurrentDailyUsd: keyCostDaily, @@ -493,7 +692,7 @@ export async function getMyQuota(): Promise> { userLimit5hUsd: user.limit5hUsd ?? null, userLimitWeeklyUsd: user.limitWeeklyUsd ?? null, userLimitMonthlyUsd: user.limitMonthlyUsd ?? null, - userLimitTotalUsd: user.limitTotalUsd ?? null, + userLimitTotalUsd, userLimitConcurrentSessions: user.limitConcurrentSessions ?? null, userRpmLimit: user.rpm ?? null, userCurrent5hUsd: resolvedUserCurrent5hUsd, @@ -513,15 +712,52 @@ export async function getMyQuota(): Promise> { keyName: key.name, keyIsEnabled: key.isEnabled ?? true, + providerGroup: key.providerGroup ?? user.providerGroup ?? null, + + limit5hUsd: effective5h.limit, + used5hUsd: effective5h.used, + remaining5hUsd: effective5h.remaining, + + limitDailyUsd: effectiveDaily.limit, + usedDailyUsd: effectiveDaily.used, + remainingDailyUsd: effectiveDaily.remaining, + + limitWeeklyUsd: effectiveWeekly.limit, + usedWeeklyUsd: effectiveWeekly.used, + remainingWeeklyUsd: effectiveWeekly.remaining, + + limitMonthlyUsd: effectiveMonthly.limit, + usedMonthlyUsd: effectiveMonthly.used, + remainingMonthlyUsd: effectiveMonthly.remaining, + + limitTotalUsd: effectiveTotal.limit, + usedTotalUsd: effectiveTotal.used, + remainingTotalUsd: effectiveTotal.remaining, + + quotaWindows, + todayUsedUsd: quotaWindows.daily.usedUsd, + todayRemainingUsd: quotaWindows.daily.remainingUsd, + todayUsedPercent: quotaWindows.daily.usedPercent, + todayRemainingPercent: quotaWindows.daily.remainingPercent, + remainingPercent: resolveOverallRemainingPercent(Object.values(quotaWindows)), + + rpmLimit: user.rpm ?? null, + concurrentSessions, + concurrentSessionsLimit, + userAllowedModels: user.allowedModels ?? [], userAllowedClients: user.allowedClients ?? [], expiresAt: key.expiresAt ?? null, dailyResetMode: key.dailyResetMode ?? "fixed", dailyResetTime: key.dailyResetTime ?? "00:00", + resetMode: key.dailyResetMode ?? "fixed", + resetTime: key.dailyResetTime ?? "00:00", + remaining: overallRemaining, + unit: "USD", }; - return { ok: true, data: quota }; + return { ok: true, data: redactReadonlyQuota(quota, key) }; } catch (error) { logger.error("[my-usage] getMyQuota failed", error); return { ok: false, error: "Failed to get quota information" }; @@ -695,7 +931,10 @@ export async function getMyUsageLogs( return { ok: true, data: { - logs: mapMyUsageLogEntries(result, settings.billingModelSource), + logs: redactReadonlyLogs( + mapMyUsageLogEntries(result, settings.billingModelSource), + session.key + ), total: result.total, page, pageSize, @@ -740,7 +979,10 @@ export async function getMyUsageLogsBatch( return { ok: true, data: { - logs: mapMyUsageLogEntries(result, settings.billingModelSource), + logs: redactReadonlyLogs( + mapMyUsageLogEntries(result, settings.billingModelSource), + session.key + ), nextCursor: result.nextCursor, hasMore: result.hasMore, currencyCode: settings.currencyDisplay, diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts index 65f711843..2ff69a65a 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -56,6 +56,7 @@ export async function saveSystemSettings(formData: { currencyDisplay?: string; billingModelSource?: string; codexPriorityBillingSource?: CodexPriorityBillingSource; + costMultiplierCorrection?: number; timezone?: string | null; enableAutoCleanup?: boolean; cleanupRetentionDays?: number; @@ -104,6 +105,7 @@ export async function saveSystemSettings(formData: { currencyDisplay: validated.currencyDisplay, billingModelSource: validated.billingModelSource, codexPriorityBillingSource: validated.codexPriorityBillingSource, + costMultiplierCorrection: validated.costMultiplierCorrection, timezone: validated.timezone, enableAutoCleanup: validated.enableAutoCleanup, cleanupRetentionDays: validated.cleanupRetentionDays, diff --git a/src/actions/users.ts b/src/actions/users.ts index 821823b91..227b40e7d 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -1,11 +1,16 @@ "use server"; import { randomBytes } from "node:crypto"; -import { and, eq, inArray, isNull } from "drizzle-orm"; +import { and, asc, eq, inArray, isNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { getLocale, getTranslations } from "next-intl/server"; import { db } from "@/drizzle/db"; -import { messageRequest, usageLedger, users as usersTable } from "@/drizzle/schema"; +import { + keys as keysTable, + messageRequest, + usageLedger, + users as usersTable, +} from "@/drizzle/schema"; import { emitActionAudit } from "@/lib/audit/emit"; import { getSession } from "@/lib/auth"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; @@ -13,7 +18,12 @@ import { logger } from "@/lib/logger"; import { getUnauthorizedFields } from "@/lib/permissions/user-field-permissions"; import { clipStartByResetAt, resolveUser5hCostResetAt } from "@/lib/rate-limit/cost-reset-utils"; import { getRedisClient } from "@/lib/redis"; -import { invalidateCachedUser } from "@/lib/security/api-key-auth-cache"; +import { invalidateCachedKey, invalidateCachedUser } from "@/lib/security/api-key-auth-cache"; +import { + buildFirstSyncedKeyConfig, + buildSyncedKeyConfigs, + type UserKeySyncSummary, +} from "@/lib/users/user-key-sync"; import { parseDateInputAsTimezone } from "@/lib/utils/date-input"; import { ERROR_CODES } from "@/lib/utils/error-messages"; import { normalizeProviderGroup, parseProviderGroups } from "@/lib/utils/provider-group"; @@ -237,10 +247,15 @@ export interface BatchUpdateUsersParams { tags?: string[]; rpm?: number | null; dailyQuota?: number | null; + providerGroup?: string | null; limit5hUsd?: number | null; limit5hResetMode?: "fixed" | "rolling"; limitWeeklyUsd?: number | null; limitMonthlyUsd?: number | null; + limitTotalUsd?: number | null; + limitConcurrentSessions?: number | null; + dailyResetMode?: "fixed" | "rolling"; + dailyResetTime?: string; }; } @@ -257,6 +272,92 @@ class BatchUpdateError extends Error { } } +type EditableUserData = { + name?: string; + note?: string; + providerGroup?: string | null; + tags?: string[]; + rpm?: number | null; + dailyQuota?: number | null; + limit5hUsd?: number | null; + limitWeeklyUsd?: number | null; + limitMonthlyUsd?: number | null; + limitTotalUsd?: number | null; + limitConcurrentSessions?: number | null; + dailyResetMode?: "fixed" | "rolling"; + dailyResetTime?: string; + isEnabled?: boolean; + expiresAt?: Date | null; + allowedClients?: string[]; + blockedClients?: string[]; + allowedModels?: string[]; +}; + +export interface SyncUserConfigToKeysResult { + updatedUserId: number; + updatedKeyIds: number[]; + keyCount: number; + summary: UserKeySyncSummary; +} + +export interface BatchSyncUserConfigToKeysResult { + requestedCount: number; + updatedUserCount: number; + updatedKeyCount: number; + users: SyncUserConfigToKeysResult[]; +} + +function buildUserDbUpdates( + validatedData: EditableUserData, + options: { forceUpdatedAt?: boolean } = {} +): Record { + const dbUpdates: Record = {}; + if (options.forceUpdatedAt) dbUpdates.updatedAt = new Date(); + if (validatedData.name !== undefined) dbUpdates.name = validatedData.name; + if (validatedData.note !== undefined) dbUpdates.description = validatedData.note; + if (validatedData.providerGroup !== undefined) { + dbUpdates.providerGroup = normalizeProviderGroup(validatedData.providerGroup); + } + if (validatedData.tags !== undefined) dbUpdates.tags = validatedData.tags; + if (validatedData.rpm !== undefined) dbUpdates.rpmLimit = validatedData.rpm; + if (validatedData.dailyQuota !== undefined) { + dbUpdates.dailyLimitUsd = + validatedData.dailyQuota === null ? null : validatedData.dailyQuota.toString(); + } + if (validatedData.limit5hUsd !== undefined) { + dbUpdates.limit5hUsd = + validatedData.limit5hUsd === null ? null : validatedData.limit5hUsd.toString(); + } + if (validatedData.limitWeeklyUsd !== undefined) { + dbUpdates.limitWeeklyUsd = + validatedData.limitWeeklyUsd === null ? null : validatedData.limitWeeklyUsd.toString(); + } + if (validatedData.limitMonthlyUsd !== undefined) { + dbUpdates.limitMonthlyUsd = + validatedData.limitMonthlyUsd === null ? null : validatedData.limitMonthlyUsd.toString(); + } + if (validatedData.limitTotalUsd !== undefined) { + dbUpdates.limitTotalUsd = + validatedData.limitTotalUsd === null ? null : validatedData.limitTotalUsd.toString(); + } + if (validatedData.limitConcurrentSessions !== undefined) { + dbUpdates.limitConcurrentSessions = validatedData.limitConcurrentSessions; + } + if (validatedData.dailyResetMode !== undefined) + dbUpdates.dailyResetMode = validatedData.dailyResetMode; + if (validatedData.dailyResetTime !== undefined) + dbUpdates.dailyResetTime = validatedData.dailyResetTime; + if (validatedData.isEnabled !== undefined) dbUpdates.isEnabled = validatedData.isEnabled; + if (validatedData.expiresAt !== undefined) dbUpdates.expiresAt = validatedData.expiresAt; + if (validatedData.allowedClients !== undefined) + dbUpdates.allowedClients = validatedData.allowedClients; + if (validatedData.blockedClients !== undefined) + dbUpdates.blockedClients = validatedData.blockedClients; + if (validatedData.allowedModels !== undefined) + dbUpdates.allowedModels = validatedData.allowedModels; + return dbUpdates; +} + /** * 验证过期时间的公共函数 * @param expiresAt - 过期时间 @@ -452,6 +553,7 @@ export async function getUsers(params?: GetUsersBatchParams): Promise> { + try { + const tError = await getTranslations("errors"); + + const session = await getSession(); + if (!session) { + return { + ok: false, + error: tError("UNAUTHORIZED"), + errorCode: ERROR_CODES.UNAUTHORIZED, + }; + } + if (session.user.role !== "admin") { + return { + ok: false, + error: tError("PERMISSION_DENIED"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const MAX_BATCH_SIZE = 500; + const requestedIds = Array.from(new Set(params.userIds)).filter((id) => Number.isInteger(id)); + if (requestedIds.length === 0) { + return { ok: false, error: tError("REQUIRED_FIELD"), errorCode: ERROR_CODES.REQUIRED_FIELD }; + } + if (requestedIds.length > MAX_BATCH_SIZE) { + return { + ok: false, + error: tError("BATCH_SIZE_EXCEEDED", { max: MAX_BATCH_SIZE }), + errorCode: ERROR_CODES.INVALID_FORMAT, + }; + } + + const updatesSchema = UpdateUserSchema.pick({ + note: true, + tags: true, + rpm: true, + dailyQuota: true, + providerGroup: true, + limit5hUsd: true, + limitWeeklyUsd: true, + limitMonthlyUsd: true, + limitTotalUsd: true, + limitConcurrentSessions: true, + dailyResetMode: true, + dailyResetTime: true, + }); + const validationResult = updatesSchema.safeParse(params.updates ?? {}); + if (!validationResult.success) { + return { + ok: false, + error: formatZodError(validationResult.error), + errorCode: ERROR_CODES.INVALID_FORMAT, + }; + } + + const updates = validationResult.data; + const hasAnyUpdate = Object.values(updates).some((value) => value !== undefined); + let result: BatchSyncUserConfigToKeysResult | null = null; + let keyStringsForCache: string[] = []; + + await db.transaction(async (tx) => { + if (hasAnyUpdate) { + await tx + .update(usersTable) + .set(buildUserDbUpdates(updates, { forceUpdatedAt: true })) + .where(and(inArray(usersTable.id, requestedIds), isNull(usersTable.deletedAt))); + } + + const userRows = await tx + .select({ + id: usersTable.id, + dailyQuota: usersTable.dailyLimitUsd, + providerGroup: usersTable.providerGroup, + limit5hUsd: usersTable.limit5hUsd, + limitWeeklyUsd: usersTable.limitWeeklyUsd, + limitMonthlyUsd: usersTable.limitMonthlyUsd, + limitTotalUsd: usersTable.limitTotalUsd, + limitConcurrentSessions: usersTable.limitConcurrentSessions, + dailyResetMode: usersTable.dailyResetMode, + dailyResetTime: usersTable.dailyResetTime, + }) + .from(usersTable) + .where(and(inArray(usersTable.id, requestedIds), isNull(usersTable.deletedAt))) + .orderBy(asc(usersTable.id)); + + const existingSet = new Set(userRows.map((row) => row.id)); + const missingIds = requestedIds.filter((id) => !existingSet.has(id)); + if (missingIds.length > 0) { + throw new BatchUpdateError( + `部分用户不存在: ${missingIds.join(", ")}`, + ERROR_CODES.NOT_FOUND + ); + } + + const keyRows = await tx + .select({ + id: keysTable.id, + key: keysTable.key, + userId: keysTable.userId, + }) + .from(keysTable) + .where(and(inArray(keysTable.userId, requestedIds), isNull(keysTable.deletedAt))) + .orderBy(asc(keysTable.userId), asc(keysTable.createdAt), asc(keysTable.id)); + + const keysByUserId = new Map(); + for (const keyRow of keyRows) { + const rows = keysByUserId.get(keyRow.userId) ?? []; + rows.push(keyRow); + keysByUserId.set(keyRow.userId, rows); + } + + const userResults: SyncUserConfigToKeysResult[] = []; + + for (const userRow of userRows) { + const userKeys = keysByUserId.get(userRow.id) ?? []; + const { configs, summary } = buildSyncedKeyConfigs( + { + dailyQuota: userRow.dailyQuota, + limit5hUsd: userRow.limit5hUsd, + limitWeeklyUsd: userRow.limitWeeklyUsd, + limitMonthlyUsd: userRow.limitMonthlyUsd, + limitTotalUsd: userRow.limitTotalUsd, + limitConcurrentSessions: userRow.limitConcurrentSessions, + providerGroup: userRow.providerGroup, + dailyResetMode: userRow.dailyResetMode, + dailyResetTime: userRow.dailyResetTime, + }, + userKeys.length + ); + + for (const [index, keyRow] of userKeys.entries()) { + const config = configs[index]; + await tx + .update(keysTable) + .set({ + updatedAt: new Date(), + limit5hUsd: config.limit5hUsd === null ? null : config.limit5hUsd.toString(), + limitDailyUsd: config.limitDailyUsd === null ? null : config.limitDailyUsd.toString(), + dailyResetMode: config.dailyResetMode, + dailyResetTime: config.dailyResetTime, + limitWeeklyUsd: + config.limitWeeklyUsd === null ? null : config.limitWeeklyUsd.toString(), + limitMonthlyUsd: + config.limitMonthlyUsd === null ? null : config.limitMonthlyUsd.toString(), + limitTotalUsd: config.limitTotalUsd === null ? null : config.limitTotalUsd.toString(), + limitConcurrentSessions: config.limitConcurrentSessions, + providerGroup: config.providerGroup, + }) + .where(and(eq(keysTable.id, keyRow.id), isNull(keysTable.deletedAt))); + } + + userResults.push({ + updatedUserId: userRow.id, + updatedKeyIds: userKeys.map((keyRow) => keyRow.id), + keyCount: userKeys.length, + summary, + }); + } + + keyStringsForCache = keyRows.map((keyRow) => keyRow.key); + result = { + requestedCount: requestedIds.length, + updatedUserCount: userRows.length, + updatedKeyCount: keyRows.length, + users: userResults, + }; + }); + + await Promise.all([ + ...requestedIds.map((userId) => invalidateCachedUser(userId).catch(() => {})), + ...keyStringsForCache.map((key) => invalidateCachedKey(key).catch(() => {})), + ]); + + const finalResult = result ?? { + requestedCount: requestedIds.length, + updatedUserCount: 0, + updatedKeyCount: 0, + users: [], + }; + + logger.info("Batch synced user config to keys", { + actorUserId: session.user.id, + requestedCount: requestedIds.length, + updatedUserCount: finalResult.updatedUserCount, + updatedKeyCount: finalResult.updatedKeyCount, + }); + + revalidatePath("/dashboard"); + return { + ok: true, + data: finalResult, + }; + } catch (error) { + if (error instanceof BatchUpdateError) { + return { ok: false, error: error.message, errorCode: error.errorCode }; + } + + logger.error("批量同步用户配置到 Key 失败:", error); + const message = error instanceof Error ? error.message : "批量同步用户配置到 Key 失败"; + return { ok: false, error: message, errorCode: ERROR_CODES.UPDATE_FAILED }; + } +} + // 添加用户 export async function addUser(data: { name: string; @@ -1325,13 +1641,32 @@ export async function addUser(data: { // 为新用户创建默认密钥 const generatedKey = `sk-${randomBytes(16).toString("hex")}`; + const defaultKeyConfig = buildFirstSyncedKeyConfig({ + dailyQuota: validatedData.dailyQuota ?? null, + limit5hUsd: validatedData.limit5hUsd ?? null, + limitWeeklyUsd: validatedData.limitWeeklyUsd ?? null, + limitMonthlyUsd: validatedData.limitMonthlyUsd ?? null, + limitTotalUsd: validatedData.limitTotalUsd ?? null, + limitConcurrentSessions: validatedData.limitConcurrentSessions ?? null, + providerGroup, + dailyResetMode: validatedData.dailyResetMode, + dailyResetTime: validatedData.dailyResetTime, + }); const newKey = await createKey({ user_id: newUser.id, name: "default", key: generatedKey, is_enabled: true, expires_at: undefined, - provider_group: providerGroup, + limit_5h_usd: defaultKeyConfig.limit5hUsd, + limit_daily_usd: defaultKeyConfig.limitDailyUsd, + daily_reset_mode: defaultKeyConfig.dailyResetMode, + daily_reset_time: defaultKeyConfig.dailyResetTime, + limit_weekly_usd: defaultKeyConfig.limitWeeklyUsd, + limit_monthly_usd: defaultKeyConfig.limitMonthlyUsd, + limit_total_usd: defaultKeyConfig.limitTotalUsd, + limit_concurrent_sessions: defaultKeyConfig.limitConcurrentSessions, + provider_group: defaultKeyConfig.providerGroup, }); revalidatePath("/dashboard"); @@ -1759,6 +2094,194 @@ export async function editUser( } } +export async function syncUserConfigToKeys( + userId: number, + data: EditableUserData +): Promise> { + try { + const tError = await getTranslations("errors"); + + const session = await getSession(); + if (!session) { + return { + ok: false, + error: tError("UNAUTHORIZED"), + errorCode: ERROR_CODES.UNAUTHORIZED, + }; + } + + if (session.user.role !== "admin") { + return { + ok: false, + error: tError("PERMISSION_DENIED"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const validationResult = UpdateUserSchema.safeParse(data); + if (!validationResult.success) { + const issue = validationResult.error.issues[0]; + const { code, params } = await import("@/lib/utils/error-messages").then((m) => + m.zodErrorToCode(issue.code, { + minimum: "minimum" in issue ? issue.minimum : undefined, + maximum: "maximum" in issue ? issue.maximum : undefined, + type: "expected" in issue ? issue.expected : undefined, + received: "received" in issue ? issue.received : undefined, + validation: "validation" in issue ? issue.validation : undefined, + path: issue.path, + message: "message" in issue ? issue.message : undefined, + params: "params" in issue ? issue.params : undefined, + }) + ); + + let translatedParams = params; + if (issue.code === "custom" && params?.field && typeof params.field === "string") { + try { + translatedParams = { + ...params, + field: tError(params.field as string), + }; + } catch { + // Keep original if translation fails + } + } + + return { + ok: false, + error: formatZodError(validationResult.error), + errorCode: code, + errorParams: translatedParams, + }; + } + + const validatedData = validationResult.data; + const unauthorizedFields = getUnauthorizedFields(validatedData, session.user.role); + if (unauthorizedFields.length > 0) { + return { + ok: false, + error: `${tError("PERMISSION_DENIED")}: ${unauthorizedFields.join(", ")}`, + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + let result: SyncUserConfigToKeysResult | null = null; + let keyStringsForCache: string[] = []; + + await db.transaction(async (tx) => { + const [updatedUser] = await tx + .update(usersTable) + .set(buildUserDbUpdates(validatedData, { forceUpdatedAt: true })) + .where(and(eq(usersTable.id, userId), isNull(usersTable.deletedAt))) + .returning({ + id: usersTable.id, + dailyQuota: usersTable.dailyLimitUsd, + providerGroup: usersTable.providerGroup, + limit5hUsd: usersTable.limit5hUsd, + limitWeeklyUsd: usersTable.limitWeeklyUsd, + limitMonthlyUsd: usersTable.limitMonthlyUsd, + limitTotalUsd: usersTable.limitTotalUsd, + limitConcurrentSessions: usersTable.limitConcurrentSessions, + dailyResetMode: usersTable.dailyResetMode, + dailyResetTime: usersTable.dailyResetTime, + }); + + if (!updatedUser) { + throw new BatchUpdateError(tError("USER_NOT_FOUND"), ERROR_CODES.NOT_FOUND); + } + + const keyRows = await tx + .select({ + id: keysTable.id, + key: keysTable.key, + }) + .from(keysTable) + .where(and(eq(keysTable.userId, userId), isNull(keysTable.deletedAt))) + .orderBy(asc(keysTable.createdAt), asc(keysTable.id)); + + const { configs, summary } = buildSyncedKeyConfigs( + { + dailyQuota: updatedUser.dailyQuota, + limit5hUsd: updatedUser.limit5hUsd, + limitWeeklyUsd: updatedUser.limitWeeklyUsd, + limitMonthlyUsd: updatedUser.limitMonthlyUsd, + limitTotalUsd: updatedUser.limitTotalUsd, + limitConcurrentSessions: updatedUser.limitConcurrentSessions, + providerGroup: updatedUser.providerGroup, + dailyResetMode: updatedUser.dailyResetMode, + dailyResetTime: updatedUser.dailyResetTime, + }, + keyRows.length + ); + + for (const [index, keyRow] of keyRows.entries()) { + const config = configs[index]; + await tx + .update(keysTable) + .set({ + updatedAt: new Date(), + limit5hUsd: config.limit5hUsd === null ? null : config.limit5hUsd.toString(), + limitDailyUsd: config.limitDailyUsd === null ? null : config.limitDailyUsd.toString(), + dailyResetMode: config.dailyResetMode, + dailyResetTime: config.dailyResetTime, + limitWeeklyUsd: + config.limitWeeklyUsd === null ? null : config.limitWeeklyUsd.toString(), + limitMonthlyUsd: + config.limitMonthlyUsd === null ? null : config.limitMonthlyUsd.toString(), + limitTotalUsd: config.limitTotalUsd === null ? null : config.limitTotalUsd.toString(), + limitConcurrentSessions: config.limitConcurrentSessions, + providerGroup: config.providerGroup, + }) + .where(and(eq(keysTable.id, keyRow.id), isNull(keysTable.deletedAt))); + } + + keyStringsForCache = keyRows.map((keyRow) => keyRow.key); + result = { + updatedUserId: updatedUser.id, + updatedKeyIds: keyRows.map((keyRow) => keyRow.id), + keyCount: keyRows.length, + summary, + }; + }); + + await Promise.all([ + invalidateCachedUser(userId).catch(() => {}), + ...keyStringsForCache.map((key) => invalidateCachedKey(key).catch(() => {})), + ]); + + const finalResult = result ?? { + updatedUserId: userId, + updatedKeyIds: [], + keyCount: 0, + summary: buildSyncedKeyConfigs({}, 0).summary, + }; + + logger.info("Synced user config to keys", { + actorUserId: session.user.id, + userId, + keyCount: finalResult.keyCount, + }); + + revalidatePath("/dashboard"); + return { + ok: true, + data: finalResult, + }; + } catch (error) { + if (error instanceof BatchUpdateError) { + return { ok: false, error: error.message, errorCode: error.errorCode }; + } + + logger.error("Failed to sync user config to keys:", error); + const tError = await getTranslations("errors"); + const message = error instanceof Error ? error.message : tError("UPDATE_USER_FAILED"); + return { + ok: false, + error: message, + errorCode: ERROR_CODES.UPDATE_FAILED, + }; + } +} + // 删除用户 // Ledger rows intentionally survive user deletion (billing audit trail) export async function removeUser(userId: number): Promise { diff --git a/src/app/[locale]/dashboard/_components/dashboard-header.tsx b/src/app/[locale]/dashboard/_components/dashboard-header.tsx index 55d13cbed..b35d9bb74 100644 --- a/src/app/[locale]/dashboard/_components/dashboard-header.tsx +++ b/src/app/[locale]/dashboard/_components/dashboard-header.tsx @@ -22,6 +22,7 @@ export function DashboardHeader({ session }: DashboardHeaderProps) { { href: "/dashboard/logs", label: t("usageLogs") }, { href: "/dashboard/leaderboard", label: t("leaderboard") }, { href: "/dashboard/availability", label: t("availability"), adminOnly: true }, + { href: "/status", label: t("systemStatus") }, { href: "/dashboard/providers", label: t("providers"), adminOnly: true }, ...(isAdmin ? [{ href: "/dashboard/quotas", label: t("quotasManagement") }] diff --git a/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx b/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx index d466a2dde..6956e00d8 100644 --- a/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx @@ -6,7 +6,11 @@ import { useTranslations } from "next-intl"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { type BatchUpdateKeysParams, batchUpdateKeys } from "@/actions/keys"; -import { type BatchUpdateUsersParams, batchUpdateUsers } from "@/actions/users"; +import { + type BatchUpdateUsersParams, + batchSyncUserConfigToKeys, + batchUpdateUsers, +} from "@/actions/users"; import { AlertDialog, AlertDialogAction, @@ -51,6 +55,7 @@ type UserFieldLabels = { limitDaily: string; limitWeekly: string; limitMonthly: string; + syncKeys: string; }; type KeyFieldLabels = { @@ -78,6 +83,7 @@ const INITIAL_USER_STATE: BatchUserSectionState = { limitWeeklyUsd: "", limitMonthlyUsdEnabled: false, limitMonthlyUsd: "", + syncKeysEnabled: false, }; const INITIAL_KEY_STATE: BatchKeySectionState = { @@ -203,6 +209,7 @@ type PendingBatchUpdate = { keyUpdates?: BatchUpdateKeysParams["updates"]; enabledUserFields: string[]; enabledKeyFields: string[]; + syncUsersToKeys: boolean; }; function BatchEditDialogInner({ @@ -242,6 +249,7 @@ function BatchEditDialogInner({ limitDaily: t("user.fields.limitDaily"), limitWeekly: t("user.fields.limitWeekly"), limitMonthly: t("user.fields.limitMonthly"), + syncKeys: t("user.fields.syncKeys"), }), [t] ); @@ -291,8 +299,9 @@ function BatchEditDialogInner({ const willUpdateUsers = selectedUsersCount > 0 && enabledUserFields.length > 0; const willUpdateKeys = selectedKeysCount > 0 && enabledKeyFields.length > 0; + const willSyncUsersToKeys = selectedUsersCount > 0 && userState.syncKeysEnabled; - if (!willUpdateUsers && !willUpdateKeys) { + if (!willUpdateUsers && !willUpdateKeys && !willSyncUsersToKeys) { toast.error(t("dialog.noFieldEnabled")); return; } @@ -304,6 +313,7 @@ function BatchEditDialogInner({ keyUpdates: willUpdateKeys ? keyUpdates : undefined, enabledUserFields, enabledKeyFields, + syncUsersToKeys: willSyncUsersToKeys, }); setConfirmOpen(true); } catch (error) { @@ -317,47 +327,73 @@ function BatchEditDialogInner({ setIsSubmitting(true); try { - const tasks: Array> = []; - - if (pendingUpdate.userUpdates && pendingUpdate.userIds.length > 0) { - tasks.push( - batchUpdateUsers({ userIds: pendingUpdate.userIds, updates: pendingUpdate.userUpdates }) - .then((result) => ({ kind: "users" as const, result })) - .catch((error) => ({ kind: "users" as const, result: { ok: false, error } })) - ); + const results: Array<{ kind: "users" | "keys" | "sync"; result: any }> = []; + + if (pendingUpdate.syncUsersToKeys && pendingUpdate.userIds.length > 0) { + try { + const result = await batchSyncUserConfigToKeys({ + userIds: pendingUpdate.userIds, + updates: pendingUpdate.userUpdates, + }); + results.push({ kind: "sync", result }); + } catch (error) { + results.push({ kind: "sync", result: { ok: false, error } }); + } + } else if (pendingUpdate.userUpdates && pendingUpdate.userIds.length > 0) { + try { + const result = await batchUpdateUsers({ + userIds: pendingUpdate.userIds, + updates: pendingUpdate.userUpdates, + }); + results.push({ kind: "users", result }); + } catch (error) { + results.push({ kind: "users", result: { ok: false, error } }); + } } if (pendingUpdate.keyUpdates && pendingUpdate.keyIds.length > 0) { - tasks.push( - batchUpdateKeys({ keyIds: pendingUpdate.keyIds, updates: pendingUpdate.keyUpdates }) - .then((result) => ({ kind: "keys" as const, result })) - .catch((error) => ({ kind: "keys" as const, result: { ok: false, error } })) - ); + try { + const result = await batchUpdateKeys({ + keyIds: pendingUpdate.keyIds, + updates: pendingUpdate.keyUpdates, + }); + results.push({ kind: "keys", result }); + } catch (error) { + results.push({ kind: "keys", result: { ok: false, error } }); + } } - if (tasks.length === 0) { + if (results.length === 0) { toast.error(t("dialog.noUpdate")); return; } - const results = await Promise.all(tasks); let anySuccess = false; let anyFailed = false; for (const { kind, result } of results) { if (result?.ok) { anySuccess = true; - const updatedCount = - typeof result.data?.updatedCount === "number" - ? result.data.updatedCount - : kind === "users" - ? pendingUpdate.userIds.length - : pendingUpdate.keyIds.length; - toast.success( - kind === "users" - ? t("toast.usersUpdated", { count: updatedCount }) - : t("toast.keysUpdated", { count: updatedCount }) - ); + if (kind === "sync") { + toast.success( + t("toast.keysSynced", { + users: result.data?.updatedUserCount ?? pendingUpdate.userIds.length, + keys: result.data?.updatedKeyCount ?? 0, + }) + ); + } else { + const updatedCount = + typeof result.data?.updatedCount === "number" + ? result.data.updatedCount + : kind === "users" + ? pendingUpdate.userIds.length + : pendingUpdate.keyIds.length; + toast.success( + kind === "users" + ? t("toast.usersUpdated", { count: updatedCount }) + : t("toast.keysUpdated", { count: updatedCount }) + ); + } } else { anyFailed = true; const errorMessage = @@ -366,11 +402,15 @@ function BatchEditDialogInner({ : result?.error instanceof Error ? result.error.message : t("toast.batchFailed"); - toast.error( - kind === "users" - ? t("toast.usersFailed", { error: errorMessage }) - : t("toast.keysFailed", { error: errorMessage }) - ); + if (kind === "sync") { + toast.error(t("toast.syncFailed", { error: errorMessage })); + } else { + toast.error( + kind === "users" + ? t("toast.usersFailed", { error: errorMessage }) + : t("toast.keysFailed", { error: errorMessage }) + ); + } } } @@ -398,7 +438,8 @@ function BatchEditDialogInner({ if (!pendingUpdate) return null; const willUpdateUsers = Boolean(pendingUpdate.userUpdates && pendingUpdate.userIds.length > 0); const willUpdateKeys = Boolean(pendingUpdate.keyUpdates && pendingUpdate.keyIds.length > 0); - const usersCount = willUpdateUsers ? pendingUpdate.userIds.length : 0; + const willSyncUsersToKeys = pendingUpdate.syncUsersToKeys; + const usersCount = willUpdateUsers || willSyncUsersToKeys ? pendingUpdate.userIds.length : 0; const keysCount = willUpdateKeys ? pendingUpdate.keyIds.length : 0; return ( @@ -414,6 +455,14 @@ function BatchEditDialogInner({ ) : null} + {willSyncUsersToKeys ? ( +
+
{t("confirm.syncKeys")}
+
+ {t("confirm.syncKeysDescription", { users: pendingUpdate.userIds.length })} +
+
+ ) : null} {willUpdateKeys ? (
{t("confirm.keyFields")}
@@ -458,6 +507,7 @@ function BatchEditDialogInner({ emptyToClear: t("user.placeholders.emptyToClear"), tagsPlaceholder: t("user.placeholders.tagsPlaceholder"), emptyNoLimit: t("user.placeholders.emptyNoLimit"), + syncKeysDescription: t("user.placeholders.syncKeysDescription"), }, }} /> diff --git a/src/app/[locale]/dashboard/_components/user/batch-edit/batch-user-section.tsx b/src/app/[locale]/dashboard/_components/user/batch-edit/batch-user-section.tsx index 5a3d5acbf..a791f9bac 100644 --- a/src/app/[locale]/dashboard/_components/user/batch-edit/batch-user-section.tsx +++ b/src/app/[locale]/dashboard/_components/user/batch-edit/batch-user-section.tsx @@ -20,6 +20,7 @@ export interface BatchUserSectionState { limitWeeklyUsd: string; limitMonthlyUsdEnabled: boolean; limitMonthlyUsd: string; + syncKeysEnabled: boolean; } export interface BatchUserSectionProps { @@ -38,11 +39,13 @@ export interface BatchUserSectionProps { limitDaily: string; limitWeekly: string; limitMonthly: string; + syncKeys: string; }; placeholders: { emptyToClear: string; tagsPlaceholder: string; emptyNoLimit: string; + syncKeysDescription: string; }; }; } @@ -171,6 +174,17 @@ export function BatchUserSection({ placeholder={translations.placeholders.emptyNoLimit} /> + + onChange({ syncKeysEnabled: enabled })} + enableFieldAria={translations.enableFieldAria} + > +

+ {translations.placeholders.syncKeysDescription} +

+
); diff --git a/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx index 256925de3..7c1f7c960 100644 --- a/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx @@ -23,6 +23,7 @@ import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { useZodForm } from "@/lib/hooks/use-zod-form"; +import { buildFirstSyncedKeyConfig } from "@/lib/users/user-key-sync"; import { KeyFormSchema, UpdateUserSchema } from "@/lib/validation/schemas"; import { KeyEditSection } from "./forms/key-edit-section"; import { UserEditSection } from "./forms/user-edit-section"; @@ -171,6 +172,17 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp } const newUserId = userRes.data.user.id; + const firstKeyConfig = buildFirstSyncedKeyConfig({ + dailyQuota: data.user.dailyQuota ?? null, + limit5hUsd: data.user.limit5hUsd ?? null, + limitWeeklyUsd: data.user.limitWeeklyUsd ?? null, + limitMonthlyUsd: data.user.limitMonthlyUsd ?? null, + limitTotalUsd: data.user.limitTotalUsd ?? null, + limitConcurrentSessions: data.user.limitConcurrentSessions ?? null, + providerGroup: data.user.providerGroup ?? PROVIDER_GROUP.DEFAULT, + dailyResetMode: data.user.dailyResetMode, + dailyResetTime: data.user.dailyResetTime, + }); // Create the first key const keyRes = await addKey({ @@ -179,17 +191,17 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp // 重要:清除到期时间时用空字符串表达,避免 undefined 在 Server Action 序列化时被丢弃 expiresAt: data.key.expiresAt ?? "", canLoginWebUi: data.key.canLoginWebUi, - providerGroup: normalizeProviderGroup(data.key.providerGroup), + providerGroup: firstKeyConfig.providerGroup, cacheTtlPreference: data.key.cacheTtlPreference, - limit5hUsd: data.key.limit5hUsd, - limit5hResetMode: data.key.limit5hResetMode, - limitDailyUsd: data.key.limitDailyUsd, - dailyResetMode: data.key.dailyResetMode, - dailyResetTime: data.key.dailyResetTime, - limitWeeklyUsd: data.key.limitWeeklyUsd, - limitMonthlyUsd: data.key.limitMonthlyUsd, - limitTotalUsd: data.key.limitTotalUsd, - limitConcurrentSessions: data.key.limitConcurrentSessions, + limit5hUsd: firstKeyConfig.limit5hUsd, + limit5hResetMode: data.user.limit5hResetMode ?? data.key.limit5hResetMode, + limitDailyUsd: firstKeyConfig.limitDailyUsd, + dailyResetMode: firstKeyConfig.dailyResetMode, + dailyResetTime: firstKeyConfig.dailyResetTime, + limitWeeklyUsd: firstKeyConfig.limitWeeklyUsd, + limitMonthlyUsd: firstKeyConfig.limitMonthlyUsd, + limitTotalUsd: firstKeyConfig.limitTotalUsd, + limitConcurrentSessions: firstKeyConfig.limitConcurrentSessions, }); if (!keyRes.ok) { @@ -410,7 +422,9 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp }} isAdmin={true} showLimitRules={false} - showExpireTime={false} + showExpireTime={true} + showProviderGroup={false} + showEnableStatus={false} onChange={handleKeyChange} translations={keyEditTranslations} /> diff --git a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx index 90e36ca2c..7b4bca8ab 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx @@ -12,6 +12,7 @@ import { removeUser, resetUserAllStatistics, resetUserLimitsOnly, + syncUserConfigToKeys, toggleUserEnabled, } from "@/actions/users"; import { @@ -100,6 +101,7 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr const [reset5hDialogOpen, setReset5hDialogOpen] = useState(false); const [isResettingLimits, setIsResettingLimits] = useState(false); const [resetLimitsDialogOpen, setResetLimitsDialogOpen] = useState(false); + const [isSyncingKeys, setIsSyncingKeys] = useState(false); // Always show providerGroup field in edit mode const userEditTranslations = useUserTranslations({ showProviderGroup: true }); @@ -303,6 +305,49 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr const canReset5h = (user.limit5hUsd ?? null) !== null && (user.limit5hUsd ?? 0) > 0; const reset5hMode = user.limit5hResetMode ?? "rolling"; + const handleSyncKeys = async () => { + setIsSyncingKeys(true); + try { + const data = form.values || defaultValues; + const res = await syncUserConfigToKeys(user.id, { + name: data.name, + note: data.note, + tags: data.tags, + expiresAt: data.expiresAt ?? null, + providerGroup: normalizeProviderGroup(data.providerGroup), + rpm: data.rpm, + limit5hUsd: data.limit5hUsd, + dailyQuota: data.dailyQuota, + limitWeeklyUsd: data.limitWeeklyUsd, + limitMonthlyUsd: data.limitMonthlyUsd, + limitTotalUsd: data.limitTotalUsd, + limitConcurrentSessions: data.limitConcurrentSessions, + dailyResetMode: data.dailyResetMode, + dailyResetTime: data.dailyResetTime, + allowedClients: data.allowedClients, + blockedClients: data.blockedClients, + allowedModels: data.allowedModels, + }); + + if (!res.ok) { + toast.error(res.error || t("editDialog.syncKeys.error")); + return; + } + + toast.success(t("editDialog.syncKeys.success", { count: res.data.keyCount })); + onSuccess?.(); + queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["userKeyGroups"] }); + queryClient.invalidateQueries({ queryKey: ["userTags"] }); + router.refresh(); + } catch (error) { + console.error("[EditUserDialog] sync keys failed", error); + toast.error(t("editDialog.syncKeys.error")); + } finally { + setIsSyncingKeys(false); + } + }; + return (
@@ -544,15 +589,24 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr {errorMessage &&
{errorMessage}
} + - diff --git a/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx b/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx index beaed6e92..5216a3d4c 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx @@ -54,6 +54,10 @@ export interface KeyEditSectionProps { showLimitRules?: boolean; /** 是否显示到期时间区域,默认为 true */ showExpireTime?: boolean; + /** 是否显示供应商分组,默认为 true */ + showProviderGroup?: boolean; + /** 是否显示启用状态,默认为 true */ + showEnableStatus?: boolean; onChange: { (field: string, value: any): void; (batch: Record): void; @@ -131,6 +135,8 @@ export function KeyEditSection({ userProviderGroup, showLimitRules = true, showExpireTime = true, + showProviderGroup = true, + showEnableStatus = true, onChange, scrollRef, translations, @@ -338,36 +344,38 @@ export function KeyEditSection({ value={keyData.name} onChange={(val) => onChange("name", val)} /> -
-
- -

- {translations.fields.enableStatus?.description || "Disabled keys cannot be used"} -

+ {showEnableStatus && ( +
+
+ +

+ {translations.fields.enableStatus?.description || "Disabled keys cannot be used"} +

+
+ + +
+ onChange("isEnabled", checked)} + /> +
+
+ {isLastEnabledKey && ( + +

+ {translations.fields.enableStatus?.cannotDisableTooltip || + "Cannot disable the last enabled key"} +

+
+ )} +
- - -
- onChange("isEnabled", checked)} - /> -
-
- {isLastEnabledKey && ( - -

- {translations.fields.enableStatus?.cannotDisableTooltip || - "Cannot disable the last enabled key"} -

-
- )} -
-
+ )} {/* 到期时间区域 - 仅在 showExpireTime 为 true 时显示 */} @@ -458,7 +466,7 @@ export function KeyEditSection({ />
- {isAdmin ? ( + {showProviderGroup && isAdmin ? ( onChange("providerGroup", val)} @@ -468,7 +476,7 @@ export function KeyEditSection({ placeholder: translations.fields.providerGroup.placeholder, }} /> - ) : userGroups.length > 0 ? ( + ) : showProviderGroup && userGroups.length > 0 ? (
{keyData.id > 0 ? ( // 编辑模式:只读显示 @@ -508,7 +516,7 @@ export function KeyEditSection({ /> )}
- ) : keyGroupOptions.length > 0 ? ( + ) : showProviderGroup && keyGroupOptions.length > 0 ? (
@@ -527,11 +535,11 @@ export function KeyEditSection({ {translations.fields.providerGroup.editHint || "已有密钥的分组不可修改"}

- ) : ( + ) : showProviderGroup ? (
{translations.fields.providerGroup.noGroupHint || "您没有分组限制,可以访问所有供应商"}
- )} + ) : null}
diff --git a/src/app/[locale]/dashboard/_components/user/temporary-key-batch-dialog.tsx b/src/app/[locale]/dashboard/_components/user/temporary-key-batch-dialog.tsx new file mode 100644 index 000000000..ad8b60d89 --- /dev/null +++ b/src/app/[locale]/dashboard/_components/user/temporary-key-batch-dialog.tsx @@ -0,0 +1,317 @@ +"use client"; + +import { Download, KeyRound, Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useMemo, useState, useTransition } from "react"; +import { toast } from "sonner"; +import { createTemporaryKeysBatch, type CreateTemporaryKeysBatchResult } from "@/actions/keys"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { normalizeProviderGroup } from "@/lib/utils/provider-group"; +import type { UserDisplay } from "@/types/user"; + +const QUICK_COUNTS = [5, 10, 20, 50, 100] as const; + +function sanitizeFilenameFragment(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return "temporary-keys"; + return trimmed.replace(/[\\/:*?"<>|,,]+/g, "-").replace(/\s+/g, "-"); +} + +function downloadTextFile(filename: string, content: string) { + const blob = new Blob([content], { type: "text/plain;charset=utf-8;" }); + const url = window.URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + window.URL.revokeObjectURL(url); +} + +function buildKeyTextContent(result: CreateTemporaryKeysBatchResult): string { + return result.keys.map((item) => item.key).join("\n"); +} + +export interface TemporaryKeyBatchDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + user: UserDisplay | null; + onSuccess?: () => void; +} + +export function TemporaryKeyBatchDialog({ + open, + onOpenChange, + user, + onSuccess, +}: TemporaryKeyBatchDialogProps) { + const t = useTranslations("dashboard.userManagement.temporaryKeys"); + const tCommon = useTranslations("common"); + const [isPending, startTransition] = useTransition(); + const [baseKeyId, setBaseKeyId] = useState(""); + const [count, setCount] = useState("5"); + const [customLimitTotalUsd, setCustomLimitTotalUsd] = useState(""); + const [result, setResult] = useState(null); + + const availableKeys = useMemo( + () => user?.keys.filter((key) => !key.temporaryGroupName?.trim()) ?? [], + [user] + ); + + useEffect(() => { + if (!open) return; + setBaseKeyId(availableKeys[0] ? String(availableKeys[0].id) : ""); + setCount("5"); + setCustomLimitTotalUsd(""); + setResult(null); + }, [open, availableKeys]); + + const userTemporaryGroup = useMemo( + () => normalizeProviderGroup(user?.providerGroup || "default"), + [user?.providerGroup] + ); + + const previewText = useMemo(() => { + if (!result) return ""; + return result.keys + .slice(0, 5) + .map((item) => `${item.name}\n${item.key}`) + .join("\n\n"); + }, [result]); + + const handleClose = (nextOpen: boolean) => { + if (isPending) return; + if (!nextOpen) { + setResult(null); + } + onOpenChange(nextOpen); + }; + + const handleDownload = () => { + if (!result) return; + const filename = `${sanitizeFilenameFragment(result.groupName)}-temporary-keys.txt`; + downloadTextFile(filename, buildKeyTextContent(result)); + }; + + const handleSubmit = () => { + if (!user) return; + + startTransition(async () => { + const parsedBaseKeyId = Number(baseKeyId); + const parsedCount = Number(count); + const parsedCustomLimit = + customLimitTotalUsd.trim() === "" ? undefined : Number(customLimitTotalUsd); + + if (!Number.isInteger(parsedBaseKeyId) || parsedBaseKeyId <= 0) { + toast.error(t("createDialog.baseKeyRequired")); + return; + } + + if (!Number.isFinite(parsedCount) || parsedCount <= 0) { + toast.error(t("createDialog.invalidCount")); + return; + } + + if ( + parsedCustomLimit !== undefined && + (!Number.isFinite(parsedCustomLimit) || parsedCustomLimit < 0) + ) { + toast.error(t("createDialog.invalidLimit")); + return; + } + + const response = await createTemporaryKeysBatch({ + userId: user.id, + baseKeyId: parsedBaseKeyId, + count: parsedCount, + customLimitTotalUsd: parsedCustomLimit, + }); + + if (!response.ok) { + toast.error( + t("toasts.createFailed", { + error: response.error || t("createDialog.genericError"), + }) + ); + return; + } + + setResult(response.data); + onSuccess?.(); + toast.success(t("toasts.createSuccess", { count: response.data.createdCount })); + }); + }; + + return ( + + + {result ? ( + <> + + {t("success.title")} + + {t("success.description", { + group: result.groupName, + count: result.createdCount, + })} + + + +
+
+
+ + +
+
+ + +
+
+ +
+ +