diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f4c2b3fdb..59694b704 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -210,7 +210,7 @@ jobs: fi - name: Create summary - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository uses: actions/github-script@v7 with: script: | diff --git a/.gitignore b/.gitignore index e4798deb0..7618e5b53 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,4 @@ tmp/ .trae/ .sisyphus .ace-tool/ +.worktrees/ diff --git a/Dockerfile b/Dockerfile index 02d915c1a..8576fbc54 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,19 +10,19 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 ENV CI=true -RUN bun run build +RUN --mount=type=cache,target=/app/.next/cache bun run build FROM node:20-slim AS runner WORKDIR /app ENV NODE_ENV=production -ENV PORT=8080 -EXPOSE 8080 +ENV PORT=3000 +EXPOSE 3000 # 关键:确保复制了所有必要的文件,特别是 drizzle 文件夹 COPY --from=builder /app/public ./public -COPY --from=builder /app/.next ./.next -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/package.json ./package.json -COPY --from=builder /app/drizzle ./drizzle +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/drizzle ./drizzle +COPY --from=builder /app/VERSION ./VERSION -CMD ["node", "node_modules/.bin/next", "start"] +CMD ["node", "server.js"] diff --git a/docker-compose.yaml b/docker-compose.yaml index ba4e98241..743440556 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -68,7 +68,13 @@ services: - "${APP_PORT:-23000}:3000" restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:3000/api/actions/health || exit 1"] + test: + [ + "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))", + ] interval: 30s timeout: 5s retries: 3 diff --git a/drizzle/0062_aromatic_taskmaster.sql b/drizzle/0062_aromatic_taskmaster.sql index dc6dd60b5..57861fa78 100644 --- a/drizzle/0062_aromatic_taskmaster.sql +++ b/drizzle/0062_aromatic_taskmaster.sql @@ -1 +1 @@ -ALTER TABLE "providers" ADD COLUMN "gemini_google_search_preference" varchar(20); \ No newline at end of file +ALTER TABLE "providers" ADD COLUMN IF NOT EXISTS "gemini_google_search_preference" varchar(20); diff --git a/drizzle/0064_harsh_dragon_lord.sql b/drizzle/0064_harsh_dragon_lord.sql new file mode 100644 index 000000000..c64402bc9 --- /dev/null +++ b/drizzle/0064_harsh_dragon_lord.sql @@ -0,0 +1 @@ +ALTER TABLE "providers" ADD COLUMN IF NOT EXISTS "group_priorities" jsonb DEFAULT 'null'::jsonb; diff --git a/drizzle/meta/0064_snapshot.json b/drizzle/meta/0064_snapshot.json new file mode 100644 index 000000000..19936a489 --- /dev/null +++ b/drizzle/meta/0064_snapshot.json @@ -0,0 +1,2975 @@ +{ + "id": "9fd69a68-7794-42af-ac5f-83f874adeecf", + "prevId": "40d9ed20-d9e3-42a4-9357-3e17e4b06ba1", + "version": "7", + "dialect": "postgresql", + "tables": { + "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_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 + }, + "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 + }, + "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_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": {} + } + }, + "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 + }, + "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 + }, + "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 + }, + "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 + }, + "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_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_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_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": {} + } + }, + "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 + }, + "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, + "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_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_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(50)", + "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 + }, + "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" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": 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_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 + }, + "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 + }, + "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 + }, + "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_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": {} + } + }, + "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 + }, + "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": {} + } + }, + "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'" + }, + "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 + }, + "enable_http2": { + "name": "enable_http2", + "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_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 + }, + "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.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_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 + }, + "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" + }, + "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_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" + ] + }, + "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": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 7d9a152eb..26c227cc5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -449,6 +449,13 @@ "when": 1770476679142, "tag": "0063_slippery_sharon_carter", "breakpoints": true + }, + { + "idx": 64, + "version": "7", + "when": 1770598056381, + "tag": "0064_harsh_dragon_lord", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/messages/en/settings/providers/filter.json b/messages/en/settings/providers/filter.json index 1f1513993..7512e5141 100644 --- a/messages/en/settings/providers/filter.json +++ b/messages/en/settings/providers/filter.json @@ -9,5 +9,8 @@ "active": "Active", "all": "Any status", "inactive": "Inactive" - } + }, + "mobileFilter": "Filter", + "mobileFilterCount": "Filter ({count})", + "resetFilters": "Reset Filters" } diff --git a/messages/en/settings/providers/form/sections.json b/messages/en/settings/providers/form/sections.json index 2c078d8ff..30c54a09d 100644 --- a/messages/en/settings/providers/form/sections.json +++ b/messages/en/settings/providers/form/sections.json @@ -296,6 +296,12 @@ "label": "Provider Group", "placeholder": "e.g. premium, economy" }, + "groupPriorities": { + "label": "Per-Group Priority", + "desc": "Override global priority for specific groups. Leave empty to use the global priority above.", + "placeholder": "Use global priority", + "noGroups": "Set a group tag first to configure per-group priorities" + }, "priority": { "desc": "Lower value = higher priority (0 is highest). The system only chooses from the highest priority tier. Suggested: primary=0, standby=1, emergency=2", "label": "Priority", diff --git a/messages/en/settings/providers/inlineEdit.json b/messages/en/settings/providers/inlineEdit.json index 69669e2cf..203470ea5 100644 --- a/messages/en/settings/providers/inlineEdit.json +++ b/messages/en/settings/providers/inlineEdit.json @@ -1,12 +1,27 @@ { + "addGroup": "Add group", "cancel": "Cancel", "costMultiplierInvalid": "Please enter a non-negative number", "costMultiplierLabel": "Cost Multiplier", + "createGroup": "Create \"{name}\"", + "editGroups": "Edit Groups", + "globalPriority": "Global Priority", + "groupPriorityLabel": "Per-Group Priority", + "groupPriorityPlaceholder": "Use global", + "groupSaveError": "Failed to save group changes", + "groupValidation": { + "empty": "Group name cannot be empty", + "noComma": "Group name cannot contain comma", + "tooLong": "Group name cannot exceed 50 characters" + }, + "noGroupsAvailable": "No groups available", "priorityInvalid": "Please enter an integer >= 0", "priorityLabel": "Priority", "save": "Save", + "saving": "Saving...", "saveFailed": "Save failed", "saveSuccess": "Saved successfully", + "searchGroups": "Search groups...", "weightInvalid": "Please enter an integer between 1 and 100", "weightLabel": "Weight" } diff --git a/messages/en/settings/providers/list.json b/messages/en/settings/providers/list.json index 6708c1d1c..99b69c9fc 100644 --- a/messages/en/settings/providers/list.json +++ b/messages/en/settings/providers/list.json @@ -33,5 +33,11 @@ "unknownError": "Unknown error", "viewFullKey": "View Complete API Key", "viewFullKeyDesc": "Please keep it safe and don't share it with others", - "weight": "Weight" + "weight": "Weight", + "actions": "Actions", + "actionClone": "Clone", + "actionResetCircuit": "Reset Circuit", + "actionResetUsage": "Reset Usage", + "actionDelete": "Delete", + "selectProvider": "Select {name}" } diff --git a/messages/ja/settings/providers/filter.json b/messages/ja/settings/providers/filter.json index 0e99db282..119f91aac 100644 --- a/messages/ja/settings/providers/filter.json +++ b/messages/ja/settings/providers/filter.json @@ -9,5 +9,8 @@ "active": "有効", "all": "すべてのステータス", "inactive": "無効" - } + }, + "mobileFilter": "フィルター", + "mobileFilterCount": "フィルター ({count})", + "resetFilters": "フィルターをリセット" } diff --git a/messages/ja/settings/providers/form/sections.json b/messages/ja/settings/providers/form/sections.json index fbe118713..41d264fce 100644 --- a/messages/ja/settings/providers/form/sections.json +++ b/messages/ja/settings/providers/form/sections.json @@ -297,6 +297,12 @@ "label": "プロバイダーグループ", "placeholder": "例: premium, economy" }, + "groupPriorities": { + "label": "グループ別優先度", + "desc": "特定のグループに個別の優先度を設定します。空欄の場合は上記のグローバル優先度を使用します。", + "placeholder": "グローバル優先度を使用", + "noGroups": "グループ別優先度を設定するには、先にグループタグを設定してください" + }, "priority": { "desc": "値が小さいほど優先度が高くなります(0 が最も高い)。システムは最も高い優先度のプロバイダーのみから選択します。推奨: メイン=0、予備=1、緊急=2", "label": "優先度", diff --git a/messages/ja/settings/providers/inlineEdit.json b/messages/ja/settings/providers/inlineEdit.json index 6e835c01b..980f77035 100644 --- a/messages/ja/settings/providers/inlineEdit.json +++ b/messages/ja/settings/providers/inlineEdit.json @@ -1,12 +1,27 @@ { + "addGroup": "グループを追加", "cancel": "キャンセル", "costMultiplierInvalid": "0以上の数値を入力してください", "costMultiplierLabel": "コスト倍率", + "createGroup": "\"{name}\" を作成", + "editGroups": "グループを編集", + "globalPriority": "グローバル優先度", + "groupPriorityLabel": "グループ別優先度", + "groupPriorityPlaceholder": "グローバル値を使用", + "groupSaveError": "グループの保存に失敗しました", + "groupValidation": { + "empty": "グループ名を空にすることはできません", + "noComma": "グループ名にカンマを含めることはできません", + "tooLong": "グループ名は50文字以内にしてください" + }, + "noGroupsAvailable": "利用可能なグループがありません", "priorityInvalid": "0 以上の整数を入力してください", "priorityLabel": "優先度", "save": "保存する", + "saving": "保存中...", "saveFailed": "保存に失敗しました", "saveSuccess": "保存に成功しました", + "searchGroups": "グループを検索...", "weightInvalid": "1〜100 の整数を入力してください", "weightLabel": "重み" } diff --git a/messages/ja/settings/providers/list.json b/messages/ja/settings/providers/list.json index 32793f63b..250012f9b 100644 --- a/messages/ja/settings/providers/list.json +++ b/messages/ja/settings/providers/list.json @@ -33,5 +33,11 @@ "unknownError": "不明なエラー", "viewFullKey": "完全な API キーを表示", "viewFullKeyDesc": "安全に保管し、他人と共有しないでください", - "weight": "重み" + "weight": "重み", + "actions": "アクション", + "actionClone": "クローン", + "actionResetCircuit": "サーキットリセット", + "actionResetUsage": "使用量リセット", + "actionDelete": "削除", + "selectProvider": "{name} を選択" } diff --git a/messages/ru/settings/providers/filter.json b/messages/ru/settings/providers/filter.json index 075abdc72..adecbee1b 100644 --- a/messages/ru/settings/providers/filter.json +++ b/messages/ru/settings/providers/filter.json @@ -9,5 +9,8 @@ "active": "Активные", "all": "Все статусы", "inactive": "Неактивные" - } + }, + "mobileFilter": "Фильтр", + "mobileFilterCount": "Фильтр ({count})", + "resetFilters": "Сбросить фильтры" } diff --git a/messages/ru/settings/providers/form/sections.json b/messages/ru/settings/providers/form/sections.json index 533d85a69..dcb6b089e 100644 --- a/messages/ru/settings/providers/form/sections.json +++ b/messages/ru/settings/providers/form/sections.json @@ -297,6 +297,12 @@ "label": "Группа провайдера", "placeholder": "напр. premium, economy" }, + "groupPriorities": { + "label": "Приоритет по группам", + "desc": "Переопределение глобального приоритета для определённых групп. Оставьте пустым для использования глобального приоритета выше.", + "placeholder": "Использовать глобальный приоритет", + "noGroups": "Сначала задайте тег группы для настройки приоритетов по группам" + }, "priority": { "desc": "Меньше — выше приоритет (0 — наивысший). Система выбирает только из провайдеров с максимальным приоритетом. Рекомендации: основной=0, резерв=1, аварийный=2", "label": "Приоритет", diff --git a/messages/ru/settings/providers/inlineEdit.json b/messages/ru/settings/providers/inlineEdit.json index 2e614a3d9..563025d6e 100644 --- a/messages/ru/settings/providers/inlineEdit.json +++ b/messages/ru/settings/providers/inlineEdit.json @@ -1,12 +1,27 @@ { + "addGroup": "Добавить группу", "cancel": "Отмена", "costMultiplierInvalid": "Введите число не меньше 0", "costMultiplierLabel": "Коэф цены", + "createGroup": "Создать \"{name}\"", + "editGroups": "Редактировать группы", + "globalPriority": "Глобальный приоритет", + "groupPriorityLabel": "Приоритет по группам", + "groupPriorityPlaceholder": "Глобальное значение", + "groupSaveError": "Не удалось сохранить изменения группы", + "groupValidation": { + "empty": "Название группы не может быть пустым", + "noComma": "Название группы не может содержать запятую", + "tooLong": "Название группы не может превышать 50 символов" + }, + "noGroupsAvailable": "Нет доступных групп", "priorityInvalid": "Введите целое число >= 0", "priorityLabel": "Приоритет", "save": "Сохранить", + "saving": "Сохранение...", "saveFailed": "Не удалось сохранить", "saveSuccess": "Успешно сохранено", + "searchGroups": "Поиск групп...", "weightInvalid": "Введите целое число от 1 до 100", "weightLabel": "Вес" } diff --git a/messages/ru/settings/providers/list.json b/messages/ru/settings/providers/list.json index 4a6e90a4f..71bbe2e38 100644 --- a/messages/ru/settings/providers/list.json +++ b/messages/ru/settings/providers/list.json @@ -33,5 +33,11 @@ "unknownError": "Неизвестная ошибка", "viewFullKey": "Просмотр полного API-ключа", "viewFullKeyDesc": "Пожалуйста, храните его в безопасности и не делитесь с другими", - "weight": "Вес" + "weight": "Вес", + "actions": "Действия", + "actionClone": "Клонировать", + "actionResetCircuit": "Сбросить автоматический выключатель", + "actionResetUsage": "Сбросить использование", + "actionDelete": "Удалить", + "selectProvider": "Выбрать {name}" } diff --git a/messages/zh-CN/settings/providers/filter.json b/messages/zh-CN/settings/providers/filter.json index b1d3dcf97..09e9792ba 100644 --- a/messages/zh-CN/settings/providers/filter.json +++ b/messages/zh-CN/settings/providers/filter.json @@ -9,5 +9,8 @@ "all": "全部", "default": "default" }, - "circuitBroken": "熔断" + "circuitBroken": "熔断", + "mobileFilter": "筛选", + "mobileFilterCount": "筛选 ({count})", + "resetFilters": "重置筛选" } diff --git a/messages/zh-CN/settings/providers/form/sections.json b/messages/zh-CN/settings/providers/form/sections.json index beff339b9..be1be70fe 100644 --- a/messages/zh-CN/settings/providers/form/sections.json +++ b/messages/zh-CN/settings/providers/form/sections.json @@ -70,6 +70,12 @@ "label": "供应商分组", "placeholder": "例如 premium, economy", "desc": "分组标签。从列表选择或输入新名称后按 Enter 创建(最多50字符)。只有 providerGroup 匹配的用户才能使用此供应商。" + }, + "groupPriorities": { + "label": "分组优先级覆盖", + "desc": "为特定分组设置独立的优先级。留空则使用上方的全局优先级。", + "placeholder": "使用全局优先级", + "noGroups": "请先设置分组标签,才能配置分组优先级" } }, "cacheTtl": { diff --git a/messages/zh-CN/settings/providers/inlineEdit.json b/messages/zh-CN/settings/providers/inlineEdit.json index 1cb7c1ab3..fb2279b57 100644 --- a/messages/zh-CN/settings/providers/inlineEdit.json +++ b/messages/zh-CN/settings/providers/inlineEdit.json @@ -1,12 +1,27 @@ { - "save": "保存", + "addGroup": "添加分组", "cancel": "取消", - "saveSuccess": "保存成功", - "saveFailed": "保存失败", - "priorityLabel": "优先级", - "weightLabel": "权重", + "costMultiplierInvalid": "请输入大于等于 0 的数字", "costMultiplierLabel": "成本倍数", + "createGroup": "创建 \"{name}\"", + "editGroups": "编辑分组", + "globalPriority": "全局优先级", + "groupPriorityLabel": "分组优先级", + "groupPriorityPlaceholder": "使用全局值", + "groupSaveError": "保存分组失败", + "groupValidation": { + "empty": "分组名不能为空", + "noComma": "分组名不能包含逗号", + "tooLong": "分组名不能超过50字符" + }, + "noGroupsAvailable": "无可用分组", "priorityInvalid": "请输入大于等于 0 的整数", + "priorityLabel": "优先级", + "save": "保存", + "saving": "保存中...", + "saveFailed": "保存失败", + "saveSuccess": "保存成功", + "searchGroups": "搜索分组...", "weightInvalid": "请输入 1-100 之间的整数", - "costMultiplierInvalid": "请输入大于等于 0 的数字" + "weightLabel": "权重" } diff --git a/messages/zh-CN/settings/providers/list.json b/messages/zh-CN/settings/providers/list.json index a63cdf96d..a6c0c17f3 100644 --- a/messages/zh-CN/settings/providers/list.json +++ b/messages/zh-CN/settings/providers/list.json @@ -33,5 +33,11 @@ "toggleSuccessDesc": "供应商 \"{name}\" 状态已更新", "toggleFailed": "状态切换失败", "statusEnabled": "启用", - "statusDisabled": "禁用" + "statusDisabled": "禁用", + "actions": "操作", + "actionClone": "克隆", + "actionResetCircuit": "重置熔断", + "actionResetUsage": "重置用量", + "actionDelete": "删除", + "selectProvider": "选择 {name}" } diff --git a/messages/zh-TW/settings/providers/filter.json b/messages/zh-TW/settings/providers/filter.json index fcd227647..765aa2961 100644 --- a/messages/zh-TW/settings/providers/filter.json +++ b/messages/zh-TW/settings/providers/filter.json @@ -9,5 +9,8 @@ "active": "已啟用", "all": "所有狀態", "inactive": "已停用" - } + }, + "mobileFilter": "篩選", + "mobileFilterCount": "篩選 ({count})", + "resetFilters": "重置篩選" } diff --git a/messages/zh-TW/settings/providers/form/sections.json b/messages/zh-TW/settings/providers/form/sections.json index 5d4b6e59d..6122584db 100644 --- a/messages/zh-TW/settings/providers/form/sections.json +++ b/messages/zh-TW/settings/providers/form/sections.json @@ -297,6 +297,12 @@ "label": "供應商分組", "placeholder": "例如 premium, economy" }, + "groupPriorities": { + "label": "分組優先級覆蓋", + "desc": "為特定分組設定獨立的優先級。留空則使用上方的全域優先級。", + "placeholder": "使用全域優先級", + "noGroups": "請先設定分組標籤,才能設定分組優先級" + }, "priority": { "desc": "數值越小,優先級越高(0 最高)。系統只會從最高優先級的供應商中選擇。建議:主力=0,備用=1,緊急備援=2", "label": "優先級", diff --git a/messages/zh-TW/settings/providers/inlineEdit.json b/messages/zh-TW/settings/providers/inlineEdit.json index f976de0e7..95aa6e70c 100644 --- a/messages/zh-TW/settings/providers/inlineEdit.json +++ b/messages/zh-TW/settings/providers/inlineEdit.json @@ -1,12 +1,27 @@ { + "addGroup": "新增分組", "cancel": "放棄", "costMultiplierInvalid": "請輸入大於等於 0 的數字", "costMultiplierLabel": "成本倍數", + "createGroup": "建立 \"{name}\"", + "editGroups": "編輯分組", + "globalPriority": "全域優先級", + "groupPriorityLabel": "分組優先級", + "groupPriorityPlaceholder": "使用全域值", + "groupSaveError": "儲存分組失敗", + "groupValidation": { + "empty": "分組名稱不能為空", + "noComma": "分組名稱不能包含逗號", + "tooLong": "分組名稱不能超過50字元" + }, + "noGroupsAvailable": "無可用分組", "priorityInvalid": "請輸入大於等於 0 的整數", "priorityLabel": "優先級", "save": "儲存", + "saving": "儲存中...", "saveFailed": "儲存失敗", "saveSuccess": "儲存成功", + "searchGroups": "搜尋分組...", "weightInvalid": "請輸入 1-100 之間的整數", "weightLabel": "權重" } diff --git a/messages/zh-TW/settings/providers/list.json b/messages/zh-TW/settings/providers/list.json index bcc07e938..620ce129f 100644 --- a/messages/zh-TW/settings/providers/list.json +++ b/messages/zh-TW/settings/providers/list.json @@ -33,5 +33,11 @@ "unknownError": "未知錯誤", "viewFullKey": "查看完整 API 金鑰", "viewFullKeyDesc": "請妥善保管,不要洩露給他人", - "weight": "權重" + "weight": "權重", + "actions": "操作", + "actionClone": "複製", + "actionResetCircuit": "重置熔斷", + "actionResetUsage": "重置用量", + "actionDelete": "刪除", + "selectProvider": "選擇 {name}" } diff --git a/package.json b/package.json index db64d8491..c6650c578 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "timeago.js": "^4", "tw-animate-css": "^1", "undici": "^7", + "vaul": "^1.1.2", "zod": "^4" }, "devDependencies": { diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 735981182..7f78ff0d2 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -244,6 +244,7 @@ export async function getProviders(): Promise { isEnabled: provider.isEnabled, weight: provider.weight, priority: provider.priority, + groupPriorities: provider.groupPriorities, costMultiplier: provider.costMultiplier, groupTag: provider.groupTag, providerType: provider.providerType, @@ -616,6 +617,7 @@ export async function editProvider( priority?: number; cost_multiplier?: number; group_tag?: string | null; + group_priorities?: Record | null; provider_type?: ProviderType; preserve_client_ip?: boolean; model_redirects?: Record | null; diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx index 890934353..966f2ca1f 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx @@ -308,6 +308,10 @@ function ProviderFormContent({ allowed_models: state.routing.allowedModels.length > 0 ? state.routing.allowedModels : null, priority: state.routing.priority, + group_priorities: + Object.keys(state.routing.groupPriorities).length > 0 + ? state.routing.groupPriorities + : null, weight: state.routing.weight, cost_multiplier: state.routing.costMultiplier, group_tag: state.routing.groupTag.length > 0 ? state.routing.groupTag.join(",") : null, diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx index 5b80ae250..11fcc6ba9 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx @@ -47,6 +47,7 @@ export function createInitialState( modelRedirects: sourceProvider?.modelRedirects ?? {}, allowedModels: sourceProvider?.allowedModels ?? [], priority: sourceProvider?.priority ?? 0, + groupPriorities: sourceProvider?.groupPriorities ?? {}, weight: sourceProvider?.weight ?? 1, costMultiplier: sourceProvider?.costMultiplier ?? 1.0, cacheTtlPreference: sourceProvider?.cacheTtlPreference ?? "inherit", @@ -141,6 +142,8 @@ export function providerFormReducer( return { ...state, routing: { ...state.routing, allowedModels: action.payload } }; case "SET_PRIORITY": return { ...state, routing: { ...state.routing, priority: action.payload } }; + case "SET_GROUP_PRIORITIES": + return { ...state, routing: { ...state.routing, groupPriorities: action.payload } }; case "SET_WEIGHT": return { ...state, routing: { ...state.routing, weight: action.payload } }; case "SET_COST_MULTIPLIER": diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts index 27f9203b2..c2c662d5c 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts @@ -40,6 +40,7 @@ export interface RoutingState { modelRedirects: Record; allowedModels: string[]; priority: number; + groupPriorities: Record; weight: number; costMultiplier: number; cacheTtlPreference: "inherit" | "5m" | "1h"; @@ -118,6 +119,7 @@ export type ProviderFormAction = | { type: "SET_MODEL_REDIRECTS"; payload: Record } | { type: "SET_ALLOWED_MODELS"; payload: string[] } | { type: "SET_PRIORITY"; payload: number } + | { type: "SET_GROUP_PRIORITIES"; payload: Record } | { type: "SET_WEIGHT"; payload: number } | { type: "SET_COST_MULTIPLIER"; payload: number } | { type: "SET_CACHE_TTL_PREFERENCE"; payload: "inherit" | "5m" | "1h" } diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx index e93e950bb..35fa62592 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx @@ -281,6 +281,46 @@ export function RoutingSection() { /> + + {/* Per-Group Priority Override */} + {state.routing.groupTag.length > 0 && ( +
+
+ {t("sections.routing.scheduleParams.groupPriorities.label")} +
+

+ {t("sections.routing.scheduleParams.groupPriorities.desc")} +

+
+ {state.routing.groupTag.map((group) => ( +
+ + {group} + + { + const val = e.target.value; + const next = { ...state.routing.groupPriorities }; + if (val === "") { + delete next[group]; + } else { + next[group] = parseInt(val, 10) || 0; + } + dispatch({ type: "SET_GROUP_PRIORITIES", payload: next }); + }} + placeholder={t("sections.routing.scheduleParams.groupPriorities.placeholder")} + disabled={state.ui.isPending} + min="0" + step="1" + className="h-8 text-sm" + /> +
+ ))} +
+
+ )} {/* Advanced Settings */} diff --git a/src/app/[locale]/settings/providers/_components/group-edit-combobox.tsx b/src/app/[locale]/settings/providers/_components/group-edit-combobox.tsx new file mode 100644 index 000000000..f454d684e --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/group-edit-combobox.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { Loader2, Plus } from "lucide-react"; +import { useTranslations } from "next-intl"; +import type * as React from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { useMediaQuery } from "@/lib/hooks/use-media-query"; +import { cn } from "@/lib/utils"; +import { getContrastTextColor, getGroupColor } from "@/lib/utils/color"; + +const MAX_GROUP_NAME_LENGTH = 50; + +export interface GroupEditComboboxProps { + currentGroups: string[]; + allGroups: string[]; + userGroups: string[]; + isAdmin: boolean; + onSave: (groups: string[]) => Promise; + disabled?: boolean; +} + +export function GroupEditCombobox({ + currentGroups, + allGroups, + userGroups, + isAdmin, + onSave, + disabled = false, +}: GroupEditComboboxProps) { + const t = useTranslations("settings.providers.inlineEdit"); + const isDesktop = useMediaQuery("(min-width: 768px)"); + const [open, setOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + const [selectedGroups, setSelectedGroups] = useState([]); + const [saving, setSaving] = useState(false); + + const inputRef = useRef(null); + + // Sync selectedGroups with currentGroups when opening + useEffect(() => { + if (open) { + setSelectedGroups([...currentGroups]); + setSearchValue(""); + } + }, [open, currentGroups]); + + // Auto-focus search input when opening + useEffect(() => { + if (!open) return; + const raf = requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + return () => cancelAnimationFrame(raf); + }, [open]); + + // Available groups: admin sees all groups, non-admin sees only their assigned groups + const availableGroups = useMemo(() => { + if (isAdmin) { + return allGroups.filter((g) => g !== "default"); + } + return userGroups.filter((g) => g !== "default"); + }, [isAdmin, allGroups, userGroups]); + + // Validation for new group name + const validateGroupName = useCallback( + (name: string): string | null => { + const trimmed = name.trim(); + if (trimmed.length === 0) { + return t("groupValidation.empty"); + } + if (trimmed.includes(",")) { + return t("groupValidation.noComma"); + } + if (trimmed.length > MAX_GROUP_NAME_LENGTH) { + return t("groupValidation.tooLong"); + } + return null; + }, + [t] + ); + + // Check if the search value matches an existing group (case-insensitive) + const searchMatchesExisting = useMemo(() => { + const trimmed = searchValue.trim().toLowerCase(); + return availableGroups.some((g) => g.toLowerCase() === trimmed); + }, [searchValue, availableGroups]); + + // Can create a new group? + const canCreateGroup = useMemo(() => { + const trimmed = searchValue.trim(); + if (!isAdmin) return false; + if (trimmed.length === 0) return false; + if (searchMatchesExisting) return false; + return validateGroupName(trimmed) === null; + }, [isAdmin, searchValue, searchMatchesExisting, validateGroupName]); + + const stopPropagation = (e: React.SyntheticEvent) => { + e.stopPropagation(); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (disabled && nextOpen) return; + setOpen(nextOpen); + }; + + const toggleGroup = async (group: string) => { + const previousSelection = [...selectedGroups]; + const newSelection = previousSelection.includes(group) + ? previousSelection.filter((g) => g !== group) + : [...previousSelection, group]; + + setSelectedGroups(newSelection); + + // Optimistic update: save immediately + setSaving(true); + try { + const ok = await onSave(newSelection); + if (!ok) { + // Rollback on failure + setSelectedGroups(previousSelection); + } + } catch { + // Rollback on exception + setSelectedGroups(previousSelection); + } finally { + setSaving(false); + } + }; + + const handleCreateGroup = async () => { + const trimmed = searchValue.trim(); + if (!canCreateGroup) return; + + const previousSelection = [...selectedGroups]; + const newSelection = [...previousSelection, trimmed]; + setSelectedGroups(newSelection); + setSearchValue(""); + + // Save immediately + setSaving(true); + try { + const ok = await onSave(newSelection); + if (!ok) { + // Rollback on failure + setSelectedGroups(previousSelection); + } + } catch { + // Rollback on exception + setSelectedGroups(previousSelection); + } finally { + setSaving(false); + } + }; + + // Trigger button: show badges if groups exist, otherwise show + button + const triggerButton = ( + + ); + + // Filter groups based on search + const filteredGroups = useMemo(() => { + const trimmed = searchValue.trim().toLowerCase(); + if (!trimmed) return availableGroups; + return availableGroups.filter((g) => g.toLowerCase().includes(trimmed)); + }, [availableGroups, searchValue]); + + const commandContent = ( + + { + if (e.key === "Escape") { + e.preventDefault(); + setOpen(false); + } + if (e.key === "Enter" && canCreateGroup) { + e.preventDefault(); + void handleCreateGroup(); + } + }} + /> + + {canCreateGroup ? null : t("noGroupsAvailable")} + + {/* Existing groups */} + {filteredGroups.length > 0 && ( + +
+ {filteredGroups.map((group) => { + const isSelected = selectedGroups.includes(group); + const bgColor = getGroupColor(group); + return ( + toggleGroup(group)} + className="cursor-pointer data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground" + disabled={saving} + > + + + {group} + + + ); + })} +
+
+ )} + + {/* Create new group option (admin only) */} + {canCreateGroup && ( + + + + {t("createGroup", { name: searchValue.trim() })} + + + )} +
+ + {saving && ( +
+ + {t("saving")} +
+ )} +
+ ); + + if (!isDesktop) { + return ( + <> + {triggerButton} + + + + {t("editGroups")} + +
{commandContent}
+
+
+ + ); + } + + return ( + + {triggerButton} + + {commandContent} + + + ); +} diff --git a/src/app/[locale]/settings/providers/_components/inline-edit-popover.tsx b/src/app/[locale]/settings/providers/_components/inline-edit-popover.tsx index d6bc237e7..fd2093b75 100644 --- a/src/app/[locale]/settings/providers/_components/inline-edit-popover.tsx +++ b/src/app/[locale]/settings/providers/_components/inline-edit-popover.tsx @@ -5,8 +5,10 @@ import { useTranslations } from "next-intl"; import type * as React from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { useMediaQuery } from "@/lib/hooks/use-media-query"; import { cn } from "@/lib/utils"; export interface InlineEditPopoverProps { @@ -29,6 +31,7 @@ export function InlineEditPopover({ type = "number", }: InlineEditPopoverProps) { const t = useTranslations("settings.providers.inlineEdit"); + const isDesktop = useMediaQuery("(min-width: 768px)"); const [open, setOpen] = useState(false); const [draft, setDraft] = useState(() => value.toString()); const [saving, setSaving] = useState(false); @@ -102,24 +105,133 @@ export function InlineEditPopover({ } }; - return ( - - - + ); + + const formContent = ( +
+
{label}
+
+ setDraft(e.target.value)} + disabled={disabled || saving} + className="w-full md:w-24 tabular-nums" + aria-label={label} + aria-invalid={validationError != null} + type="number" + inputMode="decimal" + step={type === "integer" ? "1" : "any"} onPointerDown={stopPropagation} onClick={stopPropagation} - > - {value} - {suffix} - - + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Escape") { + e.preventDefault(); + handleCancel(); + } + if (e.key === "Enter") { + e.preventDefault(); + void handleSave(); + } + }} + /> + {suffix && {suffix}} +
+ {validationError &&
{validationError}
} +
+ + +
+
+ ); + + if (!isDesktop) { + return ( + <> + {triggerButton} + + + + {label} + +
+
+ setDraft(e.target.value)} + disabled={disabled || saving} + className="tabular-nums text-lg" + aria-label={label} + aria-invalid={validationError != null} + type="number" + inputMode="decimal" + step={type === "integer" ? "1" : "any"} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault(); + handleCancel(); + } + if (e.key === "Enter") { + e.preventDefault(); + void handleSave(); + } + }} + /> + {suffix && {suffix}} + {validationError && ( +
{validationError}
+ )} +
+ + +
+
+
+
+
+ + ); + } + + return ( + + {triggerButton} -
-
{label}
- -
- setDraft(e.target.value)} - disabled={disabled || saving} - className="w-24 tabular-nums" - aria-label={label} - aria-invalid={validationError != null} - type="number" - inputMode="decimal" - step={type === "integer" ? "1" : "any"} - onPointerDown={stopPropagation} - onClick={stopPropagation} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Escape") { - e.preventDefault(); - handleCancel(); - } - if (e.key === "Enter") { - e.preventDefault(); - void handleSave(); - } - }} - /> - {suffix && {suffix}} -
- - {validationError &&
{validationError}
} - -
- - -
-
+ {formContent}
); diff --git a/src/app/[locale]/settings/providers/_components/priority-edit-popover.tsx b/src/app/[locale]/settings/providers/_components/priority-edit-popover.tsx new file mode 100644 index 000000000..518931d8c --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/priority-edit-popover.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import type * as React from "react"; +import { useEffect, useRef, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { useMediaQuery } from "@/lib/hooks/use-media-query"; +import { cn } from "@/lib/utils"; + +interface PriorityEditPopoverProps { + globalPriority: number; + groupPriorities: Record | null; + groups: string[]; + activeGroupFilter: string | null; + disabled?: boolean; + onSave: ( + globalPriority: number, + groupPriorities: Record | null + ) => Promise; + validator: (value: string) => string | null; +} + +export function PriorityEditPopover({ + globalPriority, + groupPriorities, + groups, + activeGroupFilter, + disabled = false, + onSave, + validator, +}: PriorityEditPopoverProps) { + const t = useTranslations("settings.providers.inlineEdit"); + const isDesktop = useMediaQuery("(min-width: 768px)"); + const [open, setOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [globalDraft, setGlobalDraft] = useState(() => globalPriority.toString()); + const [groupDrafts, setGroupDrafts] = useState>({}); + + const globalInputRef = useRef(null); + + // Compute display value and whether it's a group override + const effectivePriority = + activeGroupFilter && groupPriorities?.[activeGroupFilter] != null + ? groupPriorities[activeGroupFilter] + : globalPriority; + const isGroupOverride = activeGroupFilter != null && groupPriorities?.[activeGroupFilter] != null; + + // Validation for global draft + const globalError = validator(globalDraft.trim()); + + // Validation for group drafts + const groupErrors: Record = {}; + for (const g of groups) { + const draft = groupDrafts[g] ?? ""; + if (draft.trim() === "") { + groupErrors[g] = null; // empty means use global + } else { + groupErrors[g] = validator(draft.trim()); + } + } + + const hasAnyError = globalError != null || Object.values(groupErrors).some((e) => e != null); + + const canSave = !disabled && !saving && !hasAnyError && globalDraft.trim() !== ""; + + useEffect(() => { + if (!open) return; + const raf = requestAnimationFrame(() => { + globalInputRef.current?.focus(); + globalInputRef.current?.select(); + }); + return () => cancelAnimationFrame(raf); + }, [open]); + + const stopPropagation = (e: React.SyntheticEvent) => { + e.stopPropagation(); + }; + + const resetDrafts = () => { + setGlobalDraft(globalPriority.toString()); + const drafts: Record = {}; + for (const g of groups) { + drafts[g] = groupPriorities?.[g] != null ? groupPriorities[g].toString() : ""; + } + setGroupDrafts(drafts); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (disabled && nextOpen) return; + if (nextOpen) { + resetDrafts(); + } else { + setSaving(false); + } + setOpen(nextOpen); + }; + + const handleCancel = () => { + resetDrafts(); + setOpen(false); + }; + + const handleSave = async () => { + if (!canSave) return; + + const parsedGlobal = Number(globalDraft.trim()); + if (!Number.isFinite(parsedGlobal) || !Number.isInteger(parsedGlobal) || parsedGlobal < 0) + return; + + const mergedGroupPriorities: Record = { ...(groupPriorities ?? {}) }; + for (const g of groups) { + const draft = (groupDrafts[g] ?? "").trim(); + if (draft === "") { + delete mergedGroupPriorities[g]; + continue; + } + const val = Number(draft); + if (Number.isFinite(val) && Number.isInteger(val) && val >= 0) { + mergedGroupPriorities[g] = val; + } + } + const hasGroupOverrides = Object.keys(mergedGroupPriorities).length > 0; + + setSaving(true); + try { + const ok = await onSave(parsedGlobal, hasGroupOverrides ? mergedGroupPriorities : null); + if (ok) { + setOpen(false); + } + } finally { + setSaving(false); + } + }; + + const handleGroupDraftChange = (group: string, value: string) => { + setGroupDrafts((prev) => ({ ...prev, [group]: value })); + }; + + const triggerButton = ( + + ); + + const priorityFormFields = ( + <> + {/* Global priority */} +
+
{t("globalPriority")}
+ setGlobalDraft(e.target.value)} + disabled={disabled || saving} + className="tabular-nums" + aria-label={t("globalPriority")} + aria-invalid={globalError != null} + type="number" + inputMode="decimal" + step="1" + onPointerDown={stopPropagation} + onClick={stopPropagation} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Escape") { + e.preventDefault(); + handleCancel(); + } + if (e.key === "Enter") { + e.preventDefault(); + void handleSave(); + } + }} + /> + {globalError &&
{globalError}
} +
+ + {/* Per-group priorities */} + {groups.length > 0 && ( +
+
{t("groupPriorityLabel")}
+ {groups.map((group) => ( +
+ + {group} + + handleGroupDraftChange(group, e.target.value)} + disabled={disabled || saving} + placeholder={t("groupPriorityPlaceholder")} + className="tabular-nums" + aria-label={`${t("groupPriorityLabel")} - ${group}`} + aria-invalid={groupErrors[group] != null} + type="number" + inputMode="decimal" + step="1" + onPointerDown={stopPropagation} + onClick={stopPropagation} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Escape") { + e.preventDefault(); + handleCancel(); + } + if (e.key === "Enter") { + e.preventDefault(); + void handleSave(); + } + }} + /> + {groupErrors[group] && ( +
{groupErrors[group]}
+ )} +
+ ))} +
+ )} + + ); + + const actionButtons = ( +
+ + +
+ ); + + if (!isDesktop) { + return ( + <> + {triggerButton} + + + + {t("globalPriority")} + +
+
+ {priorityFormFields} +
+ + +
+
+
+
+
+ + ); + } + + return ( + + {triggerButton} + + +
+ {priorityFormFields} + {actionButtons} +
+
+
+ ); +} diff --git a/src/app/[locale]/settings/providers/_components/provider-list.tsx b/src/app/[locale]/settings/providers/_components/provider-list.tsx index 7feb92b2b..3b67ebf0a 100644 --- a/src/app/[locale]/settings/providers/_components/provider-list.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-list.tsx @@ -23,9 +23,13 @@ interface ProviderListProps { statisticsLoading?: boolean; currencyCode?: CurrencyCode; enableMultiProviderTypes: boolean; + activeGroupFilter?: string | null; isMultiSelectMode?: boolean; selectedProviderIds?: Set; onSelectProvider?: (providerId: number, checked: boolean) => void; + allGroups?: string[]; + userGroups?: string[]; + isAdmin?: boolean; } export function ProviderList({ @@ -36,9 +40,13 @@ export function ProviderList({ statisticsLoading = false, currencyCode = "USD", enableMultiProviderTypes, + activeGroupFilter = null, isMultiSelectMode = false, selectedProviderIds = new Set(), onSelectProvider, + allGroups = [], + userGroups = [], + isAdmin = false, }: ProviderListProps) { const t = useTranslations("settings.providers"); @@ -55,7 +63,7 @@ export function ProviderList({ } return ( -
+
{providers.map((provider) => ( onSelectProvider(provider.id, checked) : undefined } + allGroups={allGroups} + userGroups={userGroups} + isAdmin={isAdmin} /> ))}
diff --git a/src/app/[locale]/settings/providers/_components/provider-manager.tsx b/src/app/[locale]/settings/providers/_components/provider-manager.tsx index c35a1fd93..fce2c8761 100644 --- a/src/app/[locale]/settings/providers/_components/provider-manager.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-manager.tsx @@ -1,8 +1,9 @@ "use client"; -import { AlertTriangle, LayoutGrid, LayoutList, Loader2, Search } from "lucide-react"; +import { AlertTriangle, Filter, LayoutGrid, LayoutList, Loader2, Search } from "lucide-react"; import { useTranslations } from "next-intl"; import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -77,6 +78,7 @@ export function ProviderManager({ const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all"); const [groupFilter, setGroupFilter] = useState([]); const [circuitBrokenFilter, setCircuitBrokenFilter] = useState(false); + const [mobileFilterOpen, setMobileFilterOpen] = useState(false); // Batch edit state const [isMultiSelectMode, setIsMultiSelectMode] = useState(false); @@ -89,6 +91,16 @@ export function ProviderManager({ return providers.filter((p) => healthStatus[p.id]?.circuitState === "open").length; }, [providers, healthStatus]); + const activeFilterCount = useMemo(() => { + let count = 0; + if (typeFilter !== "all") count++; + if (statusFilter !== "all") count++; + if (groupFilter.length > 0) count++; + if (circuitBrokenFilter) count++; + if (sortBy !== "priority") count++; + return count; + }, [typeFilter, statusFilter, groupFilter, circuitBrokenFilter, sortBy]); + // Auto-reset circuit broken filter when no providers are broken useEffect(() => { if (circuitBrokenCount === 0 && circuitBrokenFilter) { @@ -120,6 +132,18 @@ export function ProviderManager({ return sortedGroups; }, [providers]); + // User's assigned groups (for non-admin users) + const userGroups = useMemo(() => { + if (!currentUser?.providerGroup) return []; + return currentUser.providerGroup + .split(",") + .map((g) => g.trim()) + .filter(Boolean); + }, [currentUser?.providerGroup]); + + // Check if current user is admin + const isAdmin = currentUser?.role === "admin"; + // 统一过滤逻辑:搜索 + 类型筛选 + 排序 const filteredProviders = useMemo(() => { let result = providers; @@ -284,52 +308,10 @@ export function ProviderManager({ /> {addDialogSlot ?
{addDialogSlot}
: null}
- {/* 筛选条件 */} + {/* Filter section */}
-
- {/* View Mode Toggle */} -
- - -
- - - - {/* Status filter */} - - - + {/* Mobile: search + filter toggle button */} +
+
- {/* Group filter */} - {allGroups.length > 0 && ( -
- {tFilter("groups.label")} - - {allGroups.map((group) => ( + {/* Mobile: collapsible filter panel */} + + +
+ + + + {allGroups.length > 0 && ( +
+ {tFilter("groups.label")} + + {allGroups.map((group) => ( + + ))} +
+ )} + {circuitBrokenCount > 0 && ( +
+ + + +
+ )} +
+
+
+ + {/* Desktop: original filter layout */} +
+
+ {/* View Mode Toggle */} +
+ + +
+ + + + + + +
+ + setSearchTerm(e.target.value)} + className="pl-9" + disabled={loading} + /> +
+
+ + {/* Group filter */} + {allGroups.length > 0 && ( +
+ {tFilter("groups.label")} + - ))} -
- )} - {/* 搜索结果提示 + Circuit Breaker filter */} + {allGroups.map((group) => ( + + ))} +
+ )} +
+ + {/* Search result count + Circuit Breaker filter (both mobile and desktop) */}
{debouncedSearchTerm ? (

@@ -394,7 +534,7 @@ export function ProviderManager({ {/* Circuit Breaker toggle - only show if there are broken providers */} {circuitBrokenCount > 0 && ( -

+
@@ -436,9 +576,13 @@ export function ProviderManager({ statisticsLoading={statisticsLoading} currencyCode={currencyCode} enableMultiProviderTypes={enableMultiProviderTypes} + activeGroupFilter={groupFilter.length === 1 ? groupFilter[0] : null} isMultiSelectMode={isMultiSelectMode} selectedProviderIds={selectedProviderIds} onSelectProvider={handleSelectProvider} + allGroups={allGroups} + userGroups={userGroups} + isAdmin={isAdmin} /> ) : ( void; onEdit?: () => void; onClone?: () => void; onDelete?: () => void; + allGroups?: string[]; + userGroups?: string[]; + isAdmin?: boolean; } export function ProviderRichListItem({ @@ -90,10 +104,14 @@ export function ProviderRichListItem({ enableMultiProviderTypes, isMultiSelectMode = false, isSelected = false, + activeGroupFilter = null, onSelectChange, onEdit: onEditProp, onClone: onCloneProp, onDelete: onDeleteProp, + allGroups = [], + userGroups = [], + isAdmin = false, }: ProviderRichListItemProps) { const router = useRouter(); const queryClient = useQueryClient(); @@ -106,6 +124,7 @@ export function ProviderRichListItem({ const [openEdit, setOpenEdit] = useState(false); const [openClone, setOpenClone] = useState(false); const [showKeyDialog, setShowKeyDialog] = useState(false); + const [mobileDeleteDialogOpen, setMobileDeleteDialogOpen] = useState(false); const [unmaskedKey, setUnmaskedKey] = useState(null); const [copied, setCopied] = useState(false); const [clipboardAvailable, setClipboardAvailable] = useState(false); @@ -359,48 +378,280 @@ export function ProviderRichListItem({ }; }; - const handleSavePriority = createSaveHandler("priority"); const handleSaveWeight = createSaveHandler("weight"); const handleSaveCostMultiplier = createSaveHandler("cost_multiplier"); + const providerGroups = provider.groupTag + ? provider.groupTag + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + : []; + + const handleSaveGroups = async (groups: string[]): Promise => { + try { + const groupTag = groups.length > 0 ? groups.join(",") : null; + const res = await editProvider(provider.id, { group_tag: groupTag }); + if (res.ok) { + toast.success(tInline("saveSuccess")); + queryClient.invalidateQueries({ queryKey: ["providers"] }); + router.refresh(); + return true; + } + toast.error(tInline("groupSaveError"), { + description: res.error || tList("unknownError"), + }); + return false; + } catch (error) { + console.error("Failed to save groups:", error); + toast.error(tInline("groupSaveError"), { description: tList("unknownError") }); + return false; + } + }; + + const handleSavePriorityWithGroups = async ( + newGlobal: number, + newGroupPriorities: Record | null + ): Promise => { + try { + const res = await editProvider(provider.id, { + priority: newGlobal, + group_priorities: newGroupPriorities, + }); + if (res.ok) { + toast.success(tInline("saveSuccess")); + queryClient.invalidateQueries({ queryKey: ["providers"] }); + router.refresh(); + return true; + } + toast.error(tInline("saveFailed"), { description: res.error || tList("unknownError") }); + return false; + } catch (error) { + console.error("Failed to update priority:", error); + toast.error(tInline("saveFailed"), { description: tList("unknownError") }); + return false; + } + }; + return ( <> -
- {/* 多选模式下显示 checkbox */} +
+ {/* Checkbox: shared between mobile and desktop */} {isMultiSelectMode && ( onSelectChange?.(Boolean(checked))} onClick={(e) => e.stopPropagation()} - aria-label={`Select ${provider.name}`} + aria-label={tList("selectProvider", { name: provider.name })} + className="flex-shrink-0" /> )} - {/* 左侧:状态和类型图标 */} -
- {/* 启用状态指示器 */} + {/* Mobile: top row with name and switch */} +
+
+ {provider.isEnabled ? ( + + ) : ( + + )} +
+ +
+ {provider.name} +
+ {canEdit && ( + + )} +
+ + {/* Mobile: status badges */} +
+ {canEdit ? ( + + ) : providerGroups.length > 0 ? ( + providerGroups.map((tag, index) => { + const bgColor = getGroupColor(tag); + return ( + + {tag} + + ); + }) + ) : ( + {PROVIDER_GROUP.DEFAULT} + )} + {healthStatus?.circuitState === "open" && ( + + + {tList("circuitBroken")} + + )} +
+ + {/* Mobile: metrics row */} +
+
+ {tList("priority")}: + + {canEdit ? ( + + ) : ( + provider.priority + )} + +
+
+ {tList("weight")}: + + {canEdit ? ( + + ) : ( + provider.weight + )} + +
+
+ {tList("costMultiplier")}: + + {canEdit ? ( + + ) : ( + <>{provider.costMultiplier}x + )} + +
+
+ + {/* Mobile: actions */} +
+ {canEdit && ( + + )} + {canEdit && ( + + + + + + + + {tList("actionClone")} + + {healthStatus?.circuitState === "open" && ( + + + {tList("actionResetCircuit")} + + )} + {provider.limitTotalUsd !== null && provider.limitTotalUsd > 0 && ( + + + {tList("actionResetUsage")} + + )} + + setMobileDeleteDialogOpen(true)} + > + + {tList("actionDelete")} + + + + )} +
+ + {canEdit && ( + + + + {tList("confirmDeleteTitle")} + + {tList("confirmDeleteMessage", { name: provider.name })} + + +
+ {tList("cancelButton")} + + {tList("deleteButton")} + +
+
+
+ )} + + {/* Desktop: original info section (hidden on mobile) */} +
{provider.isEnabled ? ( ) : ( )} - - {/* 类型图标 */}
- {/* 中间:名称、URL、官网、tag、熔断状态 */} -
+
- {/* Favicon */} {provider.faviconUrl && ( - // eslint-disable-next-line @next/next/no-img-element )} - - {/* 名称 */} {provider.name} - - {/* Group Tags (supports comma-separated values) */} - {(provider.groupTag - ? provider.groupTag - .split(",") - .map((t) => t.trim()) - .filter(Boolean) - : [] - ).length > 0 ? ( - provider.groupTag - ?.split(",") - .map((t) => t.trim()) - .filter(Boolean) - .map((tag, index) => { - const bgColor = getGroupColor(tag); - return ( - - {tag} - - ); - }) + {canEdit ? ( + + ) : providerGroups.length > 0 ? ( + providerGroups.map((tag, index) => { + const bgColor = getGroupColor(tag); + return ( + + {tag} + + ); + }) ) : ( {PROVIDER_GROUP.DEFAULT} )} - - {/* 熔断器警告 */} - {healthStatus && healthStatus.circuitState === "open" && ( + {healthStatus?.circuitState === "open" && ( {tList("circuitBroken")} )}
-
{/* Vendor & Endpoints OR Legacy URL */} {vendor ? ( @@ -482,8 +721,6 @@ export function ProviderRichListItem({ {tList("officialWebsite")} )} - - {/* API Key 展示(仅管理员) */} {canEdit && ( )} - - {/* 超时配置可视化(紧凑格式) */} {tTimeout("summary", { streaming: @@ -517,18 +752,19 @@ export function ProviderRichListItem({
- {/* 右侧:指标(仅桌面端) */} + {/* Desktop: metrics */}
{tList("priority")}
{canEdit ? ( - ) : ( {provider.priority} @@ -570,7 +806,7 @@ export function ProviderRichListItem({
- {/* 今日用量(仅大屏) */} + {/* Desktop: today usage */}
{tList("todayUsageLabel")}
{statisticsLoading ? ( @@ -595,9 +831,8 @@ export function ProviderRichListItem({ )}
- {/* 操作按钮 */} -
- {/* 启用/禁用切换 */} + {/* Desktop: action buttons */} +
{canEdit && ( )} - - {/* 编辑按钮 */} {canEdit && ( )} - - {/* 克隆按钮 */} {canEdit && ( )} - - {/* 熔断重置按钮(仅熔断时显示) */} - {canEdit && healthStatus && healthStatus.circuitState === "open" && ( + {canEdit && healthStatus?.circuitState === "open" && ( )} - - {/* 总用量重置按钮(仅配置了总限额时显示) */} {canEdit && provider.limitTotalUsd !== null && provider.limitTotalUsd > 0 && ( )} - - {/* 删除按钮 */} {canEdit && ( diff --git a/src/app/v1/_lib/proxy/provider-selector.ts b/src/app/v1/_lib/proxy/provider-selector.ts index ed75423cf..6d8381651 100644 --- a/src/app/v1/_lib/proxy/provider-selector.ts +++ b/src/app/v1/_lib/proxy/provider-selector.ts @@ -903,12 +903,23 @@ export class ProxyProviderResolver { } // Step 5: 优先级分层(只选择最高优先级的供应商) - const topPriorityProviders = ProxyProviderResolver.selectTopPriority(healthyProviders); - const priorities = [...new Set(healthyProviders.map((p) => p.priority || 0))].sort( - (a, b) => a - b + const topPriorityProviders = ProxyProviderResolver.selectTopPriority( + healthyProviders, + effectiveGroupPick ); + const priorities = [ + ...new Set( + healthyProviders.map((p) => + ProxyProviderResolver.resolveEffectivePriority(p, effectiveGroupPick ?? null) + ) + ), + ].sort((a, b) => a - b); context.priorityLevels = priorities; - context.selectedPriority = Math.min(...healthyProviders.map((p) => p.priority || 0)); + context.selectedPriority = Math.min( + ...healthyProviders.map((p) => + ProxyProviderResolver.resolveEffectivePriority(p, effectiveGroupPick ?? null) + ) + ); // Step 6: 成本排序 + 加权选择 + 计算概率 const totalWeight = topPriorityProviders.reduce((sum, p) => sum + p.weight, 0); @@ -1024,18 +1035,38 @@ export class ProxyProviderResolver { } /** - * 优先级分层:只选择最高优先级的供应商 + * 解析供应商的有效优先级:优先使用分组覆盖值,回退到全局默认值 + * 支持逗号分隔的多分组(如 "cli,admin"),取匹配到的最小优先级 + */ + static resolveEffectivePriority(provider: Provider, userGroup: string | null): number { + if (userGroup && provider.groupPriorities) { + const groups = parseGroupString(userGroup); + const overrides = groups + .map((g) => provider.groupPriorities?.[g]) + .filter((v): v is number => v !== undefined); + if (overrides.length > 0) { + return Math.min(...overrides); + } + } + return provider.priority ?? 0; + } + + /** + * 优先级分层:只选择最高优先级的供应商(支持分组优先级覆盖) */ - private static selectTopPriority(providers: Provider[]): Provider[] { + private static selectTopPriority(providers: Provider[], userGroup?: string | null): Provider[] { if (providers.length === 0) { return []; } - // 找到最小的优先级值(最高优先级) - const minPriority = Math.min(...providers.map((p) => p.priority || 0)); + const group = userGroup ?? null; + const minPriority = Math.min( + ...providers.map((p) => ProxyProviderResolver.resolveEffectivePriority(p, group)) + ); - // 只返回该优先级的供应商 - return providers.filter((p) => (p.priority || 0) === minPriority); + return providers.filter( + (p) => ProxyProviderResolver.resolveEffectivePriority(p, group) === minPriority + ); } /** @@ -1174,7 +1205,10 @@ export class ProxyProviderResolver { } // 优先级分层 - const topPriorityProviders = ProxyProviderResolver.selectTopPriority(healthyProviders); + const topPriorityProviders = ProxyProviderResolver.selectTopPriority( + healthyProviders, + effectiveGroupPick + ); // 成本排序 + 加权随机选择 const selected = ProxyProviderResolver.selectOptimal(topPriorityProviders); @@ -1201,10 +1235,17 @@ export class ProxyProviderResolver { beforeHealthCheck: typeFiltered.length, afterHealthCheck: healthyProviders.length, filteredProviders: [], - priorityLevels: [...new Set(healthyProviders.map((p) => p.priority || 0))].sort( - (a, b) => a - b + priorityLevels: [ + ...new Set( + healthyProviders.map((p) => + ProxyProviderResolver.resolveEffectivePriority(p, effectiveGroupPick ?? null) + ) + ), + ].sort((a, b) => a - b), + selectedPriority: ProxyProviderResolver.resolveEffectivePriority( + selected, + effectiveGroupPick ?? null ), - selectedPriority: selected.priority || 0, candidatesAtPriority: candidates, }, }; diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx new file mode 100644 index 000000000..f5763ff6d --- /dev/null +++ b/src/components/ui/drawer.tsx @@ -0,0 +1,124 @@ +"use client"; + +import type * as React from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; + +import { cn } from "@/lib/utils/index"; + +function Drawer({ ...props }: React.ComponentProps) { + return ; +} + +function DrawerTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DrawerPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DrawerClose({ ...props }: React.ComponentProps) { + return ; +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ); +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DrawerTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 2d0426f6d..d8d933720 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -162,6 +162,7 @@ export const providers = pgTable('providers', { // 优先级和分组配置 priority: integer('priority').notNull().default(0), + groupPriorities: jsonb('group_priorities').$type | null>().default(null), costMultiplier: numeric('cost_multiplier', { precision: 10, scale: 4 }).default('1.0'), groupTag: varchar('group_tag', { length: 50 }), diff --git a/src/lib/hooks/use-media-query.ts b/src/lib/hooks/use-media-query.ts new file mode 100644 index 000000000..d11f78df2 --- /dev/null +++ b/src/lib/hooks/use-media-query.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(false); + + useEffect(() => { + if (typeof window === "undefined" || !("matchMedia" in window)) { + return; + } + const mql = window.matchMedia(query); + setMatches(mql.matches); + const handler = (e: MediaQueryListEvent) => setMatches(e.matches); + mql.addEventListener("change", handler); + return () => mql.removeEventListener("change", handler); + }, [query]); + + return matches; +} diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index b736589b6..6697b7e3b 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -31,13 +31,13 @@ const ANTHROPIC_MAX_TOKENS_PREFERENCE = z.union([ z.literal("inherit"), z .string() - .regex(/^\d+$/, "max_tokens must be 'inherit' or a numeric string") + .regex(/^\d+$/, 'max_tokens 必须为 "inherit" 或数字字符串') .refine( (val) => { const num = Number.parseInt(val, 10); return num >= 1 && num <= 64000; }, - { message: "max_tokens must be between 1 and 64000" } + { message: "max_tokens 必须在 1 到 64000 之间" } ), ]); @@ -45,13 +45,13 @@ const ANTHROPIC_THINKING_BUDGET_PREFERENCE = z.union([ z.literal("inherit"), z .string() - .regex(/^\d+$/, "thinking.budget_tokens must be 'inherit' or a numeric string") + .regex(/^\d+$/, 'thinking.budget_tokens 必须为 "inherit" 或数字字符串') .refine( (val) => { const num = Number.parseInt(val, 10); return num >= 1024 && num <= 32000; }, - { message: "thinking.budget_tokens must be between 1024 and 32000" } + { message: "thinking.budget_tokens 必须在 1024 到 32000 之间" } ), ]); @@ -409,6 +409,11 @@ export const CreateProviderSchema = z .max(2147483647, "优先级超出整数范围") .optional() .default(0), + group_priorities: z + .record(z.string(), z.number().int().min(0).max(2147483647)) + .nullable() + .optional() + .default(null), cost_multiplier: z.coerce.number().min(0, "成本倍率不能为负数").optional().default(1.0), group_tag: z.string().max(50, "分组标签不能超过50个字符").nullable().optional(), // Codex 支持:供应商类型和模型重定向 @@ -610,6 +615,10 @@ export const UpdateProviderSchema = z .min(0, "优先级不能为负数") .max(2147483647, "优先级超出整数范围") .optional(), + group_priorities: z + .record(z.string(), z.number().int().min(0).max(2147483647)) + .nullable() + .optional(), cost_multiplier: z.coerce.number().min(0, "成本倍率不能为负数").optional(), group_tag: z.string().max(50, "分组标签不能超过50个字符").nullable().optional(), // Codex 支持:供应商类型和模型重定向 diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index f99b934d7..6ab9a8968 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -85,6 +85,7 @@ export function toProvider(dbProvider: any): Provider { isEnabled: dbProvider?.isEnabled ?? true, weight: dbProvider?.weight ?? 1, priority: dbProvider?.priority ?? 0, + groupPriorities: dbProvider?.groupPriorities ?? null, costMultiplier: dbProvider?.costMultiplier ? parseFloat(dbProvider.costMultiplier) : 1.0, groupTag: dbProvider?.groupTag ?? null, providerType: dbProvider?.providerType ?? "claude", diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 697d22686..fa5242ab9 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -24,6 +24,7 @@ export async function createProvider(providerData: CreateProviderData): Promise< isEnabled: providerData.is_enabled, weight: providerData.weight, priority: providerData.priority, + groupPriorities: providerData.group_priorities ?? null, costMultiplier: providerData.cost_multiplier != null ? providerData.cost_multiplier.toString() : "1.0", groupTag: providerData.group_tag, @@ -175,6 +176,7 @@ export async function findProviderList( isEnabled: providers.isEnabled, weight: providers.weight, priority: providers.priority, + groupPriorities: providers.groupPriorities, costMultiplier: providers.costMultiplier, groupTag: providers.groupTag, providerType: providers.providerType, @@ -252,6 +254,7 @@ export async function findAllProvidersFresh(): Promise { isEnabled: providers.isEnabled, weight: providers.weight, priority: providers.priority, + groupPriorities: providers.groupPriorities, costMultiplier: providers.costMultiplier, groupTag: providers.groupTag, providerType: providers.providerType, @@ -333,6 +336,7 @@ export async function findProviderById(id: number): Promise { isEnabled: providers.isEnabled, weight: providers.weight, priority: providers.priority, + groupPriorities: providers.groupPriorities, costMultiplier: providers.costMultiplier, groupTag: providers.groupTag, providerType: providers.providerType, @@ -403,6 +407,8 @@ export async function updateProvider( if (providerData.is_enabled !== undefined) dbData.isEnabled = providerData.is_enabled; if (providerData.weight !== undefined) dbData.weight = providerData.weight; if (providerData.priority !== undefined) dbData.priority = providerData.priority; + if (providerData.group_priorities !== undefined) + dbData.groupPriorities = providerData.group_priorities ?? null; if (providerData.cost_multiplier !== undefined) dbData.costMultiplier = providerData.cost_multiplier != null ? providerData.cost_multiplier.toString() : "1.0"; @@ -541,6 +547,7 @@ export async function updateProvider( isEnabled: providers.isEnabled, weight: providers.weight, priority: providers.priority, + groupPriorities: providers.groupPriorities, costMultiplier: providers.costMultiplier, groupTag: providers.groupTag, providerType: providers.providerType, diff --git a/src/types/provider.ts b/src/types/provider.ts index 24ee03ba3..8d4a274db 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -59,6 +59,7 @@ export interface Provider { // 优先级和分组配置 priority: number; + groupPriorities: Record | null; costMultiplier: number; groupTag: string | null; @@ -162,6 +163,7 @@ export interface ProviderDisplay { weight: number; // 优先级和分组配置 priority: number; + groupPriorities: Record | null; costMultiplier: number; groupTag: string | null; // 供应商类型 @@ -251,6 +253,7 @@ export interface CreateProviderData { // 优先级和分组配置 priority?: number; + group_priorities?: Record | null; cost_multiplier?: number; group_tag?: string | null; @@ -322,6 +325,7 @@ export interface UpdateProviderData { // 优先级和分组配置 priority?: number; + group_priorities?: Record | null; cost_multiplier?: number; group_tag?: string | null; diff --git a/tests/unit/proxy/provider-selector-group-priority.test.ts b/tests/unit/proxy/provider-selector-group-priority.test.ts new file mode 100644 index 000000000..077cb74c6 --- /dev/null +++ b/tests/unit/proxy/provider-selector-group-priority.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import type { Provider } from "@/types/provider"; +import { ProxyProviderResolver } from "@/app/v1/_lib/proxy/provider-selector"; + +function makeProvider(overrides: Partial): Provider { + return { + id: 1, + name: "test", + url: "https://api.example.com", + key: "sk-test", + providerVendorId: null, + isEnabled: true, + weight: 1, + priority: 0, + groupPriorities: null, + costMultiplier: 1, + groupTag: null, + providerType: "claude", + preserveClientIp: false, + modelRedirects: null, + allowedModels: null, + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + totalCostResetAt: null, + limitConcurrentSessions: 0, + maxRetryAttempts: null, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 1800000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 30000, + streamingIdleTimeoutMs: 10000, + requestTimeoutNonStreamingMs: 600000, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + anthropicMaxTokensPreference: null, + anthropicThinkingBudgetPreference: null, + geminiGoogleSearchPreference: null, + tpm: null, + rpm: null, + rpd: null, + cc: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe("resolveEffectivePriority", () => { + it("returns global priority when no groupPriorities", () => { + const provider = makeProvider({ priority: 5, groupPriorities: null }); + expect(ProxyProviderResolver.resolveEffectivePriority(provider, "cli")).toBe(5); + }); + + it("returns group-specific priority when override exists", () => { + const provider = makeProvider({ + priority: 5, + groupPriorities: { cli: 0, chat: 2 }, + }); + expect(ProxyProviderResolver.resolveEffectivePriority(provider, "cli")).toBe(0); + expect(ProxyProviderResolver.resolveEffectivePriority(provider, "chat")).toBe(2); + }); + + it("falls back to global when group not in overrides", () => { + const provider = makeProvider({ + priority: 5, + groupPriorities: { cli: 0 }, + }); + expect(ProxyProviderResolver.resolveEffectivePriority(provider, "chat")).toBe(5); + }); + + it("returns global priority when userGroup is null", () => { + const provider = makeProvider({ + priority: 5, + groupPriorities: { cli: 0 }, + }); + expect(ProxyProviderResolver.resolveEffectivePriority(provider, null)).toBe(5); + }); + + it("handles group priority of 0 correctly (not falsy)", () => { + const provider = makeProvider({ + priority: 5, + groupPriorities: { cli: 0 }, + }); + expect(ProxyProviderResolver.resolveEffectivePriority(provider, "cli")).toBe(0); + }); + + it("handles comma-separated user groups (multi-group)", () => { + const provider = makeProvider({ + priority: 10, + groupPriorities: { cli: 2, admin: 5, chat: 8 }, + }); + // Multi-group "cli,admin" should match both and take minimum (2) + expect(ProxyProviderResolver.resolveEffectivePriority(provider, "cli,admin")).toBe(2); + // Multi-group "admin,chat" should take minimum (5) + expect(ProxyProviderResolver.resolveEffectivePriority(provider, "admin,chat")).toBe(5); + }); + + it("falls back to global when no group in multi-group matches", () => { + const provider = makeProvider({ + priority: 10, + groupPriorities: { cli: 2 }, + }); + // "admin,chat" has no matching overrides, should fall back to global (10) + expect(ProxyProviderResolver.resolveEffectivePriority(provider, "admin,chat")).toBe(10); + }); + + it("handles partial match in multi-group", () => { + const provider = makeProvider({ + priority: 10, + groupPriorities: { cli: 3 }, + }); + // "cli,admin" - only "cli" matches, should return 3 + expect(ProxyProviderResolver.resolveEffectivePriority(provider, "cli,admin")).toBe(3); + }); +}); + +describe("selectTopPriority with group context", () => { + // Access private method via bracket notation for testing + const selectTopPriority = (providers: Provider[], userGroup?: string | null) => + (ProxyProviderResolver as any).selectTopPriority(providers, userGroup); + + it("selects providers by group-aware priority", () => { + const providerA = makeProvider({ + id: 1, + name: "A", + priority: 5, + groupPriorities: { cli: 0 }, + }); + const providerB = makeProvider({ + id: 2, + name: "B", + priority: 0, + groupPriorities: null, + }); + + // cli group: A has effective priority 0, B has effective priority 0 + const result = selectTopPriority([providerA, providerB], "cli"); + expect(result).toHaveLength(2); + expect(result.map((p: Provider) => p.id).sort()).toEqual([1, 2]); + }); + + it("without group context, uses global priority", () => { + const providerA = makeProvider({ + id: 1, + name: "A", + priority: 5, + groupPriorities: { cli: 0 }, + }); + const providerB = makeProvider({ + id: 2, + name: "B", + priority: 0, + groupPriorities: null, + }); + + // no group: A has priority 5, B has priority 0 -> only B selected + const result = selectTopPriority([providerA, providerB], null); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(2); + }); + + it("group override changes which providers are top priority", () => { + const providerA = makeProvider({ + id: 1, + name: "A", + priority: 5, + groupPriorities: { chat: 1 }, + }); + const providerB = makeProvider({ + id: 2, + name: "B", + priority: 3, + groupPriorities: null, + }); + + // chat group: A=1, B=3 -> only A + const chatResult = selectTopPriority([providerA, providerB], "chat"); + expect(chatResult).toHaveLength(1); + expect(chatResult[0].id).toBe(1); + + // no group: A=5, B=3 -> only B + const noGroupResult = selectTopPriority([providerA, providerB], null); + expect(noGroupResult).toHaveLength(1); + expect(noGroupResult[0].id).toBe(2); + }); +});