From d7e5d6dd350017e236f76d5c608541185600b3c4 Mon Sep 17 00:00:00 2001 From: umaru Date: Mon, 18 May 2026 22:30:11 +0800 Subject: [PATCH 01/12] feat: add traffic recording runtime controls --- drizzle-sqlite/0011_lush_kitty_pryde.sql | 38 + drizzle-sqlite/meta/0011_snapshot.json | 2562 +++++++++++++++ drizzle-sqlite/meta/_journal.json | 7 + drizzle/0033_shocking_emma_frost.sql | 38 + drizzle/meta/0033_snapshot.json | 2898 +++++++++++++++++ drizzle/meta/_journal.json | 7 + .../.openspec.yaml | 2 + .../design.md | 85 + .../proposal.md | 26 + .../specs/background-sync-tasks/spec.md | 19 + .../traffic-recording-runtime-control/spec.md | 104 + .../tasks.md | 21 + openspec/specs/background-sync-tasks/spec.md | 21 + .../traffic-recording-runtime-control/spec.md | 108 + .../[locale]/(dashboard)/settings/page.tsx | 8 + .../system/traffic-recording/page.tsx | 765 +++++ .../admin/traffic-recording/settings/route.ts | 66 + .../admin/traffic-recordings/[id]/route.ts | 59 + .../admin/traffic-recordings/cleanup/route.ts | 27 + src/app/api/admin/traffic-recordings/route.ts | 71 + src/app/api/proxy/v1/[...path]/route.ts | 44 +- .../admin/background-sync-tasks-panel.tsx | 6 + src/components/admin/sidebar.tsx | 9 +- src/hooks/use-traffic-recording.ts | 132 + src/lib/db/schema-pg.ts | 67 + src/lib/db/schema-sqlite.ts | 68 + src/lib/db/schema.ts | 7 + src/lib/services/background-sync-registry.ts | 2 + src/lib/services/traffic-recorder.ts | 121 +- .../traffic-recording-background-cleanup.ts | 34 + src/lib/services/traffic-recording-service.ts | 397 +++ src/lib/utils/api-transformers.ts | 115 + src/messages/en.json | 52 + src/messages/zh-CN.json | 52 + src/types/api.ts | 49 + tests/components/settings-page.test.tsx | 9 + tests/components/sidebar.test.tsx | 4 + .../traffic-recording-page.test.tsx | 244 ++ .../admin/traffic-recording-routes.test.ts | 237 ++ tests/unit/api/proxy/route.test.ts | 31 +- tests/unit/lib/schema.test.ts | 61 + tests/unit/services/traffic-recorder.test.ts | 121 +- .../traffic-recording-service.test.ts | 294 ++ 43 files changed, 8956 insertions(+), 132 deletions(-) create mode 100644 drizzle-sqlite/0011_lush_kitty_pryde.sql create mode 100644 drizzle-sqlite/meta/0011_snapshot.json create mode 100644 drizzle/0033_shocking_emma_frost.sql create mode 100644 drizzle/meta/0033_snapshot.json create mode 100644 openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/design.md create mode 100644 openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/proposal.md create mode 100644 openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/specs/background-sync-tasks/spec.md create mode 100644 openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/specs/traffic-recording-runtime-control/spec.md create mode 100644 openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/tasks.md create mode 100644 openspec/specs/traffic-recording-runtime-control/spec.md create mode 100644 src/app/[locale]/(dashboard)/system/traffic-recording/page.tsx create mode 100644 src/app/api/admin/traffic-recording/settings/route.ts create mode 100644 src/app/api/admin/traffic-recordings/[id]/route.ts create mode 100644 src/app/api/admin/traffic-recordings/cleanup/route.ts create mode 100644 src/app/api/admin/traffic-recordings/route.ts create mode 100644 src/hooks/use-traffic-recording.ts create mode 100644 src/lib/services/traffic-recording-background-cleanup.ts create mode 100644 src/lib/services/traffic-recording-service.ts create mode 100644 tests/components/traffic-recording-page.test.tsx create mode 100644 tests/unit/api/admin/traffic-recording-routes.test.ts create mode 100644 tests/unit/services/traffic-recording-service.test.ts diff --git a/drizzle-sqlite/0011_lush_kitty_pryde.sql b/drizzle-sqlite/0011_lush_kitty_pryde.sql new file mode 100644 index 00000000..3c5387c4 --- /dev/null +++ b/drizzle-sqlite/0011_lush_kitty_pryde.sql @@ -0,0 +1,38 @@ +CREATE TABLE `traffic_recording_settings` ( + `id` text PRIMARY KEY DEFAULT 'default' NOT NULL, + `enabled` integer DEFAULT false NOT NULL, + `mode` text DEFAULT 'failure' NOT NULL, + `redact_sensitive` integer DEFAULT true NOT NULL, + `retention_days` integer DEFAULT 7 NOT NULL, + `created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL, + `updated_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL +); +--> statement-breakpoint +CREATE TABLE `traffic_recordings` ( + `id` text PRIMARY KEY NOT NULL, + `request_log_id` text, + `api_key_id` text, + `upstream_id` text, + `method` text, + `path` text, + `model` text, + `status_code` integer, + `outcome` text NOT NULL, + `fixture_path` text NOT NULL, + `fixture_size_bytes` integer DEFAULT 0 NOT NULL, + `request_size_bytes` integer DEFAULT 0 NOT NULL, + `response_size_bytes` integer DEFAULT 0 NOT NULL, + `redacted` integer DEFAULT true NOT NULL, + `created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL, + FOREIGN KEY (`request_log_id`) REFERENCES `request_logs`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`api_key_id`) REFERENCES `api_keys`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`upstream_id`) REFERENCES `upstreams`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `traffic_recordings_fixture_path_unique` ON `traffic_recordings` (`fixture_path`);--> statement-breakpoint +CREATE INDEX `traffic_recordings_request_log_id_idx` ON `traffic_recordings` (`request_log_id`);--> statement-breakpoint +CREATE INDEX `traffic_recordings_api_key_id_idx` ON `traffic_recordings` (`api_key_id`);--> statement-breakpoint +CREATE INDEX `traffic_recordings_upstream_id_idx` ON `traffic_recordings` (`upstream_id`);--> statement-breakpoint +CREATE INDEX `traffic_recordings_status_code_idx` ON `traffic_recordings` (`status_code`);--> statement-breakpoint +CREATE INDEX `traffic_recordings_model_idx` ON `traffic_recordings` (`model`);--> statement-breakpoint +CREATE INDEX `traffic_recordings_created_at_idx` ON `traffic_recordings` (`created_at`); diff --git a/drizzle-sqlite/meta/0011_snapshot.json b/drizzle-sqlite/meta/0011_snapshot.json new file mode 100644 index 00000000..f92884ad --- /dev/null +++ b/drizzle-sqlite/meta/0011_snapshot.json @@ -0,0 +1,2562 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9ccc6c7a-d485-45ec-9c9e-fb786093fefc", + "prevId": "fa855bda-382b-46fb-901e-50117c919c54", + "tables": { + "api_key_upstreams": { + "name": "api_key_upstreams", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "api_key_upstreams_api_key_id_idx": { + "name": "api_key_upstreams_api_key_id_idx", + "columns": ["api_key_id"], + "isUnique": false + }, + "api_key_upstreams_upstream_id_idx": { + "name": "api_key_upstreams_upstream_id_idx", + "columns": ["upstream_id"], + "isUnique": false + }, + "uq_api_key_upstream": { + "name": "uq_api_key_upstream", + "columns": ["api_key_id", "upstream_id"], + "isUnique": true + } + }, + "foreignKeys": { + "api_key_upstreams_api_key_id_api_keys_id_fk": { + "name": "api_key_upstreams_api_key_id_api_keys_id_fk", + "tableFrom": "api_key_upstreams", + "tableTo": "api_keys", + "columnsFrom": ["api_key_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_upstreams_upstream_id_upstreams_id_fk": { + "name": "api_key_upstreams_upstream_id_upstreams_id_fk", + "tableFrom": "api_key_upstreams", + "tableTo": "upstreams", + "columnsFrom": ["upstream_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_value_encrypted": { + "name": "key_value_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_mode": { + "name": "access_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unrestricted'" + }, + "allowed_models": { + "name": "allowed_models", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spending_rules": { + "name": "spending_rules", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": ["key_hash"], + "isUnique": true + }, + "api_keys_key_hash_idx": { + "name": "api_keys_key_hash_idx", + "columns": ["key_hash"], + "isUnique": false + }, + "api_keys_is_active_idx": { + "name": "api_keys_is_active_idx", + "columns": ["is_active"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "background_sync_task_runs": { + "name": "background_sync_task_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_name": { + "name": "task_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "error_summary": { + "name": "error_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "background_sync_task_runs_task_name_idx": { + "name": "background_sync_task_runs_task_name_idx", + "columns": ["task_name"], + "isUnique": false + }, + "background_sync_task_runs_started_at_idx": { + "name": "background_sync_task_runs_started_at_idx", + "columns": ["started_at"], + "isUnique": false + }, + "background_sync_task_runs_status_idx": { + "name": "background_sync_task_runs_status_idx", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "background_sync_tasks": { + "name": "background_sync_tasks", + "columns": { + "task_name": { + "name": "task_name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "startup_delay_seconds": { + "name": "startup_delay_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_started_at": { + "name": "last_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_finished_at": { + "name": "last_finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_duration_ms": { + "name": "last_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_success_count": { + "name": "last_success_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_failure_count": { + "name": "last_failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "background_sync_tasks_enabled_idx": { + "name": "background_sync_tasks_enabled_idx", + "columns": ["enabled"], + "isUnique": false + }, + "background_sync_tasks_next_run_at_idx": { + "name": "background_sync_tasks_next_run_at_idx", + "columns": ["next_run_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "billing_manual_price_overrides": { + "name": "billing_manual_price_overrides", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_price_per_million": { + "name": "input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_price_per_million": { + "name": "output_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cache_read_input_price_per_million": { + "name": "cache_read_input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_input_price_per_million": { + "name": "cache_write_input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "billing_manual_price_overrides_model_unique": { + "name": "billing_manual_price_overrides_model_unique", + "columns": ["model"], + "isUnique": true + }, + "billing_manual_price_overrides_model_idx": { + "name": "billing_manual_price_overrides_model_idx", + "columns": ["model"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "billing_model_prices": { + "name": "billing_model_prices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_price_per_million": { + "name": "input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_price_per_million": { + "name": "output_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cache_read_input_price_per_million": { + "name": "cache_read_input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_input_price_per_million": { + "name": "cache_write_input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_input_tokens": { + "name": "max_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "synced_at": { + "name": "synced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "billing_model_prices_model_idx": { + "name": "billing_model_prices_model_idx", + "columns": ["model"], + "isUnique": false + }, + "billing_model_prices_source_idx": { + "name": "billing_model_prices_source_idx", + "columns": ["source"], + "isUnique": false + }, + "uq_billing_model_prices_model_source": { + "name": "uq_billing_model_prices_model_source", + "columns": ["model", "source"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "billing_price_sync_history": { + "name": "billing_price_sync_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "billing_price_sync_history_created_at_idx": { + "name": "billing_price_sync_history_created_at_idx", + "columns": ["created_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "billing_tier_rules": { + "name": "billing_tier_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "threshold_input_tokens": { + "name": "threshold_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_label": { + "name": "display_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_price_per_million": { + "name": "input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_price_per_million": { + "name": "output_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cache_read_input_price_per_million": { + "name": "cache_read_input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_input_price_per_million": { + "name": "cache_write_input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "billing_tier_rules_model_idx": { + "name": "billing_tier_rules_model_idx", + "columns": ["model"], + "isUnique": false + }, + "billing_tier_rules_source_idx": { + "name": "billing_tier_rules_source_idx", + "columns": ["source"], + "isUnique": false + }, + "uq_billing_tier_rules_model_source_threshold": { + "name": "uq_billing_tier_rules_model_source_threshold", + "columns": ["model", "source", "threshold_input_tokens"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_breaker_states": { + "name": "circuit_breaker_states", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'closed'" + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opened_at": { + "name": "opened_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_probe_at": { + "name": "last_probe_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "circuit_breaker_states_upstream_id_unique": { + "name": "circuit_breaker_states_upstream_id_unique", + "columns": ["upstream_id"], + "isUnique": true + }, + "circuit_breaker_states_upstream_id_idx": { + "name": "circuit_breaker_states_upstream_id_idx", + "columns": ["upstream_id"], + "isUnique": false + }, + "circuit_breaker_states_state_idx": { + "name": "circuit_breaker_states_state_idx", + "columns": ["state"], + "isUnique": false + } + }, + "foreignKeys": { + "circuit_breaker_states_upstream_id_upstreams_id_fk": { + "name": "circuit_breaker_states_upstream_id_upstreams_id_fk", + "tableFrom": "circuit_breaker_states", + "tableTo": "upstreams", + "columnsFrom": ["upstream_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "compensation_rules": { + "name": "compensation_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_builtin": { + "name": "is_builtin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_header": { + "name": "target_header", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sources": { + "name": "sources", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'missing_only'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "compensation_rules_name_unique": { + "name": "compensation_rules_name_unique", + "columns": ["name"], + "isUnique": true + }, + "compensation_rules_enabled_idx": { + "name": "compensation_rules_enabled_idx", + "columns": ["enabled"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "request_billing_snapshots": { + "name": "request_billing_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_log_id": { + "name": "request_log_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unbillable_reason": { + "name": "unbillable_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_source": { + "name": "price_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_input_price_per_million": { + "name": "base_input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_output_price_per_million": { + "name": "base_output_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_cache_read_input_price_per_million": { + "name": "base_cache_read_input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_cache_write_input_price_per_million": { + "name": "base_cache_write_input_price_per_million", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "matched_rule_type": { + "name": "matched_rule_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "matched_rule_display_label": { + "name": "matched_rule_display_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "applied_tier_threshold": { + "name": "applied_tier_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_max_input_tokens": { + "name": "model_max_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_max_output_tokens": { + "name": "model_max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_multiplier": { + "name": "input_multiplier", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_multiplier": { + "name": "output_multiplier", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_read_cost": { + "name": "cache_read_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_cost": { + "name": "cache_write_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "final_cost": { + "name": "final_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "billed_at": { + "name": "billed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "request_billing_snapshots_request_log_id_unique": { + "name": "request_billing_snapshots_request_log_id_unique", + "columns": ["request_log_id"], + "isUnique": true + }, + "request_billing_snapshots_request_log_id_idx": { + "name": "request_billing_snapshots_request_log_id_idx", + "columns": ["request_log_id"], + "isUnique": false + }, + "request_billing_snapshots_billing_status_idx": { + "name": "request_billing_snapshots_billing_status_idx", + "columns": ["billing_status"], + "isUnique": false + }, + "request_billing_snapshots_model_idx": { + "name": "request_billing_snapshots_model_idx", + "columns": ["model"], + "isUnique": false + }, + "request_billing_snapshots_created_at_idx": { + "name": "request_billing_snapshots_created_at_idx", + "columns": ["created_at"], + "isUnique": false + } + }, + "foreignKeys": { + "request_billing_snapshots_request_log_id_request_logs_id_fk": { + "name": "request_billing_snapshots_request_log_id_request_logs_id_fk", + "tableFrom": "request_billing_snapshots", + "tableTo": "request_logs", + "columnsFrom": ["request_log_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "request_billing_snapshots_api_key_id_api_keys_id_fk": { + "name": "request_billing_snapshots_api_key_id_api_keys_id_fk", + "tableFrom": "request_billing_snapshots", + "tableTo": "api_keys", + "columnsFrom": ["api_key_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "request_billing_snapshots_upstream_id_upstreams_id_fk": { + "name": "request_billing_snapshots_upstream_id_upstreams_id_fk", + "tableFrom": "request_billing_snapshots", + "tableTo": "upstreams", + "columnsFrom": ["upstream_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "request_logs": { + "name": "request_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_name": { + "name": "api_key_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_prefix": { + "name": "api_key_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cached_tokens": { + "name": "cached_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_creation_tokens": { + "name": "cache_creation_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_creation_5m_tokens": { + "name": "cache_creation_5m_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_creation_1h_tokens": { + "name": "cache_creation_1h_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "routing_duration_ms": { + "name": "routing_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "routing_type": { + "name": "routing_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lb_strategy": { + "name": "lb_strategy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority_tier": { + "name": "priority_tier", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failover_attempts": { + "name": "failover_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "failover_history": { + "name": "failover_history", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "routing_decision": { + "name": "routing_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thinking_config": { + "name": "thinking_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affinity_hit": { + "name": "affinity_hit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "affinity_migrated": { + "name": "affinity_migrated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ttft_ms": { + "name": "ttft_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_stream": { + "name": "is_stream", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "session_id_compensated": { + "name": "session_id_compensated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "header_diff": { + "name": "header_diff", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "request_logs_api_key_id_idx": { + "name": "request_logs_api_key_id_idx", + "columns": ["api_key_id"], + "isUnique": false + }, + "request_logs_upstream_id_idx": { + "name": "request_logs_upstream_id_idx", + "columns": ["upstream_id"], + "isUnique": false + }, + "request_logs_created_at_idx": { + "name": "request_logs_created_at_idx", + "columns": ["created_at"], + "isUnique": false + }, + "request_logs_routing_type_idx": { + "name": "request_logs_routing_type_idx", + "columns": ["routing_type"], + "isUnique": false + } + }, + "foreignKeys": { + "request_logs_api_key_id_api_keys_id_fk": { + "name": "request_logs_api_key_id_api_keys_id_fk", + "tableFrom": "request_logs", + "tableTo": "api_keys", + "columnsFrom": ["api_key_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "request_logs_upstream_id_upstreams_id_fk": { + "name": "request_logs_upstream_id_upstreams_id_fk", + "tableFrom": "request_logs", + "tableTo": "upstreams", + "columnsFrom": ["upstream_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "traffic_recording_settings": { + "name": "traffic_recording_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'failure'" + }, + "redact_sensitive": { + "name": "redact_sensitive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "retention_days": { + "name": "retention_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 7 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "traffic_recordings": { + "name": "traffic_recordings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_log_id": { + "name": "request_log_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture_path": { + "name": "fixture_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture_size_bytes": { + "name": "fixture_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "request_size_bytes": { + "name": "request_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_size_bytes": { + "name": "response_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "redacted": { + "name": "redacted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "traffic_recordings_fixture_path_unique": { + "name": "traffic_recordings_fixture_path_unique", + "columns": ["fixture_path"], + "isUnique": true + }, + "traffic_recordings_request_log_id_idx": { + "name": "traffic_recordings_request_log_id_idx", + "columns": ["request_log_id"], + "isUnique": false + }, + "traffic_recordings_api_key_id_idx": { + "name": "traffic_recordings_api_key_id_idx", + "columns": ["api_key_id"], + "isUnique": false + }, + "traffic_recordings_upstream_id_idx": { + "name": "traffic_recordings_upstream_id_idx", + "columns": ["upstream_id"], + "isUnique": false + }, + "traffic_recordings_status_code_idx": { + "name": "traffic_recordings_status_code_idx", + "columns": ["status_code"], + "isUnique": false + }, + "traffic_recordings_model_idx": { + "name": "traffic_recordings_model_idx", + "columns": ["model"], + "isUnique": false + }, + "traffic_recordings_created_at_idx": { + "name": "traffic_recordings_created_at_idx", + "columns": ["created_at"], + "isUnique": false + } + }, + "foreignKeys": { + "traffic_recordings_request_log_id_request_logs_id_fk": { + "name": "traffic_recordings_request_log_id_request_logs_id_fk", + "tableFrom": "traffic_recordings", + "tableTo": "request_logs", + "columnsFrom": ["request_log_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "traffic_recordings_api_key_id_api_keys_id_fk": { + "name": "traffic_recordings_api_key_id_api_keys_id_fk", + "tableFrom": "traffic_recordings", + "tableTo": "api_keys", + "columnsFrom": ["api_key_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "traffic_recordings_upstream_id_upstreams_id_fk": { + "name": "traffic_recordings_upstream_id_upstreams_id_fk", + "tableFrom": "traffic_recordings", + "tableTo": "upstreams", + "columnsFrom": ["upstream_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "upstream_failure_rules": { + "name": "upstream_failure_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "match": { + "name": "match", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "upstream_failure_rules_upstream_id_idx": { + "name": "upstream_failure_rules_upstream_id_idx", + "columns": ["upstream_id"], + "isUnique": false + }, + "upstream_failure_rules_enabled_idx": { + "name": "upstream_failure_rules_enabled_idx", + "columns": ["enabled"], + "isUnique": false + }, + "upstream_failure_rules_priority_idx": { + "name": "upstream_failure_rules_priority_idx", + "columns": ["priority"], + "isUnique": false + } + }, + "foreignKeys": { + "upstream_failure_rules_upstream_id_upstreams_id_fk": { + "name": "upstream_failure_rules_upstream_id_upstreams_id_fk", + "tableFrom": "upstream_failure_rules", + "tableTo": "upstreams", + "columnsFrom": ["upstream_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "upstream_health": { + "name": "upstream_health", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_healthy": { + "name": "is_healthy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_check_at": { + "name": "last_check_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "upstream_health_upstream_id_unique": { + "name": "upstream_health_upstream_id_unique", + "columns": ["upstream_id"], + "isUnique": true + }, + "upstream_health_upstream_id_idx": { + "name": "upstream_health_upstream_id_idx", + "columns": ["upstream_id"], + "isUnique": false + }, + "upstream_health_is_healthy_idx": { + "name": "upstream_health_is_healthy_idx", + "columns": ["is_healthy"], + "isUnique": false + } + }, + "foreignKeys": { + "upstream_health_upstream_id_upstreams_id_fk": { + "name": "upstream_health_upstream_id_upstreams_id_fk", + "tableFrom": "upstream_health", + "tableTo": "upstreams", + "columnsFrom": ["upstream_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "upstream_probe_results": { + "name": "upstream_probe_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "route_capability": { + "name": "route_capability", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_profile": { + "name": "client_profile", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "probe_template_id": { + "name": "probe_template_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "probe_kind": { + "name": "probe_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "layer": { + "name": "layer", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_byte_latency_ms": { + "name": "first_byte_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_latency_ms": { + "name": "completed_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_type": { + "name": "error_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "probe_url": { + "name": "probe_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "upstream_probe_results_upstream_id_idx": { + "name": "upstream_probe_results_upstream_id_idx", + "columns": ["upstream_id"], + "isUnique": false + }, + "upstream_probe_results_status_idx": { + "name": "upstream_probe_results_status_idx", + "columns": ["status"], + "isUnique": false + }, + "upstream_probe_results_checked_at_idx": { + "name": "upstream_probe_results_checked_at_idx", + "columns": ["checked_at"], + "isUnique": false + }, + "upstream_probe_results_identity_unique": { + "name": "upstream_probe_results_identity_unique", + "columns": ["upstream_id", "route_capability", "client_profile", "probe_template_id"], + "isUnique": true + } + }, + "foreignKeys": { + "upstream_probe_results_upstream_id_upstreams_id_fk": { + "name": "upstream_probe_results_upstream_id_upstreams_id_fk", + "tableFrom": "upstream_probe_results", + "tableTo": "upstreams", + "columnsFrom": ["upstream_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "upstreams": { + "name": "upstreams", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "official_website_url": { + "name": "official_website_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_encrypted": { + "name": "api_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 60 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "max_concurrency": { + "name": "max_concurrency", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "route_capabilities": { + "name": "route_capabilities", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_discovery": { + "name": "model_discovery", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_catalog": { + "name": "model_catalog", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_catalog_updated_at": { + "name": "model_catalog_updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_catalog_last_status": { + "name": "model_catalog_last_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_catalog_last_error": { + "name": "model_catalog_last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_catalog_last_failed_at": { + "name": "model_catalog_last_failed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_rules": { + "name": "model_rules", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queue_policy": { + "name": "queue_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_rule_config": { + "name": "failure_rule_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affinity_migration": { + "name": "affinity_migration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "billing_input_multiplier": { + "name": "billing_input_multiplier", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "billing_output_multiplier": { + "name": "billing_output_multiplier", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "spending_rules": { + "name": "spending_rules", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "upstreams_name_unique": { + "name": "upstreams_name_unique", + "columns": ["name"], + "isUnique": true + }, + "upstreams_name_idx": { + "name": "upstreams_name_idx", + "columns": ["name"], + "isUnique": false + }, + "upstreams_is_active_idx": { + "name": "upstreams_is_active_idx", + "columns": ["is_active"], + "isUnique": false + }, + "upstreams_priority_idx": { + "name": "upstreams_priority_idx", + "columns": ["priority"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle-sqlite/meta/_journal.json b/drizzle-sqlite/meta/_journal.json index da5993d9..3ac0d582 100644 --- a/drizzle-sqlite/meta/_journal.json +++ b/drizzle-sqlite/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1778922152911, "tag": "0010_confused_mister_fear", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1779007446870, + "tag": "0011_lush_kitty_pryde", + "breakpoints": true } ] } diff --git a/drizzle/0033_shocking_emma_frost.sql b/drizzle/0033_shocking_emma_frost.sql new file mode 100644 index 00000000..810021a0 --- /dev/null +++ b/drizzle/0033_shocking_emma_frost.sql @@ -0,0 +1,38 @@ +CREATE TABLE "traffic_recording_settings" ( + "id" varchar(32) PRIMARY KEY DEFAULT 'default' NOT NULL, + "enabled" boolean DEFAULT false NOT NULL, + "mode" varchar(16) DEFAULT 'failure' NOT NULL, + "redact_sensitive" boolean DEFAULT true NOT NULL, + "retention_days" integer DEFAULT 7 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "traffic_recordings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "request_log_id" uuid, + "api_key_id" uuid, + "upstream_id" uuid, + "method" varchar(10), + "path" text, + "model" varchar(128), + "status_code" integer, + "outcome" varchar(16) NOT NULL, + "fixture_path" text NOT NULL, + "fixture_size_bytes" integer DEFAULT 0 NOT NULL, + "request_size_bytes" integer DEFAULT 0 NOT NULL, + "response_size_bytes" integer DEFAULT 0 NOT NULL, + "redacted" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "traffic_recordings_fixture_path_unique" UNIQUE("fixture_path") +); +--> statement-breakpoint +ALTER TABLE "traffic_recordings" ADD CONSTRAINT "traffic_recordings_request_log_id_request_logs_id_fk" FOREIGN KEY ("request_log_id") REFERENCES "public"."request_logs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "traffic_recordings" ADD CONSTRAINT "traffic_recordings_api_key_id_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "public"."api_keys"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "traffic_recordings" ADD CONSTRAINT "traffic_recordings_upstream_id_upstreams_id_fk" FOREIGN KEY ("upstream_id") REFERENCES "public"."upstreams"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "traffic_recordings_request_log_id_idx" ON "traffic_recordings" USING btree ("request_log_id");--> statement-breakpoint +CREATE INDEX "traffic_recordings_api_key_id_idx" ON "traffic_recordings" USING btree ("api_key_id");--> statement-breakpoint +CREATE INDEX "traffic_recordings_upstream_id_idx" ON "traffic_recordings" USING btree ("upstream_id");--> statement-breakpoint +CREATE INDEX "traffic_recordings_status_code_idx" ON "traffic_recordings" USING btree ("status_code");--> statement-breakpoint +CREATE INDEX "traffic_recordings_model_idx" ON "traffic_recordings" USING btree ("model");--> statement-breakpoint +CREATE INDEX "traffic_recordings_created_at_idx" ON "traffic_recordings" USING btree ("created_at"); diff --git a/drizzle/meta/0033_snapshot.json b/drizzle/meta/0033_snapshot.json new file mode 100644 index 00000000..9f178210 --- /dev/null +++ b/drizzle/meta/0033_snapshot.json @@ -0,0 +1,2898 @@ +{ + "id": "b361c064-84de-492e-99b1-f1d1b8abba4e", + "prevId": "4e845e46-cf79-451c-a09c-d5e28f6fec6c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_key_upstreams": { + "name": "api_key_upstreams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "upstream_id": { + "name": "upstream_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_key_upstreams_api_key_id_idx": { + "name": "api_key_upstreams_api_key_id_idx", + "columns": [ + { + "expression": "api_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_upstreams_upstream_id_idx": { + "name": "api_key_upstreams_upstream_id_idx", + "columns": [ + { + "expression": "upstream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_upstreams_api_key_id_api_keys_id_fk": { + "name": "api_key_upstreams_api_key_id_api_keys_id_fk", + "tableFrom": "api_key_upstreams", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_upstreams_upstream_id_upstreams_id_fk": { + "name": "api_key_upstreams_upstream_id_upstreams_id_fk", + "tableFrom": "api_key_upstreams", + "tableTo": "upstreams", + "columnsFrom": [ + "upstream_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_api_key_upstream": { + "name": "uq_api_key_upstream", + "nullsNotDistinct": false, + "columns": [ + "api_key_id", + "upstream_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key_hash": { + "name": "key_hash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "key_value_encrypted": { + "name": "key_value_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "access_mode": { + "name": "access_mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'unrestricted'" + }, + "allowed_models": { + "name": "allowed_models", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "spending_rules": { + "name": "spending_rules", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_keys_key_hash_idx": { + "name": "api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_is_active_idx": { + "name": "api_keys_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_sync_task_runs": { + "name": "background_sync_task_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_name": { + "name": "task_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "trigger_type": { + "name": "trigger_type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_summary": { + "name": "error_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_sync_task_runs_task_name_idx": { + "name": "background_sync_task_runs_task_name_idx", + "columns": [ + { + "expression": "task_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_sync_task_runs_started_at_idx": { + "name": "background_sync_task_runs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_sync_task_runs_status_idx": { + "name": "background_sync_task_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_sync_tasks": { + "name": "background_sync_tasks", + "schema": "", + "columns": { + "task_name": { + "name": "task_name", + "type": "varchar(128)", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "startup_delay_seconds": { + "name": "startup_delay_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_finished_at": { + "name": "last_finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_status": { + "name": "last_status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_duration_ms": { + "name": "last_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_success_count": { + "name": "last_success_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_failure_count": { + "name": "last_failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_sync_tasks_enabled_idx": { + "name": "background_sync_tasks_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_sync_tasks_next_run_at_idx": { + "name": "background_sync_tasks_next_run_at_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_manual_price_overrides": { + "name": "billing_manual_price_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "input_price_per_million": { + "name": "input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "output_price_per_million": { + "name": "output_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "cache_read_input_price_per_million": { + "name": "cache_read_input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "cache_write_input_price_per_million": { + "name": "cache_write_input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_manual_price_overrides_model_idx": { + "name": "billing_manual_price_overrides_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_manual_price_overrides_model_unique": { + "name": "billing_manual_price_overrides_model_unique", + "nullsNotDistinct": false, + "columns": [ + "model" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_model_prices": { + "name": "billing_model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "input_price_per_million": { + "name": "input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "output_price_per_million": { + "name": "output_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "cache_read_input_price_per_million": { + "name": "cache_read_input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "cache_write_input_price_per_million": { + "name": "cache_write_input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "max_input_tokens": { + "name": "max_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_model_prices_model_idx": { + "name": "billing_model_prices_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_model_prices_source_idx": { + "name": "billing_model_prices_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_billing_model_prices_model_source": { + "name": "uq_billing_model_prices_model_source", + "nullsNotDistinct": false, + "columns": [ + "model", + "source" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_price_sync_history": { + "name": "billing_price_sync_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_price_sync_history_created_at_idx": { + "name": "billing_price_sync_history_created_at_idx", + "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.billing_tier_rules": { + "name": "billing_tier_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "threshold_input_tokens": { + "name": "threshold_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "display_label": { + "name": "display_label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "input_price_per_million": { + "name": "input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "output_price_per_million": { + "name": "output_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "cache_read_input_price_per_million": { + "name": "cache_read_input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "cache_write_input_price_per_million": { + "name": "cache_write_input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_tier_rules_model_idx": { + "name": "billing_tier_rules_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_tier_rules_source_idx": { + "name": "billing_tier_rules_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_billing_tier_rules_model_source_threshold": { + "name": "uq_billing_tier_rules_model_source_threshold", + "nullsNotDistinct": false, + "columns": [ + "model", + "source", + "threshold_input_tokens" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.circuit_breaker_states": { + "name": "circuit_breaker_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "upstream_id": { + "name": "upstream_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'closed'" + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_at": { + "name": "last_probe_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "circuit_breaker_states_upstream_id_idx": { + "name": "circuit_breaker_states_upstream_id_idx", + "columns": [ + { + "expression": "upstream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "circuit_breaker_states_state_idx": { + "name": "circuit_breaker_states_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "circuit_breaker_states_upstream_id_upstreams_id_fk": { + "name": "circuit_breaker_states_upstream_id_upstreams_id_fk", + "tableFrom": "circuit_breaker_states", + "tableTo": "upstreams", + "columnsFrom": [ + "upstream_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "circuit_breaker_states_upstream_id_unique": { + "name": "circuit_breaker_states_upstream_id_unique", + "nullsNotDistinct": false, + "columns": [ + "upstream_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.compensation_rules": { + "name": "compensation_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "is_builtin": { + "name": "is_builtin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "capabilities": { + "name": "capabilities", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "target_header": { + "name": "target_header", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "sources": { + "name": "sources", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'missing_only'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "compensation_rules_enabled_idx": { + "name": "compensation_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "compensation_rules_name_unique": { + "name": "compensation_rules_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_billing_snapshots": { + "name": "request_billing_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_log_id": { + "name": "request_log_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "billing_status": { + "name": "billing_status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "unbillable_reason": { + "name": "unbillable_reason", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "price_source": { + "name": "price_source", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "base_input_price_per_million": { + "name": "base_input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "base_output_price_per_million": { + "name": "base_output_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "base_cache_read_input_price_per_million": { + "name": "base_cache_read_input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "base_cache_write_input_price_per_million": { + "name": "base_cache_write_input_price_per_million", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "matched_rule_type": { + "name": "matched_rule_type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "matched_rule_display_label": { + "name": "matched_rule_display_label", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "applied_tier_threshold": { + "name": "applied_tier_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "model_max_input_tokens": { + "name": "model_max_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "model_max_output_tokens": { + "name": "model_max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "input_multiplier": { + "name": "input_multiplier", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "output_multiplier": { + "name": "output_multiplier", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_cost": { + "name": "cache_read_cost", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "cache_write_cost": { + "name": "cache_write_cost", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "final_cost": { + "name": "final_cost", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billed_at": { + "name": "billed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "request_billing_snapshots_request_log_id_idx": { + "name": "request_billing_snapshots_request_log_id_idx", + "columns": [ + { + "expression": "request_log_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "request_billing_snapshots_billing_status_idx": { + "name": "request_billing_snapshots_billing_status_idx", + "columns": [ + { + "expression": "billing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "request_billing_snapshots_model_idx": { + "name": "request_billing_snapshots_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "request_billing_snapshots_created_at_idx": { + "name": "request_billing_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "request_billing_snapshots_request_log_id_request_logs_id_fk": { + "name": "request_billing_snapshots_request_log_id_request_logs_id_fk", + "tableFrom": "request_billing_snapshots", + "tableTo": "request_logs", + "columnsFrom": [ + "request_log_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "request_billing_snapshots_api_key_id_api_keys_id_fk": { + "name": "request_billing_snapshots_api_key_id_api_keys_id_fk", + "tableFrom": "request_billing_snapshots", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "request_billing_snapshots_upstream_id_upstreams_id_fk": { + "name": "request_billing_snapshots_upstream_id_upstreams_id_fk", + "tableFrom": "request_billing_snapshots", + "tableTo": "upstreams", + "columnsFrom": [ + "upstream_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "request_billing_snapshots_request_log_id_unique": { + "name": "request_billing_snapshots_request_log_id_unique", + "nullsNotDistinct": false, + "columns": [ + "request_log_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_logs": { + "name": "request_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "api_key_name": { + "name": "api_key_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "api_key_prefix": { + "name": "api_key_prefix", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cached_tokens": { + "name": "cached_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_creation_tokens": { + "name": "cache_creation_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_creation_5m_tokens": { + "name": "cache_creation_5m_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_creation_1h_tokens": { + "name": "cache_creation_1h_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "routing_duration_ms": { + "name": "routing_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_type": { + "name": "routing_type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "group_name": { + "name": "group_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "lb_strategy": { + "name": "lb_strategy", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "priority_tier": { + "name": "priority_tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "failover_attempts": { + "name": "failover_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failover_history": { + "name": "failover_history", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_decision": { + "name": "routing_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thinking_config": { + "name": "thinking_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "affinity_hit": { + "name": "affinity_hit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "affinity_migrated": { + "name": "affinity_migrated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ttft_ms": { + "name": "ttft_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_stream": { + "name": "is_stream", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "session_id_compensated": { + "name": "session_id_compensated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "header_diff": { + "name": "header_diff", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "request_logs_api_key_id_idx": { + "name": "request_logs_api_key_id_idx", + "columns": [ + { + "expression": "api_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "request_logs_upstream_id_idx": { + "name": "request_logs_upstream_id_idx", + "columns": [ + { + "expression": "upstream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "request_logs_created_at_idx": { + "name": "request_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "request_logs_routing_type_idx": { + "name": "request_logs_routing_type_idx", + "columns": [ + { + "expression": "routing_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "request_logs_api_key_id_api_keys_id_fk": { + "name": "request_logs_api_key_id_api_keys_id_fk", + "tableFrom": "request_logs", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "request_logs_upstream_id_upstreams_id_fk": { + "name": "request_logs_upstream_id_upstreams_id_fk", + "tableFrom": "request_logs", + "tableTo": "upstreams", + "columnsFrom": [ + "upstream_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.traffic_recording_settings": { + "name": "traffic_recording_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(32)", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "mode": { + "name": "mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'failure'" + }, + "redact_sensitive": { + "name": "redact_sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "retention_days": { + "name": "retention_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 7 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.traffic_recordings": { + "name": "traffic_recordings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "request_log_id": { + "name": "request_log_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "fixture_path": { + "name": "fixture_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fixture_size_bytes": { + "name": "fixture_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_size_bytes": { + "name": "request_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "response_size_bytes": { + "name": "response_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "redacted": { + "name": "redacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "traffic_recordings_request_log_id_idx": { + "name": "traffic_recordings_request_log_id_idx", + "columns": [ + { + "expression": "request_log_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "traffic_recordings_api_key_id_idx": { + "name": "traffic_recordings_api_key_id_idx", + "columns": [ + { + "expression": "api_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "traffic_recordings_upstream_id_idx": { + "name": "traffic_recordings_upstream_id_idx", + "columns": [ + { + "expression": "upstream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "traffic_recordings_status_code_idx": { + "name": "traffic_recordings_status_code_idx", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "traffic_recordings_model_idx": { + "name": "traffic_recordings_model_idx", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "traffic_recordings_created_at_idx": { + "name": "traffic_recordings_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "traffic_recordings_request_log_id_request_logs_id_fk": { + "name": "traffic_recordings_request_log_id_request_logs_id_fk", + "tableFrom": "traffic_recordings", + "tableTo": "request_logs", + "columnsFrom": [ + "request_log_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "traffic_recordings_api_key_id_api_keys_id_fk": { + "name": "traffic_recordings_api_key_id_api_keys_id_fk", + "tableFrom": "traffic_recordings", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "traffic_recordings_upstream_id_upstreams_id_fk": { + "name": "traffic_recordings_upstream_id_upstreams_id_fk", + "tableFrom": "traffic_recordings", + "tableTo": "upstreams", + "columnsFrom": [ + "upstream_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "traffic_recordings_fixture_path_unique": { + "name": "traffic_recordings_fixture_path_unique", + "nullsNotDistinct": false, + "columns": [ + "fixture_path" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upstream_failure_rules": { + "name": "upstream_failure_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "upstream_id": { + "name": "upstream_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "match": { + "name": "match", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upstream_failure_rules_upstream_id_idx": { + "name": "upstream_failure_rules_upstream_id_idx", + "columns": [ + { + "expression": "upstream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upstream_failure_rules_enabled_idx": { + "name": "upstream_failure_rules_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upstream_failure_rules_priority_idx": { + "name": "upstream_failure_rules_priority_idx", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "upstream_failure_rules_upstream_id_upstreams_id_fk": { + "name": "upstream_failure_rules_upstream_id_upstreams_id_fk", + "tableFrom": "upstream_failure_rules", + "tableTo": "upstreams", + "columnsFrom": [ + "upstream_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upstream_health": { + "name": "upstream_health", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "upstream_id": { + "name": "upstream_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_healthy": { + "name": "is_healthy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_check_at": { + "name": "last_check_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "upstream_health_upstream_id_idx": { + "name": "upstream_health_upstream_id_idx", + "columns": [ + { + "expression": "upstream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upstream_health_is_healthy_idx": { + "name": "upstream_health_is_healthy_idx", + "columns": [ + { + "expression": "is_healthy", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "upstream_health_upstream_id_upstreams_id_fk": { + "name": "upstream_health_upstream_id_upstreams_id_fk", + "tableFrom": "upstream_health", + "tableTo": "upstreams", + "columnsFrom": [ + "upstream_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "upstream_health_upstream_id_unique": { + "name": "upstream_health_upstream_id_unique", + "nullsNotDistinct": false, + "columns": [ + "upstream_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upstream_probe_results": { + "name": "upstream_probe_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "upstream_id": { + "name": "upstream_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "route_capability": { + "name": "route_capability", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "client_profile": { + "name": "client_profile", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "probe_template_id": { + "name": "probe_template_id", + "type": "varchar(96)", + "primaryKey": false, + "notNull": true + }, + "probe_kind": { + "name": "probe_kind", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "layer": { + "name": "layer", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "first_byte_latency_ms": { + "name": "first_byte_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_latency_ms": { + "name": "completed_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "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 + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "probe_url": { + "name": "probe_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upstream_probe_results_upstream_id_idx": { + "name": "upstream_probe_results_upstream_id_idx", + "columns": [ + { + "expression": "upstream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upstream_probe_results_status_idx": { + "name": "upstream_probe_results_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upstream_probe_results_checked_at_idx": { + "name": "upstream_probe_results_checked_at_idx", + "columns": [ + { + "expression": "checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "upstream_probe_results_upstream_id_upstreams_id_fk": { + "name": "upstream_probe_results_upstream_id_upstreams_id_fk", + "tableFrom": "upstream_probe_results", + "tableTo": "upstreams", + "columnsFrom": [ + "upstream_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "upstream_probe_results_identity_unique": { + "name": "upstream_probe_results_identity_unique", + "nullsNotDistinct": false, + "columns": [ + "upstream_id", + "route_capability", + "client_profile", + "probe_template_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upstreams": { + "name": "upstreams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "official_website_url": { + "name": "official_website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_encrypted": { + "name": "api_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "max_concurrency": { + "name": "max_concurrency", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "route_capabilities": { + "name": "route_capabilities", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "model_discovery": { + "name": "model_discovery", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "model_catalog": { + "name": "model_catalog", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "model_catalog_updated_at": { + "name": "model_catalog_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "model_catalog_last_status": { + "name": "model_catalog_last_status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "model_catalog_last_error": { + "name": "model_catalog_last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_catalog_last_failed_at": { + "name": "model_catalog_last_failed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "model_rules": { + "name": "model_rules", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "queue_policy": { + "name": "queue_policy", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "failure_rule_config": { + "name": "failure_rule_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "affinity_migration": { + "name": "affinity_migration", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "billing_input_multiplier": { + "name": "billing_input_multiplier", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "billing_output_multiplier": { + "name": "billing_output_multiplier", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "spending_rules": { + "name": "spending_rules", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upstreams_name_idx": { + "name": "upstreams_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upstreams_is_active_idx": { + "name": "upstreams_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upstreams_priority_idx": { + "name": "upstreams_priority_idx", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "upstreams_name_unique": { + "name": "upstreams_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6e4753b8..db6a6479 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -232,6 +232,13 @@ "when": 1778922127202, "tag": "0032_blue_peter_quill", "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1779007329409, + "tag": "0033_shocking_emma_frost", + "breakpoints": true } ] } diff --git a/openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/.openspec.yaml b/openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/.openspec.yaml new file mode 100644 index 00000000..66da1ae9 --- /dev/null +++ b/openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-17 diff --git a/openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/design.md b/openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/design.md new file mode 100644 index 00000000..1d20b3d3 --- /dev/null +++ b/openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/design.md @@ -0,0 +1,85 @@ +## Context + +当前请求录制实现位于 `src/lib/services/traffic-recorder.ts`,代理路由在请求处理时通过 `shouldRecordFixture()` 判断是否录制,并把请求、上游响应、下游响应和故障信息写入 fixture 文件。该判断直接读取 `RECORDER_ENABLED`、`RECORDER_MODE` 和 `RECORDER_REDACT_SENSITIVE`,因此配置变更依赖进程环境,管理端无法实时控制。 + +请求日志页已有 `request_logs` 数据库表和 `/api/admin/logs` 查询能力,但该表只保存统计、路由和计费字段,不保存完整请求与响应正文。现有 `/api/mock/*` 通过 fixture 文件回放最新录制结果,因此录制正文继续保留文件格式更符合当前实现。 + +前端变更属于管理台操作界面,设计依据以现有 Settings、Background Sync 和 Logs 页面为主;`frontend-skill` 只作为界面层级约束:以状态、筛选、详情和操作为主,不引入营销式 hero 或装饰性卡片堆叠。 + +## Goals / Non-Goals + +**Goals:** +- 管理员可以在运行中启停请求录制,并配置录制模式、脱敏策略和保留天数。 +- 录制结果可以按时间、状态、模型、API key 和上游检索,查看详情与删除。 +- 录制内容继续使用 fixture JSON 文件,数据库只保存索引和查询字段。 +- 定时清理任务复用现有后台任务调度、状态与手动执行能力。 + +**Non-Goals:** +- 不改变代理请求的协议行为、路由策略或请求日志统计字段。 +- 不把完整请求与响应正文迁移进 `request_logs`。 +- 不重做现有 `/logs` 请求日志页面。 +- 不改变 `/api/mock/*` 的回放语义。 + +## Decisions + +### 1. 文件保存正文,数据库保存索引 + +录制正文继续写成 fixture 文件,新增 `traffic_recordings` 保存 `request_log_id`、方法、路径、模型、状态码、文件路径、大小、脱敏状态和创建时间。列表查询只读数据库;详情接口读取索引后再加载文件。 + +选择理由:现有录制和 mock 回放已经围绕 fixture 文件建立,文件更适合保存大体积流式响应正文;数据库索引用于分页和筛选,避免列表查询加载大 JSON。 + +替代方案:全部进入数据库。该方案会简化详情读取,但会扩大数据库体积、影响备份和查询性能,并需要重写 fixture 回放读取方式。 + +### 2. 运行时配置使用数据库单例行 + +新增 `traffic_recording_settings` 保存启用状态、录制模式、脱敏开关和保留天数。服务层提供读取、更新与默认值补齐。代理路由在每个请求开始时读取一次配置快照,本次请求后续录制使用同一份配置。 + +选择理由:快照语义清晰,运行中配置变化立即影响新请求,不会改变已经进入处理中的请求行为。 + +替代方案:进程内全局变量。该方案跨进程和重启后不一致,无法满足持久管理需求。 + +### 3. 清理任务复用后台任务框架 + +新增 `traffic_recording_cleanup` 后台任务,默认启用,按配置保留天数删除过期录制文件和索引。系统设置中的后台任务页面可展示任务状态;请求录制页面提供手动清理入口。 + +选择理由:项目已经有后台任务状态表、执行历史、手动执行 API 和设置页入口,复用该能力比新增一套清理调度更一致。 + +### 4. 请求录制管理页面放在系统设置下 + +新增 `/system/traffic-recording` 页面,从设置页进入。页面顶部展示当前状态、模式、占用和保留天数;下方是筛选工具栏、记录表格和详情抽屉或展开区。 + +布局示意: + +```text +Settings + └─ Request Recording + +/system/traffic-recording ++----------------------------------------------------------+ +| 请求录制 [关闭/开启] [模式] [保存] | +| 状态、脱敏、保留天数、占用大小、最近记录时间 | ++----------------------------------------------------------+ +| 时间范围 | 状态码 | 模型搜索 | API key | 上游 | 清理过期 | ++----------------------------------------------------------+ +| 时间 | 状态 | 模型 | 接口 | 大小 | 脱敏 | 操作 | +| ... | ... | ... | ... | ... | ... | 查看/删除 | ++----------------------------------------------------------+ +| 详情:元信息 / 请求 / 响应 / Failover | ++----------------------------------------------------------+ +``` + +视觉层级说明:页面采用现有管理台深色面板、细边框、紧凑表格和单一琥珀色强调。控制区承担状态总览职责,记录区承担查询与操作职责,详情区只在选中记录后显示。 + +## Risks / Trade-offs + +- 录制文件可能在索引存在时被外部删除 → 详情接口返回文件缺失错误,删除操作仍清理索引。 +- 大型流式响应会占用磁盘 → 沿用现有录制大小上限,并在索引中展示文件大小与过期清理入口。 +- 配置读取会发生在代理请求热路径 → 服务层可使用短生命周期内存缓存,更新配置后清除缓存。 +- 明文录制可能保存敏感数据 → 默认脱敏,配置关闭脱敏时页面必须给出明确状态提示。 + +## Migration Plan + +- 新增 PostgreSQL 与 SQLite 表和索引迁移。 +- 首次读取配置时自动创建默认配置行:关闭录制、模式 `failure`、默认脱敏、保留 7 天。 +- `RECORDER_ENABLED`、`RECORDER_MODE`、`RECORDER_REDACT_SENSITIVE` 不再参与运行时判断;`RECORDER_FIXTURES_DIR` 继续作为存储根目录兼容入口。 +- 回滚时删除新增 API、页面、服务和迁移;已写入 fixture 文件可作为普通文件保留或手动删除。 diff --git a/openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/proposal.md b/openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/proposal.md new file mode 100644 index 00000000..f831b6de --- /dev/null +++ b/openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/proposal.md @@ -0,0 +1,26 @@ +## Why + +请求录制目前由 `RECORDER_*` 环境变量控制,运行中无法从管理端启停、查看占用或清理记录。Issue #160 要求把录制从部署模式提升为可实时管理的功能,并让管理员能够查看录制详情、按条件检索和删除记录。 + +## What Changes + +- 新增请求录制运行时配置,管理员可在系统设置中启停录制、选择录制模式、配置脱敏策略和保留天数。 +- 保留现有 fixture 文件录制格式,用数据库索引记录文件位置、大小、请求元信息和查询字段。 +- 新增录制记录管理 API 与页面,支持分页、时间范围、状态码、模型等条件查询,支持查看详情和删除单条记录。 +- 新增录制清理后台任务,按保留天数删除过期索引与文件,并支持手动执行。 +- 移除 `RECORDER_ENABLED`、`RECORDER_MODE`、`RECORDER_REDACT_SENSITIVE` 对运行时录制决策的直接控制;`RECORDER_FIXTURES_DIR` 仅作为存储根目录兼容配置保留。 + +## Capabilities + +### New Capabilities +- `traffic-recording-runtime-control`: 管理端实时控制请求录制、查询录制索引、读取录制详情与删除录制记录。 + +### Modified Capabilities +- `background-sync-tasks`: 新增请求录制清理任务,纳入现有后台任务状态、手动执行与调度能力。 + +## Impact + +- 数据库:新增录制配置表与录制索引表,覆盖 PostgreSQL 与 SQLite schema/migration。 +- 后端:调整 `traffic-recorder`、代理路由录制判断、管理端录制 API、后台任务注册与清理服务。 +- 前端:系统设置页新增请求录制入口,新建请求录制管理页面与相关 hooks/i18n。 +- 测试:覆盖服务、API、代理录制分支、后台清理任务和管理端页面。 diff --git a/openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/specs/background-sync-tasks/spec.md b/openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/specs/background-sync-tasks/spec.md new file mode 100644 index 00000000..05091f45 --- /dev/null +++ b/openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/specs/background-sync-tasks/spec.md @@ -0,0 +1,19 @@ +## ADDED Requirements + +### Requirement: 系统必须提供请求录制清理后台任务 +系统 MUST 将请求录制清理注册为后台同步任务,使过期录制文件和索引能够按配置自动清理,并能由管理员手动立即执行。 + +#### Scenario: 注册请求录制清理任务 +- **WHEN** 应用注册后台同步任务定义 +- **THEN** 系统 SHALL 注册任务名为 `traffic_recording_cleanup` 的后台任务 +- **AND** 该任务 SHALL 出现在后台任务状态列表中 + +#### Scenario: 自动清理过期录制 +- **WHEN** 请求录制清理任务执行 +- **THEN** 系统 SHALL 根据请求录制配置中的保留天数删除过期录制索引和对应 fixture 文件 +- **AND** 任务结果 SHALL 记录删除数量和失败数量 + +#### Scenario: 手动执行清理任务 +- **WHEN** 管理员请求立即执行 `traffic_recording_cleanup` +- **THEN** 系统 SHALL 立即执行过期录制清理 +- **AND** API SHALL 返回本次执行结果或当前运行状态 diff --git a/openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/specs/traffic-recording-runtime-control/spec.md b/openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/specs/traffic-recording-runtime-control/spec.md new file mode 100644 index 00000000..b2deca08 --- /dev/null +++ b/openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/specs/traffic-recording-runtime-control/spec.md @@ -0,0 +1,104 @@ +## ADDED Requirements + +### Requirement: 请求录制配置必须可运行时管理 +系统 MUST 提供受管理员认证保护的请求录制配置能力,使管理员可以在不重启服务的情况下启停录制、选择录制模式、配置脱敏策略和保留天数。 + +#### Scenario: 查询当前录制配置 +- **WHEN** 管理员请求录制配置 +- **THEN** 系统 SHALL 返回启用状态、录制模式、脱敏状态、保留天数和更新时间 + +#### Scenario: 更新录制配置 +- **WHEN** 管理员更新录制配置 +- **THEN** 系统 SHALL 持久化新的配置 +- **AND** 后续新请求 SHALL 使用新的录制配置 + +#### Scenario: 缺少管理员认证 +- **WHEN** 请求缺少有效管理员认证 +- **THEN** 系统 SHALL 拒绝读取或修改录制配置 + +### Requirement: 请求录制必须使用文件正文和数据库索引 +系统 MUST 继续将请求录制正文保存为 fixture 文件,并为每条录制记录保存数据库索引,用于列表查询、详情读取、占用统计和删除操作。 + +#### Scenario: 成功写入录制记录 +- **WHEN** 某次代理请求满足当前录制配置 +- **THEN** 系统 SHALL 写入 fixture 文件 +- **AND** 系统 SHALL 创建包含文件路径、文件大小、请求日志 ID、模型、路径、状态码、脱敏状态和创建时间的录制索引 + +#### Scenario: 录制关闭 +- **WHEN** 当前录制配置为关闭 +- **THEN** 新的代理请求 SHALL 不读取请求体用于录制 +- **AND** 系统 SHALL 不写入新的录制文件或录制索引 + +#### Scenario: 按录制模式过滤 +- **WHEN** 当前录制模式为 `success` +- **THEN** 系统 SHALL 只录制成功请求 +- **WHEN** 当前录制模式为 `failure` +- **THEN** 系统 SHALL 只录制失败请求 +- **WHEN** 当前录制模式为 `all` +- **THEN** 系统 SHALL 录制成功和失败请求 + +### Requirement: 请求录制内容必须默认脱敏 +系统 MUST 默认对录制内容中的敏感请求头、响应头和故障详情进行脱敏,并允许管理员显式关闭脱敏。 + +#### Scenario: 默认脱敏写入 +- **WHEN** 管理员未修改脱敏配置 +- **THEN** 系统 SHALL 在录制内容中隐藏认证头、Cookie 和会话相关敏感字段 + +#### Scenario: 管理员关闭脱敏 +- **WHEN** 管理员将脱敏配置关闭 +- **THEN** 后续新录制 SHALL 按配置保留原始内容 +- **AND** 录制索引 SHALL 标记该记录未脱敏 + +### Requirement: 管理员必须能查询和查看请求录制记录 +系统 MUST 提供录制记录查询和详情读取能力,支持管理员按常用条件查找录制记录并查看对应 fixture 内容。 + +#### Scenario: 分页查询录制记录 +- **WHEN** 管理员请求录制记录列表 +- **THEN** 系统 SHALL 返回按创建时间倒序排列的分页结果 +- **AND** 响应 SHALL 包含总数、当前页和总页数 + +#### Scenario: 按条件筛选录制记录 +- **WHEN** 管理员提供时间范围、状态码、模型、API key 或上游筛选条件 +- **THEN** 系统 SHALL 只返回匹配条件的录制记录 + +#### Scenario: 读取录制详情 +- **WHEN** 管理员请求某条录制记录详情 +- **THEN** 系统 SHALL 返回录制索引元信息和 fixture 内容 + +#### Scenario: 录制文件缺失 +- **WHEN** 录制索引存在但对应 fixture 文件无法读取 +- **THEN** 详情接口 SHALL 返回可解释的文件缺失错误 + +### Requirement: 管理员必须能删除请求录制记录 +系统 MUST 提供单条录制记录删除能力,同时清理数据库索引和对应 fixture 文件。 + +#### Scenario: 删除存在的录制记录 +- **WHEN** 管理员删除某条录制记录 +- **THEN** 系统 SHALL 删除对应 fixture 文件 +- **AND** 系统 SHALL 删除数据库索引 + +#### Scenario: 删除文件已缺失的录制记录 +- **WHEN** 管理员删除某条录制记录但对应文件已经不存在 +- **THEN** 系统 SHALL 删除数据库索引 +- **AND** 删除请求 SHALL 成功完成 + +### Requirement: 管理端必须提供请求录制管理页面 +管理端 MUST 在系统设置区域提供请求录制管理页面,使管理员可以查看状态、修改配置、筛选记录、查看详情和删除记录。 + +#### Scenario: 从设置页进入请求录制页面 +- **WHEN** 管理员打开系统设置页 +- **THEN** 页面 SHALL 展示请求录制入口 +- **AND** 入口 SHALL 导航到请求录制管理页面 + +#### Scenario: 查看录制状态与占用 +- **WHEN** 管理员打开请求录制管理页面 +- **THEN** 页面 SHALL 展示当前启用状态、录制模式、脱敏状态、保留天数、记录数量和磁盘占用 + +#### Scenario: 修改录制配置 +- **WHEN** 管理员在页面中修改录制配置并保存 +- **THEN** 页面 SHALL 调用配置更新 API +- **AND** 保存成功后 SHALL 刷新当前配置 + +#### Scenario: 查看和删除记录 +- **WHEN** 管理员在录制记录列表中操作某条记录 +- **THEN** 页面 SHALL 支持查看详情和删除该记录 diff --git a/openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/tasks.md b/openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/tasks.md new file mode 100644 index 00000000..7e97cf94 --- /dev/null +++ b/openspec/changes/archive/2026-05-18-traffic-recording-runtime-control/tasks.md @@ -0,0 +1,21 @@ +## 1. 数据模型与服务 + +- [x] 1.1 新增 PostgreSQL 与 SQLite 录制配置表、录制索引表、导出类型和迁移文件,验收标准为 schema 一致性检查可识别新增结构。 +- [x] 1.2 新增请求录制服务,提供配置读取更新、录制索引创建、列表查询、详情读取、删除、统计和过期清理,验收标准为服务单元测试覆盖默认配置、筛选、详情缺失和删除。 + +## 2. 后端 API 与代理接入 + +- [x] 2.1 新增管理端请求录制配置、列表、详情、删除和清理 API,验收标准为 API 测试覆盖鉴权、参数校验和核心成功路径。 +- [x] 2.2 改造代理录制判断与 fixture 写入逻辑,使用运行时配置快照并写入索引,验收标准为代理测试覆盖关闭、成功模式、失败模式和脱敏状态。 +- [x] 2.3 注册请求录制清理后台任务,验收标准为任务列表包含 `traffic_recording_cleanup`,手动执行会删除过期记录。 + +## 3. 管理端页面 + +- [x] 3.1 在系统设置页新增请求录制入口,验收标准为设置页组件测试能找到入口并导航到 `/system/traffic-recording`。 +- [x] 3.2 新增请求录制管理页面和 hooks,包含配置表单、状态统计、筛选列表、详情查看、删除和手动清理,验收标准为组件测试覆盖主要操作。 +- [x] 3.3 补齐英文与中文翻译,验收标准为页面不出现缺失翻译键。 + +## 4. 校验与交接 + +- [x] 4.1 运行新增与受影响测试,修复直接相关失败。 +- [x] 4.2 运行 `pnpm exec tsc --noEmit --pretty false`、`pnpm format:check`、`pnpm lint` 和 `git diff --check`,整理剩余限制。 diff --git a/openspec/specs/background-sync-tasks/spec.md b/openspec/specs/background-sync-tasks/spec.md index 3f3a3788..ac2553d5 100644 --- a/openspec/specs/background-sync-tasks/spec.md +++ b/openspec/specs/background-sync-tasks/spec.md @@ -114,3 +114,24 @@ TBD - created by archiving change background-catalog-sync. Update Purpose after - **WHEN** 管理员在页面中手动执行某个后台同步任务且执行完成 - **THEN** 页面 MUST 刷新任务状态与相关业务数据 +### Requirement: 系统必须提供请求录制清理后台任务 + +系统 MUST 将请求录制清理注册为后台同步任务,使过期录制文件和索引能够按配置自动清理,并能由管理员手动立即执行。 + +#### Scenario: 注册请求录制清理任务 + +- **WHEN** 应用注册后台同步任务定义 +- **THEN** 系统 SHALL 注册任务名为 `traffic_recording_cleanup` 的后台任务 +- **AND** 该任务 SHALL 出现在后台任务状态列表中 + +#### Scenario: 自动清理过期录制 + +- **WHEN** 请求录制清理任务执行 +- **THEN** 系统 SHALL 根据请求录制配置中的保留天数删除过期录制索引和对应 fixture 文件 +- **AND** 任务结果 SHALL 记录删除数量和失败数量 + +#### Scenario: 手动执行清理任务 + +- **WHEN** 管理员请求立即执行 `traffic_recording_cleanup` +- **THEN** 系统 SHALL 立即执行过期录制清理 +- **AND** API SHALL 返回本次执行结果或当前运行状态 diff --git a/openspec/specs/traffic-recording-runtime-control/spec.md b/openspec/specs/traffic-recording-runtime-control/spec.md new file mode 100644 index 00000000..895fcfcf --- /dev/null +++ b/openspec/specs/traffic-recording-runtime-control/spec.md @@ -0,0 +1,108 @@ +# traffic-recording-runtime-control Specification + +## Purpose +Define runtime administration for proxy request recording, including recording settings, indexed fixture storage, query and detail inspection, deletion, and the management UI surface. + +## Requirements +### Requirement: 请求录制配置必须可运行时管理 +系统 MUST 提供受管理员认证保护的请求录制配置能力,使管理员可以在不重启服务的情况下启停录制、选择录制模式、配置脱敏策略和保留天数。 + +#### Scenario: 查询当前录制配置 +- **WHEN** 管理员请求录制配置 +- **THEN** 系统 SHALL 返回启用状态、录制模式、脱敏状态、保留天数和更新时间 + +#### Scenario: 更新录制配置 +- **WHEN** 管理员更新录制配置 +- **THEN** 系统 SHALL 持久化新的配置 +- **AND** 后续新请求 SHALL 使用新的录制配置 + +#### Scenario: 缺少管理员认证 +- **WHEN** 请求缺少有效管理员认证 +- **THEN** 系统 SHALL 拒绝读取或修改录制配置 + +### Requirement: 请求录制必须使用文件正文和数据库索引 +系统 MUST 继续将请求录制正文保存为 fixture 文件,并为每条录制记录保存数据库索引,用于列表查询、详情读取、占用统计和删除操作。 + +#### Scenario: 成功写入录制记录 +- **WHEN** 某次代理请求满足当前录制配置 +- **THEN** 系统 SHALL 写入 fixture 文件 +- **AND** 系统 SHALL 创建包含文件路径、文件大小、请求日志 ID、模型、路径、状态码、脱敏状态和创建时间的录制索引 + +#### Scenario: 录制关闭 +- **WHEN** 当前录制配置为关闭 +- **THEN** 新的代理请求 SHALL 不读取请求体用于录制 +- **AND** 系统 SHALL 不写入新的录制文件或录制索引 + +#### Scenario: 按录制模式过滤 +- **WHEN** 当前录制模式为 `success` +- **THEN** 系统 SHALL 只录制成功请求 +- **WHEN** 当前录制模式为 `failure` +- **THEN** 系统 SHALL 只录制失败请求 +- **WHEN** 当前录制模式为 `all` +- **THEN** 系统 SHALL 录制成功和失败请求 + +### Requirement: 请求录制内容必须默认脱敏 +系统 MUST 默认对录制内容中的敏感请求头、响应头和故障详情进行脱敏,并允许管理员显式关闭脱敏。 + +#### Scenario: 默认脱敏写入 +- **WHEN** 管理员未修改脱敏配置 +- **THEN** 系统 SHALL 在录制内容中隐藏认证头、Cookie 和会话相关敏感字段 + +#### Scenario: 管理员关闭脱敏 +- **WHEN** 管理员将脱敏配置关闭 +- **THEN** 后续新录制 SHALL 按配置保留原始内容 +- **AND** 录制索引 SHALL 标记该记录未脱敏 + +### Requirement: 管理员必须能查询和查看请求录制记录 +系统 MUST 提供录制记录查询和详情读取能力,支持管理员按常用条件查找录制记录并查看对应 fixture 内容。 + +#### Scenario: 分页查询录制记录 +- **WHEN** 管理员请求录制记录列表 +- **THEN** 系统 SHALL 返回按创建时间倒序排列的分页结果 +- **AND** 响应 SHALL 包含总数、当前页和总页数 + +#### Scenario: 按条件筛选录制记录 +- **WHEN** 管理员提供时间范围、状态码、模型、API key 或上游筛选条件 +- **THEN** 系统 SHALL 只返回匹配条件的录制记录 + +#### Scenario: 读取录制详情 +- **WHEN** 管理员请求某条录制记录详情 +- **THEN** 系统 SHALL 返回录制索引元信息和 fixture 内容 + +#### Scenario: 录制文件缺失 +- **WHEN** 录制索引存在但对应 fixture 文件无法读取 +- **THEN** 详情接口 SHALL 返回可解释的文件缺失错误 + +### Requirement: 管理员必须能删除请求录制记录 +系统 MUST 提供单条录制记录删除能力,同时清理数据库索引和对应 fixture 文件。 + +#### Scenario: 删除存在的录制记录 +- **WHEN** 管理员删除某条录制记录 +- **THEN** 系统 SHALL 删除对应 fixture 文件 +- **AND** 系统 SHALL 删除数据库索引 + +#### Scenario: 删除文件已缺失的录制记录 +- **WHEN** 管理员删除某条录制记录但对应文件已经不存在 +- **THEN** 系统 SHALL 删除数据库索引 +- **AND** 删除请求 SHALL 成功完成 + +### Requirement: 管理端必须提供请求录制管理页面 +管理端 MUST 在系统设置区域提供请求录制管理页面,使管理员可以查看状态、修改配置、筛选记录、查看详情和删除记录。 + +#### Scenario: 从设置页进入请求录制页面 +- **WHEN** 管理员打开系统设置页 +- **THEN** 页面 SHALL 展示请求录制入口 +- **AND** 入口 SHALL 导航到请求录制管理页面 + +#### Scenario: 查看录制状态与占用 +- **WHEN** 管理员打开请求录制管理页面 +- **THEN** 页面 SHALL 展示当前启用状态、录制模式、脱敏状态、保留天数、记录数量和磁盘占用 + +#### Scenario: 修改录制配置 +- **WHEN** 管理员在页面中修改录制配置并保存 +- **THEN** 页面 SHALL 调用配置更新 API +- **AND** 保存成功后 SHALL 刷新当前配置 + +#### Scenario: 查看和删除记录 +- **WHEN** 管理员在录制记录列表中操作某条记录 +- **THEN** 页面 SHALL 支持查看详情和删除该记录 diff --git a/src/app/[locale]/(dashboard)/settings/page.tsx b/src/app/[locale]/(dashboard)/settings/page.tsx index 57ea1ca3..710f50e4 100644 --- a/src/app/[locale]/(dashboard)/settings/page.tsx +++ b/src/app/[locale]/(dashboard)/settings/page.tsx @@ -3,6 +3,7 @@ import { ArrowUpRight, ArrowLeftRight, + DatabaseZap, Globe, Github, LogOut, @@ -33,6 +34,7 @@ export default function SettingsPage() { const tCompensation = useTranslations("compensation"); const tBilling = useTranslations("billing"); const tBackgroundSync = useTranslations("backgroundSync"); + const tTrafficRecording = useTranslations("trafficRecording"); const tRepository = useTranslations("repository"); const tFailureRules = useTranslations("upstreamFailureRules"); @@ -64,6 +66,12 @@ export default function SettingsPage() { title: tBackgroundSync("title"), description: tBackgroundSync("panelDescription"), }, + { + href: "/system/traffic-recording", + icon: DatabaseZap, + title: tTrafficRecording("title"), + description: tTrafficRecording("settingsDescription"), + }, { href: "/system/failure-rules", icon: ShieldAlert, diff --git a/src/app/[locale]/(dashboard)/system/traffic-recording/page.tsx b/src/app/[locale]/(dashboard)/system/traffic-recording/page.tsx new file mode 100644 index 00000000..7d9d5e62 --- /dev/null +++ b/src/app/[locale]/(dashboard)/system/traffic-recording/page.tsx @@ -0,0 +1,765 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { + Check, + ChevronDown, + ChevronRight, + Copy, + DatabaseZap, + FileJson, + Loader2, + Save, + Search, + Trash2, +} from "lucide-react"; +import { toast } from "sonner"; +import { Topbar } from "@/components/admin/topbar"; +import { PaginationControls } from "@/components/admin/pagination-controls"; +import { + TimeRangeSelector, + type TimeRangeOrCustom, +} from "@/components/dashboard/time-range-selector"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; +import { + useCleanupTrafficRecordings, + useDeleteTrafficRecording, + useTrafficRecordingDetail, + useTrafficRecordingSettings, + useTrafficRecordings, + useUpdateTrafficRecordingSettings, +} from "@/hooks/use-traffic-recording"; +import type { CustomDateRange } from "@/hooks/use-dashboard-stats"; +import type { TrafficRecordingMode, TrafficRecordingResponse } from "@/types/api"; + +const PAGE_SIZE = 20; + +interface SettingsDraft { + enabled: boolean; + mode: TrafficRecordingMode; + redactSensitive: boolean; + retentionDays: string; +} + +function formatBytes(value: number): string { + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`; + return `${(value / 1024 / 1024).toFixed(1)} MiB`; +} + +function useDateFormatter() { + const locale = useLocale(); + return (value: string | null) => { + if (!value) return "-"; + return new Intl.DateTimeFormat(locale, { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); + }; +} + +function getStatusVariant(statusCode: number | null): "success" | "warning" | "error" | "neutral" { + if (statusCode == null) return "neutral"; + if (statusCode >= 200 && statusCode < 300) return "success"; + if (statusCode >= 500) return "error"; + if (statusCode >= 400) return "warning"; + return "neutral"; +} + +function getTimeRangeFilters( + value: TimeRangeOrCustom, + customRange?: CustomDateRange +): { start_time?: string; end_time?: string } { + const now = new Date(); + + if (value === "today") { + const start = new Date(now); + start.setHours(0, 0, 0, 0); + return { start_time: start.toISOString() }; + } + + if (value === "7d" || value === "30d") { + const start = new Date(now); + start.setDate(start.getDate() - (value === "7d" ? 7 : 30)); + return { start_time: start.toISOString() }; + } + + if (customRange) { + return { + start_time: customRange.start.toISOString(), + end_time: customRange.end.toISOString(), + }; + } + + return {}; +} + +function isJsonBranch(value: unknown): value is Record | unknown[] { + return typeof value === "object" && value !== null; +} + +function getJsonBranchEntries(value: Record | unknown[]) { + return Array.isArray(value) + ? value.map((entry, index) => [String(index), entry] as const) + : Object.entries(value); +} + +function getJsonBranchSummary(value: Record | unknown[]) { + const count = getJsonBranchEntries(value).length; + return Array.isArray(value) ? `Array(${count})` : `Object(${count})`; +} + +function collectExpandedJsonPaths(value: unknown, maxDepth = Number.POSITIVE_INFINITY) { + const paths = new Set(); + + const visit = (currentValue: unknown, path: string, depth: number) => { + if (!isJsonBranch(currentValue) || depth > maxDepth) { + return; + } + + paths.add(path); + for (const [key, childValue] of getJsonBranchEntries(currentValue)) { + visit(childValue, `${path}.${key}`, depth + 1); + } + }; + + visit(value, "$", 0); + return paths; +} + +function JsonPrimitiveValue({ value }: { value: unknown }) { + if (typeof value === "string") { + return {JSON.stringify(value)}; + } + + if (typeof value === "number") { + return {value}; + } + + if (typeof value === "boolean") { + return {String(value)}; + } + + if (value == null) { + return null; + } + + return {JSON.stringify(value)}; +} + +function JsonTreeNode({ + label, + value, + path, + depth, + expandedPaths, + onToggle, + expandLabel, + collapseLabel, +}: { + label: string | null; + value: unknown; + path: string; + depth: number; + expandedPaths: Set; + onToggle: (path: string) => void; + expandLabel: string; + collapseLabel: string; +}) { + const isBranch = isJsonBranch(value); + + if (!isBranch) { + return ( +
+ + {label != null ? ( + {JSON.stringify(label)}: + ) : null} + +
+ ); + } + + const entries = getJsonBranchEntries(value); + const isExpanded = expandedPaths.has(path); + const branchLabel = label ?? "root"; + const openToken = Array.isArray(value) ? "[" : "{"; + const closeToken = Array.isArray(value) ? "]" : "}"; + + return ( +
0 && "border-l border-divider/55 pl-3")}> +
+ + {label != null ? ( + {JSON.stringify(label)}: + ) : null} + {openToken} + {!isExpanded ? ( + <> + {getJsonBranchSummary(value)} + {closeToken} + + ) : null} +
+ + {isExpanded ? ( + <> + {entries.length > 0 ? ( +
+ {entries.map(([entryLabel, entryValue]) => ( + + ))} +
+ ) : null} +
+ + {closeToken} +
+ + ) : null} +
+ ); +} + +function RecordingJsonBlock({ value }: { value: unknown }) { + const tCommon = useTranslations("common"); + const jsonText = useMemo(() => JSON.stringify(value, null, 2), [value]); + const [expandedPaths, setExpandedPaths] = useState(() => collectExpandedJsonPaths(value, 1)); + const [copied, setCopied] = useState(false); + + const handleToggle = (path: string) => { + setExpandedPaths((current) => { + const next = new Set(current); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(jsonText); + setCopied(true); + toast.success(tCommon("copied")); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error(tCommon("error")); + } + }; + + return ( +
+
+
+
+
+ + + +
+
+
+ +
+
+ ); +} + +export default function TrafficRecordingPage() { + const t = useTranslations("trafficRecording"); + const tCommon = useTranslations("common"); + const formatDate = useDateFormatter(); + const [page, setPage] = useState(1); + const [statusFilter, setStatusFilter] = useState("all"); + const [modelFilter, setModelFilter] = useState(""); + const [apiKeyFilter, setApiKeyFilter] = useState(""); + const [upstreamFilter, setUpstreamFilter] = useState(""); + const [timeRangeFilter, setTimeRangeFilter] = useState("30d"); + const [customTimeRange, setCustomTimeRange] = useState(); + const [selectedId, setSelectedId] = useState(null); + const [confirmingDeleteId, setConfirmingDeleteId] = useState(null); + const settings = useTrafficRecordingSettings(); + const updateSettings = useUpdateTrafficRecordingSettings(); + const deleteRecording = useDeleteTrafficRecording(); + const cleanupRecordings = useCleanupTrafficRecordings(); + + const [draft, setDraft] = useState(null); + + const filters = useMemo(() => { + const timeRangeFilters = getTimeRangeFilters(timeRangeFilter, customTimeRange); + + return { + ...(statusFilter === "all" ? {} : { status_code: Number(statusFilter) }), + ...(modelFilter.trim() ? { model: modelFilter.trim() } : {}), + ...(apiKeyFilter.trim() ? { api_key_id: apiKeyFilter.trim() } : {}), + ...(upstreamFilter.trim() ? { upstream_id: upstreamFilter.trim() } : {}), + ...timeRangeFilters, + }; + }, [apiKeyFilter, customTimeRange, modelFilter, statusFilter, timeRangeFilter, upstreamFilter]); + + const recordings = useTrafficRecordings(page, PAGE_SIZE, filters); + const detail = useTrafficRecordingDetail(selectedId); + const rows = recordings.data?.items ?? []; + const currentSettings = settings.data; + const baseSettings: SettingsDraft = { + enabled: currentSettings?.enabled ?? false, + mode: currentSettings?.mode ?? "failure", + redactSensitive: currentSettings?.redact_sensitive ?? true, + retentionDays: String(currentSettings?.retention_days ?? 7), + }; + const formSettings = draft ?? baseSettings; + const retentionDaysValue = Number(formSettings.retentionDays); + const canSave = + Boolean(currentSettings) && + Number.isInteger(retentionDaysValue) && + retentionDaysValue > 0 && + currentSettings != null && + (formSettings.enabled !== currentSettings.enabled || + formSettings.mode !== currentSettings.mode || + formSettings.redactSensitive !== currentSettings.redact_sensitive || + retentionDaysValue !== currentSettings.retention_days); + + const updateDraft = (patch: Partial) => { + setDraft((current) => ({ ...baseSettings, ...current, ...patch })); + }; + + const handleSave = () => { + updateSettings.mutate({ + enabled: formSettings.enabled, + mode: formSettings.mode, + redact_sensitive: formSettings.redactSensitive, + retention_days: retentionDaysValue, + }); + }; + + const handleSelect = (recording: TrafficRecordingResponse) => { + setSelectedId((current) => (current === recording.id ? null : recording.id)); + }; + + const handleTimeRangeChange = (value: TimeRangeOrCustom, range?: CustomDateRange) => { + setTimeRangeFilter(value); + setCustomTimeRange(range); + setPage(1); + }; + + const handleConfirmDelete = (recordingId: string) => { + deleteRecording.mutate(recordingId); + setConfirmingDeleteId(null); + setSelectedId((current) => (current === recordingId ? null : current)); + }; + + return ( + <> + + +
+ + +
+
+
+
+

+ {t("description")} +

+
+
+ + {formSettings.enabled ? t("enabled") : t("disabled")} + + + {formSettings.redactSensitive ? t("redacted") : t("notRedacted")} + +
+
+ +
+
+

{t("recordCount")}

+

{recordings.data?.stats.total ?? 0}

+
+
+

{t("diskUsage")}

+

+ {formatBytes(recordings.data?.stats.total_size_bytes ?? 0)} +

+
+
+

{t("mode")}

+

{t(`mode_${settings.data?.mode ?? "failure"}`)}

+
+
+

{t("latestRecord")}

+

+ {formatDate(recordings.data?.stats.latest_created_at ?? null)} +

+
+
+ +
+ + + + + + + + + +
+
+
+ + + +
+
+
+ +
+ +
+ + { + setModelFilter(event.target.value); + setPage(1); + }} + placeholder={t("modelSearchPlaceholder")} + aria-label={t("modelSearchPlaceholder")} + /> + { + setApiKeyFilter(event.target.value); + setPage(1); + }} + placeholder={t("apiKeyFilterPlaceholder")} + aria-label={t("apiKeyFilterPlaceholder")} + /> + { + setUpstreamFilter(event.target.value); + setPage(1); + }} + placeholder={t("upstreamFilterPlaceholder")} + aria-label={t("upstreamFilterPlaceholder")} + /> +
+ +
+
+ + {recordings.isLoading ? ( +
+ + {t("loading")} +
+ ) : rows.length === 0 ? ( +
{t("empty")}
+ ) : ( + + + + {t("tableTime")} + {t("tableStatus")} + {t("tableModel")} + {t("tablePath")} + {t("tableSize")} + {t("tableRedaction")} + {t("tableActions")} + + + + {rows.map((recording) => ( + + + {formatDate(recording.created_at)} + + + + {recording.status_code ?? recording.outcome} + + + + {recording.model ?? "-"} + + + {recording.method ?? "-"} {recording.path ?? ""} + + + {formatBytes(recording.fixture_size_bytes)} + + + + {recording.redacted ? t("redacted") : t("notRedacted")} + + + +
+ + {confirmingDeleteId === recording.id ? ( +
+ + +
+ ) : ( + + )} +
+
+
+ ))} +
+
+ )} + + {recordings.data && recordings.data.total_pages > 1 ? ( + + ) : null} +
+
+ + {selectedId ? ( + + +
+
+ {detail.isLoading ? ( +
+ + {t("loadingDetail")} +
+ ) : detail.isError ? ( +

{t("detailLoadFailed")}

+ ) : ( + + )} +
+
+ ) : null} +
+ + ); +} diff --git a/src/app/api/admin/traffic-recording/settings/route.ts b/src/app/api/admin/traffic-recording/settings/route.ts new file mode 100644 index 00000000..6bbfdf5b --- /dev/null +++ b/src/app/api/admin/traffic-recording/settings/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { + getTrafficRecordingSettings, + updateTrafficRecordingSettings, +} from "@/lib/services/traffic-recording-service"; +import { errorResponse } from "@/lib/utils/api-auth"; +import { validateAdminAuth } from "@/lib/utils/auth"; +import { createLogger } from "@/lib/utils/logger"; +import { transformTrafficRecordingSettingsToApi } from "@/lib/utils/api-transformers"; + +const log = createLogger("admin-traffic-recording-settings"); + +const updateSettingsSchema = z.object({ + enabled: z.boolean().optional(), + mode: z.enum(["all", "success", "failure"]).optional(), + redact_sensitive: z.boolean().optional(), + retention_days: z.number().int().min(1).max(3650).optional(), +}); + +/** Return the current traffic recording runtime settings. */ +export async function GET(request: NextRequest): Promise { + const authHeader = request.headers.get("authorization"); + if (!validateAdminAuth(authHeader)) { + return errorResponse("Unauthorized", 401); + } + + try { + const settings = await getTrafficRecordingSettings(); + return NextResponse.json(transformTrafficRecordingSettingsToApi(settings)); + } catch (error) { + log.error({ err: error }, "failed to get traffic recording settings"); + return errorResponse("Internal server error", 500); + } +} + +/** Update traffic recording runtime settings from admin input. */ +export async function PATCH(request: NextRequest): Promise { + const authHeader = request.headers.get("authorization"); + if (!validateAdminAuth(authHeader)) { + return errorResponse("Unauthorized", 401); + } + + try { + const body = await request.json(); + const validated = updateSettingsSchema.parse(body); + const settings = await updateTrafficRecordingSettings({ + enabled: validated.enabled, + mode: validated.mode, + redactSensitive: validated.redact_sensitive, + retentionDays: validated.retention_days, + }); + + return NextResponse.json(transformTrafficRecordingSettingsToApi(settings)); + } catch (error) { + if (error instanceof z.ZodError) { + return errorResponse( + `Validation error: ${error.issues.map((issue) => issue.message).join(", ")}`, + 400 + ); + } + + log.error({ err: error }, "failed to update traffic recording settings"); + return errorResponse("Internal server error", 500); + } +} diff --git a/src/app/api/admin/traffic-recordings/[id]/route.ts b/src/app/api/admin/traffic-recordings/[id]/route.ts new file mode 100644 index 00000000..a97516cb --- /dev/null +++ b/src/app/api/admin/traffic-recordings/[id]/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + deleteTrafficRecording, + getTrafficRecordingDetail, +} from "@/lib/services/traffic-recording-service"; +import { errorResponse } from "@/lib/utils/api-auth"; +import { validateAdminAuth } from "@/lib/utils/auth"; +import { createLogger } from "@/lib/utils/logger"; +import { transformTrafficRecordingDetailToApi } from "@/lib/utils/api-transformers"; + +const log = createLogger("admin-traffic-recording-detail"); + +interface RouteContext { + params: Promise<{ + id: string; + }>; +} + +/** Return one traffic recording index and its fixture content. */ +export async function GET(request: NextRequest, context: RouteContext): Promise { + const authHeader = request.headers.get("authorization"); + if (!validateAdminAuth(authHeader)) { + return errorResponse("Unauthorized", 401); + } + + const { id } = await context.params; + + try { + const recording = await getTrafficRecordingDetail(id); + if (!recording) { + return errorResponse("Traffic recording not found", 404); + } + return NextResponse.json(transformTrafficRecordingDetailToApi(recording)); + } catch (error) { + log.error({ err: error, id }, "failed to get traffic recording detail"); + return errorResponse(error instanceof Error ? error.message : "Internal server error", 500); + } +} + +/** Delete one traffic recording index and its fixture file. */ +export async function DELETE(request: NextRequest, context: RouteContext): Promise { + const authHeader = request.headers.get("authorization"); + if (!validateAdminAuth(authHeader)) { + return errorResponse("Unauthorized", 401); + } + + const { id } = await context.params; + + try { + const deleted = await deleteTrafficRecording(id); + if (!deleted) { + return errorResponse("Traffic recording not found", 404); + } + return NextResponse.json({ deleted: true }); + } catch (error) { + log.error({ err: error, id }, "failed to delete traffic recording"); + return errorResponse("Internal server error", 500); + } +} diff --git a/src/app/api/admin/traffic-recordings/cleanup/route.ts b/src/app/api/admin/traffic-recordings/cleanup/route.ts new file mode 100644 index 00000000..56457657 --- /dev/null +++ b/src/app/api/admin/traffic-recordings/cleanup/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { cleanupExpiredTrafficRecordings } from "@/lib/services/traffic-recording-service"; +import { errorResponse } from "@/lib/utils/api-auth"; +import { validateAdminAuth } from "@/lib/utils/auth"; +import { createLogger } from "@/lib/utils/logger"; + +const log = createLogger("admin-traffic-recordings-cleanup"); + +/** Trigger immediate cleanup for expired traffic recordings. */ +export async function POST(request: NextRequest): Promise { + const authHeader = request.headers.get("authorization"); + if (!validateAdminAuth(authHeader)) { + return errorResponse("Unauthorized", 401); + } + + try { + const result = await cleanupExpiredTrafficRecordings(); + return NextResponse.json({ + deleted_count: result.deletedCount, + failure_count: result.failureCount, + error_summary: result.errorSummary, + }); + } catch (error) { + log.error({ err: error }, "failed to cleanup traffic recordings"); + return errorResponse("Internal server error", 500); + } +} diff --git a/src/app/api/admin/traffic-recordings/route.ts b/src/app/api/admin/traffic-recordings/route.ts new file mode 100644 index 00000000..152772e0 --- /dev/null +++ b/src/app/api/admin/traffic-recordings/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + listTrafficRecordings, + type TrafficRecordingListFilters, +} from "@/lib/services/traffic-recording-service"; +import { errorResponse, getPaginationParams } from "@/lib/utils/api-auth"; +import { validateAdminAuth } from "@/lib/utils/auth"; +import { createLogger } from "@/lib/utils/logger"; +import { transformPaginatedTrafficRecordingsToApi } from "@/lib/utils/api-transformers"; + +const log = createLogger("admin-traffic-recordings"); + +function parseDateFilter(value: string, fieldName: string): Date | Response { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return errorResponse(`Invalid ${fieldName}`, 400); + } + return date; +} + +/** Return paginated traffic recording indexes using supported filters. */ +export async function GET(request: NextRequest): Promise { + const authHeader = request.headers.get("authorization"); + if (!validateAdminAuth(authHeader)) { + return errorResponse("Unauthorized", 401); + } + + try { + const { page, pageSize } = getPaginationParams(request); + const url = new URL(request.url); + const filters: TrafficRecordingListFilters = {}; + + const apiKeyId = url.searchParams.get("api_key_id"); + if (apiKeyId) filters.apiKeyId = apiKeyId; + + const upstreamId = url.searchParams.get("upstream_id"); + if (upstreamId) filters.upstreamId = upstreamId; + + const statusCode = url.searchParams.get("status_code"); + if (statusCode) { + const parsedStatusCode = Number(statusCode); + if (!Number.isInteger(parsedStatusCode)) { + return errorResponse("Invalid status_code", 400); + } + filters.statusCode = parsedStatusCode; + } + + const model = url.searchParams.get("model"); + if (model) filters.model = model; + + const startTime = url.searchParams.get("start_time"); + if (startTime) { + const parsedStartTime = parseDateFilter(startTime, "start_time"); + if (parsedStartTime instanceof Response) return parsedStartTime; + filters.startTime = parsedStartTime; + } + + const endTime = url.searchParams.get("end_time"); + if (endTime) { + const parsedEndTime = parseDateFilter(endTime, "end_time"); + if (parsedEndTime instanceof Response) return parsedEndTime; + filters.endTime = parsedEndTime; + } + + const result = await listTrafficRecordings(page, pageSize, filters); + return NextResponse.json(transformPaginatedTrafficRecordingsToApi(result)); + } catch (error) { + log.error({ err: error }, "failed to list traffic recordings"); + return errorResponse("Internal server error", 500); + } +} diff --git a/src/app/api/proxy/v1/[...path]/route.ts b/src/app/api/proxy/v1/[...path]/route.ts index 8ce7bb8c..3e016eea 100644 --- a/src/app/api/proxy/v1/[...path]/route.ts +++ b/src/app/api/proxy/v1/[...path]/route.ts @@ -104,6 +104,7 @@ import { buildFixture, recordTrafficFixture, } from "@/lib/services/traffic-recorder"; +import { getTrafficRecordingSettings } from "@/lib/services/traffic-recording-service"; import { extractSessionId, affinityStore, @@ -2453,8 +2454,9 @@ async function handleProxy(request: NextRequest, context: RouteContext): Promise }; // Recorder setup - const shouldRecordSuccess = shouldRecordFixture("success"); - const shouldRecordFailure = shouldRecordFixture("failure"); + const trafficRecordingSettings = await getTrafficRecordingSettings(); + const shouldRecordSuccess = shouldRecordFixture("success", trafficRecordingSettings); + const shouldRecordFailure = shouldRecordFixture("failure", trafficRecordingSettings); const recorderEnabled = shouldRecordSuccess || shouldRecordFailure; const inboundBody = recorderEnabled ? await readRequestBody(request) : null; @@ -3564,8 +3566,18 @@ async function handleProxy(request: NextRequest, context: RouteContext): Promise }, outboundRequestSent: true, outboundResponseSource: "upstream", + redactSensitive: trafficRecordingSettings.redactSensitive, + }); + return recordTrafficFixture(fixture, { + requestLogId, + apiKeyId: validApiKey.id, + upstreamId: upstreamForLogging.id, + method: request.method, + path, + model: resolvedModel, + statusCode: result.statusCode, + outcome: "success", }); - return recordTrafficFixture(fixture); }) .catch((error) => log.error({ err: error, requestId }, "failed to record stream fixture") @@ -3752,11 +3764,19 @@ async function handleProxy(request: NextRequest, context: RouteContext): Promise }, outboundRequestSent: true, outboundResponseSource: "upstream", + redactSensitive: trafficRecordingSettings.redactSensitive, }); - void recordTrafficFixture(fixture).catch((error) => - log.error({ err: error, requestId }, "failed to record fixture") - ); + void recordTrafficFixture(fixture, { + requestLogId: persistedLogId, + apiKeyId: validApiKey.id, + upstreamId: upstreamForLogging.id, + method: request.method, + path, + model: resolvedModel, + statusCode: result.statusCode, + outcome: "success", + }).catch((error) => log.error({ err: error, requestId }, "failed to record fixture")); } return new Response(Buffer.from(bodyBytes), { @@ -3982,9 +4002,19 @@ async function handleProxy(request: NextRequest, context: RouteContext): Promise bodyJson: downstreamErrorBody, }, failoverHistory: failoverHistory.length > 0 ? failoverHistory : null, + redactSensitive: trafficRecordingSettings.redactSensitive, }); - void recordTrafficFixture(failureFixture).catch((recordError) => + void recordTrafficFixture(failureFixture, { + requestLogId, + apiKeyId: validApiKey.id, + upstreamId: actualUpstreamId, + method: request.method, + path, + model: resolvedModel, + statusCode: errorStatusCode, + outcome: "failure", + }).catch((recordError) => log.error({ err: recordError, requestId }, "failed to record error fixture") ); } diff --git a/src/components/admin/background-sync-tasks-panel.tsx b/src/components/admin/background-sync-tasks-panel.tsx index 7a1cee71..d52102d6 100644 --- a/src/components/admin/background-sync-tasks-panel.tsx +++ b/src/components/admin/background-sync-tasks-panel.tsx @@ -104,6 +104,9 @@ function getTaskTitle(task: BackgroundSyncTaskResponse, t: ReturnType +) => string; + +export function useTrafficRecordingSettings() { + const { apiClient } = useAuth(); + + return useQuery({ + queryKey: ["traffic-recording", "settings"], + queryFn: () => + apiClient.get("/admin/traffic-recording/settings"), + }); +} + +export function useUpdateTrafficRecordingSettings() { + const { apiClient } = useAuth(); + const queryClient = useQueryClient(); + const t = useTranslations("trafficRecording") as TrafficRecordingTranslator; + + return useMutation({ + mutationFn: (data: TrafficRecordingSettingsUpdate) => + apiClient.patch("/admin/traffic-recording/settings", data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["traffic-recording"] }); + toast.success(t("settingsSaved")); + }, + onError: (error: Error) => { + toast.error(t("settingsSaveFailed", { message: error.message })); + }, + }); +} + +export function useTrafficRecordings( + page = 1, + pageSize = 20, + filters: TrafficRecordingFilters = {} +) { + const { apiClient } = useAuth(); + + return useQuery({ + queryKey: ["traffic-recording", "recordings", page, pageSize, filters], + queryFn: () => { + const params = new URLSearchParams(); + params.set("page", String(page)); + params.set("page_size", String(pageSize)); + if (filters.api_key_id?.trim()) params.set("api_key_id", filters.api_key_id.trim()); + if (filters.upstream_id?.trim()) params.set("upstream_id", filters.upstream_id.trim()); + if (filters.status_code !== undefined) params.set("status_code", String(filters.status_code)); + if (filters.model?.trim()) params.set("model", filters.model.trim()); + if (filters.start_time) params.set("start_time", filters.start_time); + if (filters.end_time) params.set("end_time", filters.end_time); + return apiClient.get( + `/admin/traffic-recordings?${params.toString()}` + ); + }, + }); +} + +export function useTrafficRecordingDetail(id: string | null) { + const { apiClient } = useAuth(); + + return useQuery({ + queryKey: ["traffic-recording", "detail", id], + queryFn: () => + apiClient.get( + `/admin/traffic-recordings/${encodeURIComponent(id ?? "")}` + ), + enabled: Boolean(id), + }); +} + +export function useDeleteTrafficRecording() { + const { apiClient } = useAuth(); + const queryClient = useQueryClient(); + const t = useTranslations("trafficRecording") as TrafficRecordingTranslator; + + return useMutation({ + mutationFn: (id: string) => + apiClient.delete<{ deleted: boolean }>(`/admin/traffic-recordings/${encodeURIComponent(id)}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["traffic-recording"] }); + toast.success(t("recordDeleted")); + }, + onError: (error: Error) => { + toast.error(t("recordDeleteFailed", { message: error.message })); + }, + }); +} + +export function useCleanupTrafficRecordings() { + const { apiClient } = useAuth(); + const queryClient = useQueryClient(); + const t = useTranslations("trafficRecording") as TrafficRecordingTranslator; + + return useMutation({ + mutationFn: () => + apiClient.post<{ + deleted_count: number; + failure_count: number; + error_summary: string | null; + }>("/admin/traffic-recordings/cleanup"), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ["traffic-recording"] }); + queryClient.invalidateQueries({ queryKey: ["background-sync", "tasks"] }); + toast.success(t("cleanupComplete", { count: result.deleted_count })); + }, + onError: (error: Error) => { + toast.error(t("cleanupFailed", { message: error.message })); + }, + }); +} diff --git a/src/lib/db/schema-pg.ts b/src/lib/db/schema-pg.ts index 58dc88cc..32f41efc 100644 --- a/src/lib/db/schema-pg.ts +++ b/src/lib/db/schema-pg.ts @@ -335,6 +335,54 @@ export const requestLogs = pgTable( ] ); +/** + * Runtime configuration for traffic recording. + */ +export const trafficRecordingSettings = pgTable("traffic_recording_settings", { + id: varchar("id", { length: 32 }).primaryKey().default("default"), + enabled: boolean("enabled").notNull().default(false), + mode: varchar("mode", { length: 16 }).notNull().default("failure"), + redactSensitive: boolean("redact_sensitive").notNull().default(true), + retentionDays: integer("retention_days").notNull().default(7), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +/** + * Searchable index for recorded traffic fixture files. + */ +export const trafficRecordings = pgTable( + "traffic_recordings", + { + id: uuid("id").primaryKey().defaultRandom(), + requestLogId: uuid("request_log_id").references(() => requestLogs.id, { + onDelete: "set null", + }), + apiKeyId: uuid("api_key_id").references(() => apiKeys.id, { onDelete: "set null" }), + upstreamId: uuid("upstream_id").references(() => upstreams.id, { onDelete: "set null" }), + method: varchar("method", { length: 10 }), + path: text("path"), + model: varchar("model", { length: 128 }), + statusCode: integer("status_code"), + outcome: varchar("outcome", { length: 16 }).notNull(), + fixturePath: text("fixture_path").notNull(), + fixtureSizeBytes: integer("fixture_size_bytes").notNull().default(0), + requestSizeBytes: integer("request_size_bytes").notNull().default(0), + responseSizeBytes: integer("response_size_bytes").notNull().default(0), + redacted: boolean("redacted").notNull().default(true), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + unique("traffic_recordings_fixture_path_unique").on(table.fixturePath), + index("traffic_recordings_request_log_id_idx").on(table.requestLogId), + index("traffic_recordings_api_key_id_idx").on(table.apiKeyId), + index("traffic_recordings_upstream_id_idx").on(table.upstreamId), + index("traffic_recordings_status_code_idx").on(table.statusCode), + index("traffic_recordings_model_idx").on(table.model), + index("traffic_recordings_created_at_idx").on(table.createdAt), + ] +); + /** * Synced model price catalog from external sources. */ @@ -628,6 +676,21 @@ export const requestLogsRelations = relations(requestLogs, ({ one }) => ({ }), })); +export const trafficRecordingsRelations = relations(trafficRecordings, ({ one }) => ({ + requestLog: one(requestLogs, { + fields: [trafficRecordings.requestLogId], + references: [requestLogs.id], + }), + apiKey: one(apiKeys, { + fields: [trafficRecordings.apiKeyId], + references: [apiKeys.id], + }), + upstream: one(upstreams, { + fields: [trafficRecordings.upstreamId], + references: [upstreams.id], + }), +})); + export const requestBillingSnapshotsRelations = relations(requestBillingSnapshots, ({ one }) => ({ requestLog: one(requestLogs, { fields: [requestBillingSnapshots.requestLogId], @@ -656,6 +719,10 @@ export type ApiKeyUpstream = typeof apiKeyUpstreams.$inferSelect; export type NewApiKeyUpstream = typeof apiKeyUpstreams.$inferInsert; export type RequestLog = typeof requestLogs.$inferSelect; export type NewRequestLog = typeof requestLogs.$inferInsert; +export type TrafficRecordingSettings = typeof trafficRecordingSettings.$inferSelect; +export type NewTrafficRecordingSettings = typeof trafficRecordingSettings.$inferInsert; +export type TrafficRecording = typeof trafficRecordings.$inferSelect; +export type NewTrafficRecording = typeof trafficRecordings.$inferInsert; export type CircuitBreakerState = typeof circuitBreakerStates.$inferSelect; export type NewCircuitBreakerState = typeof circuitBreakerStates.$inferInsert; export type UpstreamFailureRule = typeof upstreamFailureRules.$inferSelect; diff --git a/src/lib/db/schema-sqlite.ts b/src/lib/db/schema-sqlite.ts index a94efa20..ae1194ad 100644 --- a/src/lib/db/schema-sqlite.ts +++ b/src/lib/db/schema-sqlite.ts @@ -351,6 +351,55 @@ export const requestLogs = sqliteTable( ] ); +/** + * Runtime configuration for traffic recording. + */ +export const trafficRecordingSettings = sqliteTable("traffic_recording_settings", { + id: text("id").primaryKey().default("default"), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(false), + mode: text("mode").notNull().default("failure"), + redactSensitive: integer("redact_sensitive", { mode: "boolean" }).notNull().default(true), + retentionDays: integer("retention_days").notNull().default(7), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().defaultNow(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull().defaultNow(), +}); + +/** + * Searchable index for recorded traffic fixture files. + */ +export const trafficRecordings = sqliteTable( + "traffic_recordings", + { + id: text("id") + .primaryKey() + .$defaultFn(() => randomUUID()), + requestLogId: text("request_log_id").references(() => requestLogs.id, { + onDelete: "set null", + }), + apiKeyId: text("api_key_id").references(() => apiKeys.id, { onDelete: "set null" }), + upstreamId: text("upstream_id").references(() => upstreams.id, { onDelete: "set null" }), + method: text("method"), + path: text("path"), + model: text("model"), + statusCode: integer("status_code"), + outcome: text("outcome").notNull(), + fixturePath: text("fixture_path").notNull().unique(), + fixtureSizeBytes: integer("fixture_size_bytes").notNull().default(0), + requestSizeBytes: integer("request_size_bytes").notNull().default(0), + responseSizeBytes: integer("response_size_bytes").notNull().default(0), + redacted: integer("redacted", { mode: "boolean" }).notNull().default(true), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().defaultNow(), + }, + (table) => [ + index("traffic_recordings_request_log_id_idx").on(table.requestLogId), + index("traffic_recordings_api_key_id_idx").on(table.apiKeyId), + index("traffic_recordings_upstream_id_idx").on(table.upstreamId), + index("traffic_recordings_status_code_idx").on(table.statusCode), + index("traffic_recordings_model_idx").on(table.model), + index("traffic_recordings_created_at_idx").on(table.createdAt), + ] +); + /** * Synced model price catalog from external sources. */ @@ -658,6 +707,21 @@ export const requestLogsRelations = relations(requestLogs, ({ one }) => ({ }), })); +export const trafficRecordingsRelations = relations(trafficRecordings, ({ one }) => ({ + requestLog: one(requestLogs, { + fields: [trafficRecordings.requestLogId], + references: [requestLogs.id], + }), + apiKey: one(apiKeys, { + fields: [trafficRecordings.apiKeyId], + references: [apiKeys.id], + }), + upstream: one(upstreams, { + fields: [trafficRecordings.upstreamId], + references: [upstreams.id], + }), +})); + export const requestBillingSnapshotsRelations = relations(requestBillingSnapshots, ({ one }) => ({ requestLog: one(requestLogs, { fields: [requestBillingSnapshots.requestLogId], @@ -686,6 +750,10 @@ export type ApiKeyUpstream = typeof apiKeyUpstreams.$inferSelect; export type NewApiKeyUpstream = typeof apiKeyUpstreams.$inferInsert; export type RequestLog = typeof requestLogs.$inferSelect; export type NewRequestLog = typeof requestLogs.$inferInsert; +export type TrafficRecordingSettings = typeof trafficRecordingSettings.$inferSelect; +export type NewTrafficRecordingSettings = typeof trafficRecordingSettings.$inferInsert; +export type TrafficRecording = typeof trafficRecordings.$inferSelect; +export type NewTrafficRecording = typeof trafficRecordings.$inferInsert; export type CircuitBreakerState = typeof circuitBreakerStates.$inferSelect; export type NewCircuitBreakerState = typeof circuitBreakerStates.$inferInsert; export type UpstreamFailureRule = typeof upstreamFailureRules.$inferSelect; diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 1f070dfe..73dde6fd 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -12,6 +12,8 @@ export const apiKeyUpstreams = schema.apiKeyUpstreams; export const circuitBreakerStates = schema.circuitBreakerStates; export const upstreamFailureRules = schema.upstreamFailureRules; export const requestLogs = schema.requestLogs; +export const trafficRecordingSettings = schema.trafficRecordingSettings; +export const trafficRecordings = schema.trafficRecordings; export const compensationRules = schema.compensationRules; export const billingModelPrices = schema.billingModelPrices; export const billingManualPriceOverrides = schema.billingManualPriceOverrides; @@ -29,6 +31,7 @@ export const circuitBreakerStatesRelations = schema.circuitBreakerStatesRelation export const upstreamFailureRulesRelations = schema.upstreamFailureRulesRelations; export const apiKeyUpstreamsRelations = schema.apiKeyUpstreamsRelations; export const requestLogsRelations = schema.requestLogsRelations; +export const trafficRecordingsRelations = schema.trafficRecordingsRelations; export const requestBillingSnapshotsRelations = schema.requestBillingSnapshotsRelations; export type ApiKey = typeof apiKeys.$inferSelect; @@ -43,6 +46,10 @@ export type ApiKeyUpstream = typeof apiKeyUpstreams.$inferSelect; export type NewApiKeyUpstream = typeof apiKeyUpstreams.$inferInsert; export type RequestLog = typeof requestLogs.$inferSelect; export type NewRequestLog = typeof requestLogs.$inferInsert; +export type TrafficRecordingSettings = typeof trafficRecordingSettings.$inferSelect; +export type NewTrafficRecordingSettings = typeof trafficRecordingSettings.$inferInsert; +export type TrafficRecording = typeof trafficRecordings.$inferSelect; +export type NewTrafficRecording = typeof trafficRecordings.$inferInsert; export type CircuitBreakerState = typeof circuitBreakerStates.$inferSelect; export type NewCircuitBreakerState = typeof circuitBreakerStates.$inferInsert; export type UpstreamFailureRule = typeof upstreamFailureRules.$inferSelect; diff --git a/src/lib/services/background-sync-registry.ts b/src/lib/services/background-sync-registry.ts index b3a12ad7..ef412468 100644 --- a/src/lib/services/background-sync-registry.ts +++ b/src/lib/services/background-sync-registry.ts @@ -1,5 +1,6 @@ import type { BackgroundSyncTaskDefinition } from "./background-sync-types"; import { createBillingPriceCatalogSyncTaskDefinition } from "./billing-price-background-sync"; +import { createTrafficRecordingCleanupTaskDefinition } from "./traffic-recording-background-cleanup"; import { createUpstreamModelCatalogSyncTaskDefinition } from "./upstream-model-catalog-background-sync"; /** @@ -9,5 +10,6 @@ export function getBackgroundSyncTaskDefinitions(): BackgroundSyncTaskDefinition return [ createBillingPriceCatalogSyncTaskDefinition(), createUpstreamModelCatalogSyncTaskDefinition(), + createTrafficRecordingCleanupTaskDefinition(), ]; } diff --git a/src/lib/services/traffic-recorder.ts b/src/lib/services/traffic-recorder.ts index cec595aa..b8cc5ff4 100644 --- a/src/lib/services/traffic-recorder.ts +++ b/src/lib/services/traffic-recorder.ts @@ -1,6 +1,13 @@ import { mkdir, readFile, writeFile } from "fs/promises"; import path from "path"; import type { FailoverAttempt } from "./request-logger"; +import { + createTrafficRecordingIndex, + getTrafficRecordingRoot, + shouldRecordTraffic, + type CreateTrafficRecordingIndexInput, + type TrafficRecordingSettingsValue, +} from "./traffic-recording-service"; export interface TrafficRecordRequest { method: string; @@ -34,6 +41,7 @@ export interface TrafficRecordFixture { durationMs: number; /** Fixture format version. Absent in v1 fixtures. */ version?: 2; + redacted?: boolean; }; inbound: TrafficRecordRequest; outbound: { @@ -96,6 +104,7 @@ export interface BuildFixtureParams { streamChunks?: string[]; } | null; failoverHistory?: FailoverAttempt[] | null; + redactSensitive?: boolean; } /** Parsed inbound request body. */ @@ -105,8 +114,6 @@ export interface InboundBody { buffer: ArrayBuffer | null; } -const DEFAULT_FIXTURE_DIR = "tests/fixtures"; - /** * Maximum total bytes to buffer when recording a tee'd stream. * Once exceeded the recording side is cancelled to avoid memory pressure. @@ -135,45 +142,38 @@ export type RecorderOutcome = "success" | "failure"; * Determine whether traffic recording is enabled by environment configuration. */ export function isRecorderEnabled(): boolean { - return process.env.RECORDER_ENABLED === "true" || process.env.RECORDER_ENABLED === "1"; + return false; } /** * Resolve the configured traffic-recording mode. */ export function getRecorderMode(): RecorderMode { - const value = process.env.RECORDER_MODE?.trim().toLowerCase(); - if (value === "success" || value === "failure" || value === "all") { - return value; - } - return "all"; + return "failure"; } /** * Decide whether a fixture should be recorded for the given request outcome. */ -export function shouldRecordFixture(outcome: RecorderOutcome): boolean { - if (!isRecorderEnabled()) { - return false; - } - const mode = getRecorderMode(); - return mode === "all" || mode === outcome; +export function shouldRecordFixture( + outcome: RecorderOutcome, + settings?: Pick +): boolean { + return settings ? shouldRecordTraffic(settings, outcome) : false; } /** * Return the root directory used to store recorded traffic fixtures. */ export function getFixtureRoot(): string { - return process.env.RECORDER_FIXTURES_DIR || DEFAULT_FIXTURE_DIR; + return getTrafficRecordingRoot(); } /** * Determine whether sensitive values should be redacted from recorded fixtures. */ export function isRecorderRedactionEnabled(): boolean { - const value = process.env.RECORDER_REDACT_SENSITIVE; - if (!value) return true; - return value !== "false" && value !== "0"; + return true; } /** @@ -209,29 +209,32 @@ function toHeaderRecord(headers: Headers | Record): Record + headers: Headers | Record, + redactSensitive = true ): Record { - return isRecorderRedactionEnabled() ? redactHeaders(headers) : toHeaderRecord(headers); + return redactSensitive ? redactHeaders(headers) : toHeaderRecord(headers); } -function formatUrlForFixture(url: string): string { - return isRecorderRedactionEnabled() ? redactUrl(url) : url; +function formatUrlForFixture(url: string, redactSensitive = true): string { + return redactSensitive ? redactUrl(url) : url; } -function formatFailoverHistoryForFixture(history: FailoverAttempt[]): FailoverAttempt[] { - const redactionEnabled = isRecorderRedactionEnabled(); +function formatFailoverHistoryForFixture( + history: FailoverAttempt[], + redactSensitive = true +): FailoverAttempt[] { return history.map((attempt) => { const formatted: FailoverAttempt = { ...attempt, ...(typeof attempt.upstream_base_url === "string" - ? { upstream_base_url: formatUrlForFixture(attempt.upstream_base_url) } + ? { upstream_base_url: formatUrlForFixture(attempt.upstream_base_url, redactSensitive) } : {}), ...(attempt.response_headers - ? { response_headers: formatHeadersForFixture(attempt.response_headers) } + ? { response_headers: formatHeadersForFixture(attempt.response_headers, redactSensitive) } : {}), }; - if (redactionEnabled) { + if (redactSensitive) { delete formatted.response_body_text; delete formatted.response_body_json; } @@ -386,6 +389,7 @@ export function compactSSEChunks(chunks: string[]): string[] { /** Build a TrafficRecordFixture from proxy context. */ export function buildFixture(params: BuildFixtureParams): TrafficRecordFixture { + const redactSensitive = params.redactSensitive ?? true; // Body: prefer JSON, fall back to text const inboundBody: Pick = params.inboundRequest.bodyJson != null @@ -414,7 +418,7 @@ export function buildFixture(params: BuildFixtureParams): TrafficRecordFixture { : null; const failoverHistory = params.failoverHistory && params.failoverHistory.length > 0 - ? formatFailoverHistoryForFixture(params.failoverHistory) + ? formatFailoverHistoryForFixture(params.failoverHistory, redactSensitive) : null; return { @@ -426,11 +430,12 @@ export function buildFixture(params: BuildFixtureParams): TrafficRecordFixture { model: params.model, durationMs: Date.now() - params.startTime, version: 2, + redacted: redactSensitive, }, inbound: { method: params.inboundRequest.method, path: params.inboundRequest.path, - headers: formatHeadersForFixture(params.inboundRequest.headers), + headers: formatHeadersForFixture(params.inboundRequest.headers, redactSensitive), ...inboundBody, }, outbound: { @@ -438,7 +443,7 @@ export function buildFixture(params: BuildFixtureParams): TrafficRecordFixture { id: params.upstream.id, name: params.upstream.name, providerType: params.upstream.providerType, - baseUrl: formatUrlForFixture(params.upstream.baseUrl), + baseUrl: formatUrlForFixture(params.upstream.baseUrl, redactSensitive), }, ...(typeof params.outboundRequestSent === "boolean" ? { didSendUpstream: params.outboundRequestSent } @@ -447,12 +452,12 @@ export function buildFixture(params: BuildFixtureParams): TrafficRecordFixture { request: { method: params.inboundRequest.method, path: params.inboundRequest.path, - headers: formatHeadersForFixture(params.outboundHeaders), + headers: formatHeadersForFixture(params.outboundHeaders, redactSensitive), bodyFromInbound: true, }, response: { status: params.response.statusCode, - headers: formatHeadersForFixture(params.response.headers), + headers: formatHeadersForFixture(params.response.headers, redactSensitive), ...responseBody, streamChunks, }, @@ -462,7 +467,7 @@ export function buildFixture(params: BuildFixtureParams): TrafficRecordFixture { downstream: { response: { status: params.downstreamResponse.statusCode, - headers: formatHeadersForFixture(params.downstreamResponse.headers), + headers: formatHeadersForFixture(params.downstreamResponse.headers, redactSensitive), ...(downstreamResponseBody ?? {}), streamChunks: downstreamStreamChunks, }, @@ -483,10 +488,39 @@ export function buildFixture(params: BuildFixtureParams): TrafficRecordFixture { // Fixture persistence // --------------------------------------------------------------------------- +function jsonSizeBytes(value: unknown): number { + if (value == null) return 0; + return new TextEncoder().encode(typeof value === "string" ? value : JSON.stringify(value)).length; +} + +function estimateRequestSizeBytes(fixture: TrafficRecordFixture): number { + return jsonSizeBytes(fixture.inbound.bodyJson ?? fixture.inbound.bodyText); +} + +function estimateResponseSizeBytes(fixture: TrafficRecordFixture): number { + const upstreamBodySize = jsonSizeBytes( + fixture.outbound.response.bodyJson ?? + fixture.outbound.response.bodyText ?? + fixture.outbound.response.streamChunks + ); + const downstreamBodySize = jsonSizeBytes( + fixture.downstream?.response.bodyJson ?? + fixture.downstream?.response.bodyText ?? + fixture.downstream?.response.streamChunks + ); + return upstreamBodySize + downstreamBodySize; +} + /** * Persist a recorded traffic fixture to disk and return its file path. */ -export async function recordTrafficFixture(fixture: TrafficRecordFixture): Promise { +export async function recordTrafficFixture( + fixture: TrafficRecordFixture, + index?: Omit< + CreateTrafficRecordingIndexInput, + "fixturePath" | "requestSizeBytes" | "responseSizeBytes" | "redacted" | "createdAt" | "outcome" + > & { outcome?: CreateTrafficRecordingIndexInput["outcome"] } +): Promise { const timestamp = fixture.meta.createdAt.replace(/[:.]/g, "-"); const filePath = buildFixturePath(fixture.meta.providerType, fixture.meta.route, timestamp); const latestPath = path.join(path.dirname(filePath), "latest.json"); @@ -494,6 +528,23 @@ export async function recordTrafficFixture(fixture: TrafficRecordFixture): Promi await mkdir(path.dirname(filePath), { recursive: true }); await writeFile(filePath, payload, "utf-8"); await writeFile(latestPath, payload, "utf-8"); + + if (index) { + await createTrafficRecordingIndex({ + ...index, + outcome: + index.outcome ?? + (fixture.outbound.response.status >= 200 && fixture.outbound.response.status < 400 + ? "success" + : "failure"), + fixturePath: filePath, + requestSizeBytes: estimateRequestSizeBytes(fixture), + responseSizeBytes: estimateResponseSizeBytes(fixture), + redacted: fixture.meta.redacted ?? true, + createdAt: new Date(fixture.meta.createdAt), + }); + } + return filePath; } @@ -505,7 +556,7 @@ export async function readLatestFixture( route: string ): Promise { const dir = path.join( - DEFAULT_FIXTURE_DIR, + getFixtureRoot(), sanitizePathSegment(provider), sanitizePathSegment(route) ); diff --git a/src/lib/services/traffic-recording-background-cleanup.ts b/src/lib/services/traffic-recording-background-cleanup.ts new file mode 100644 index 00000000..28bd54e7 --- /dev/null +++ b/src/lib/services/traffic-recording-background-cleanup.ts @@ -0,0 +1,34 @@ +import { cleanupExpiredTrafficRecordings } from "@/lib/services/traffic-recording-service"; +import type { + BackgroundSyncTaskDefinition, + BackgroundSyncTaskRunResult, +} from "./background-sync-types"; + +export const TRAFFIC_RECORDING_CLEANUP_TASK_NAME = "traffic_recording_cleanup"; + +const DEFAULT_TRAFFIC_RECORDING_CLEANUP_INTERVAL_SECONDS = 86_400; +const DEFAULT_BACKGROUND_SYNC_STARTUP_DELAY_SECONDS = 120; + +/** Run the scheduled cleanup task for expired traffic recordings. */ +export async function runTrafficRecordingCleanupTask(): Promise { + const result = await cleanupExpiredTrafficRecordings(); + return { + status: + result.failureCount === 0 ? "success" : result.deletedCount === 0 ? "failed" : "partial", + successCount: result.deletedCount, + failureCount: result.failureCount, + errorSummary: result.errorSummary, + }; +} + +/** Create the background sync definition for traffic recording cleanup. */ +export function createTrafficRecordingCleanupTaskDefinition(): BackgroundSyncTaskDefinition { + return { + taskName: TRAFFIC_RECORDING_CLEANUP_TASK_NAME, + displayName: "Traffic recording cleanup", + defaultEnabled: true, + defaultIntervalSeconds: DEFAULT_TRAFFIC_RECORDING_CLEANUP_INTERVAL_SECONDS, + defaultStartupDelaySeconds: DEFAULT_BACKGROUND_SYNC_STARTUP_DELAY_SECONDS, + run: runTrafficRecordingCleanupTask, + }; +} diff --git a/src/lib/services/traffic-recording-service.ts b/src/lib/services/traffic-recording-service.ts new file mode 100644 index 00000000..983b0f02 --- /dev/null +++ b/src/lib/services/traffic-recording-service.ts @@ -0,0 +1,397 @@ +import { stat, readFile, unlink } from "fs/promises"; +import path from "path"; +import { and, count, desc, eq, gte, lte, lt, sql } from "drizzle-orm"; +import { db, trafficRecordings, trafficRecordingSettings, type TrafficRecording } from "@/lib/db"; +import { createLogger } from "@/lib/utils/logger"; + +const log = createLogger("traffic-recording-service"); + +export const TRAFFIC_RECORDING_SETTINGS_ID = "default"; +export const DEFAULT_TRAFFIC_RECORDING_ROOT = "data/traffic-recordings"; + +export type TrafficRecordingMode = "all" | "success" | "failure"; +export type TrafficRecordingOutcome = "success" | "failure"; + +export interface TrafficRecordingSettingsValue { + enabled: boolean; + mode: TrafficRecordingMode; + redactSensitive: boolean; + retentionDays: number; + updatedAt: Date; +} + +export interface TrafficRecordingSettingsUpdate { + enabled?: boolean; + mode?: TrafficRecordingMode; + redactSensitive?: boolean; + retentionDays?: number; +} + +export interface CreateTrafficRecordingIndexInput { + requestLogId?: string | null; + apiKeyId?: string | null; + upstreamId?: string | null; + method?: string | null; + path?: string | null; + model?: string | null; + statusCode?: number | null; + outcome: TrafficRecordingOutcome; + fixturePath: string; + requestSizeBytes?: number; + responseSizeBytes?: number; + redacted: boolean; + createdAt?: Date; +} + +export interface TrafficRecordingListFilters { + apiKeyId?: string; + upstreamId?: string; + statusCode?: number; + model?: string; + startTime?: Date; + endTime?: Date; +} + +export interface TrafficRecordingListItem { + id: string; + requestLogId: string | null; + apiKeyId: string | null; + upstreamId: string | null; + method: string | null; + path: string | null; + model: string | null; + statusCode: number | null; + outcome: TrafficRecordingOutcome; + fixturePath: string; + fixtureSizeBytes: number; + requestSizeBytes: number; + responseSizeBytes: number; + redacted: boolean; + createdAt: Date; +} + +export interface TrafficRecordingDetail extends TrafficRecordingListItem { + fixture: unknown; +} + +export interface TrafficRecordingStats { + total: number; + totalSizeBytes: number; + latestCreatedAt: Date | null; +} + +export interface PaginatedTrafficRecordings { + items: TrafficRecordingListItem[]; + total: number; + page: number; + pageSize: number; + totalPages: number; + stats: TrafficRecordingStats; +} + +export interface TrafficRecordingCleanupResult { + deletedCount: number; + failureCount: number; + errorSummary: string | null; +} + +function normalizeMode(value: string | null | undefined): TrafficRecordingMode { + return value === "all" || value === "success" || value === "failure" ? value : "failure"; +} + +function normalizeOutcome(value: string): TrafficRecordingOutcome { + return value === "success" ? "success" : "failure"; +} + +function normalizeDate(value: Date | string | number): Date { + return value instanceof Date ? value : new Date(value); +} + +function normalizeOptionalDate(value: Date | string | number | null | undefined): Date | null { + return value == null ? null : normalizeDate(value); +} + +function mapSettings( + row: typeof trafficRecordingSettings.$inferSelect +): TrafficRecordingSettingsValue { + return { + enabled: row.enabled, + mode: normalizeMode(row.mode), + redactSensitive: row.redactSensitive, + retentionDays: row.retentionDays, + updatedAt: normalizeDate(row.updatedAt), + }; +} + +function mapRecording(row: TrafficRecording): TrafficRecordingListItem { + return { + id: row.id, + requestLogId: row.requestLogId ?? null, + apiKeyId: row.apiKeyId ?? null, + upstreamId: row.upstreamId ?? null, + method: row.method ?? null, + path: row.path ?? null, + model: row.model ?? null, + statusCode: row.statusCode ?? null, + outcome: normalizeOutcome(row.outcome), + fixturePath: row.fixturePath, + fixtureSizeBytes: row.fixtureSizeBytes, + requestSizeBytes: row.requestSizeBytes, + responseSizeBytes: row.responseSizeBytes, + redacted: row.redacted, + createdAt: normalizeDate(row.createdAt), + }; +} + +function resolveRecordingRoot(): string { + return path.resolve(process.env.RECORDER_FIXTURES_DIR || DEFAULT_TRAFFIC_RECORDING_ROOT); +} + +function assertPathInsideRecordingRoot(filePath: string): string { + const root = resolveRecordingRoot(); + const resolved = path.resolve(filePath); + const relative = path.relative(root, resolved); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Recording file is outside the configured recording root"); + } + return resolved; +} + +/** Return the configured fixture root for recorded traffic files. */ +export function getTrafficRecordingRoot(): string { + return process.env.RECORDER_FIXTURES_DIR || DEFAULT_TRAFFIC_RECORDING_ROOT; +} + +/** Read the singleton runtime traffic recording settings row, creating defaults when absent. */ +export async function getTrafficRecordingSettings(): Promise { + await db + .insert(trafficRecordingSettings) + .values({ + id: TRAFFIC_RECORDING_SETTINGS_ID, + enabled: false, + mode: "failure", + redactSensitive: true, + retentionDays: 7, + updatedAt: new Date(), + }) + .onConflictDoNothing({ + target: trafficRecordingSettings.id, + }); + + const row = await db.query.trafficRecordingSettings.findFirst({ + where: eq(trafficRecordingSettings.id, TRAFFIC_RECORDING_SETTINGS_ID), + }); + + if (!row) { + throw new Error("Failed to initialize traffic recording settings"); + } + + return mapSettings(row); +} + +/** Persist changes to the singleton runtime traffic recording settings row. */ +export async function updateTrafficRecordingSettings( + input: TrafficRecordingSettingsUpdate +): Promise { + await getTrafficRecordingSettings(); + + const [row] = await db + .update(trafficRecordingSettings) + .set({ + ...(input.enabled !== undefined ? { enabled: input.enabled } : {}), + ...(input.mode !== undefined ? { mode: input.mode } : {}), + ...(input.redactSensitive !== undefined ? { redactSensitive: input.redactSensitive } : {}), + ...(input.retentionDays !== undefined ? { retentionDays: input.retentionDays } : {}), + updatedAt: new Date(), + }) + .where(eq(trafficRecordingSettings.id, TRAFFIC_RECORDING_SETTINGS_ID)) + .returning(); + + if (!row) { + throw new Error("Traffic recording settings not found"); + } + + return mapSettings(row); +} + +/** Decide whether the current settings record the given request outcome. */ +export function shouldRecordTraffic( + settings: Pick, + outcome: TrafficRecordingOutcome +): boolean { + return settings.enabled && (settings.mode === "all" || settings.mode === outcome); +} + +/** Create or refresh the database index row for a fixture file. */ +export async function createTrafficRecordingIndex( + input: CreateTrafficRecordingIndexInput +): Promise { + const fileStats = await stat(input.fixturePath); + const [row] = await db + .insert(trafficRecordings) + .values({ + requestLogId: input.requestLogId ?? null, + apiKeyId: input.apiKeyId ?? null, + upstreamId: input.upstreamId ?? null, + method: input.method ?? null, + path: input.path ?? null, + model: input.model ?? null, + statusCode: input.statusCode ?? null, + outcome: input.outcome, + fixturePath: input.fixturePath, + fixtureSizeBytes: fileStats.size, + requestSizeBytes: input.requestSizeBytes ?? 0, + responseSizeBytes: input.responseSizeBytes ?? 0, + redacted: input.redacted, + createdAt: input.createdAt ?? new Date(), + }) + .onConflictDoUpdate({ + target: trafficRecordings.fixturePath, + set: { + requestLogId: input.requestLogId ?? null, + apiKeyId: input.apiKeyId ?? null, + upstreamId: input.upstreamId ?? null, + method: input.method ?? null, + path: input.path ?? null, + model: input.model ?? null, + statusCode: input.statusCode ?? null, + outcome: input.outcome, + fixtureSizeBytes: fileStats.size, + requestSizeBytes: input.requestSizeBytes ?? 0, + responseSizeBytes: input.responseSizeBytes ?? 0, + redacted: input.redacted, + }, + }) + .returning(); + + return mapRecording(row); +} + +/** List traffic recording indexes with pagination, filters, and aggregate stats. */ +export async function listTrafficRecordings( + page = 1, + pageSize = 20, + filters: TrafficRecordingListFilters = {} +): Promise { + page = Math.max(1, page); + pageSize = Math.min(100, Math.max(1, pageSize)); + + const conditions = []; + if (filters.apiKeyId) conditions.push(eq(trafficRecordings.apiKeyId, filters.apiKeyId)); + if (filters.upstreamId) conditions.push(eq(trafficRecordings.upstreamId, filters.upstreamId)); + if (filters.statusCode !== undefined) + conditions.push(eq(trafficRecordings.statusCode, filters.statusCode)); + if (filters.startTime) conditions.push(gte(trafficRecordings.createdAt, filters.startTime)); + if (filters.endTime) conditions.push(lte(trafficRecordings.createdAt, filters.endTime)); + if (filters.model?.trim()) { + conditions.push( + sql`lower(${trafficRecordings.model}) like ${`%${filters.model.trim().toLowerCase()}%`}` + ); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + const [{ value: total }] = await db + .select({ value: count() }) + .from(trafficRecordings) + .where(whereClause); + + const rows = await db.query.trafficRecordings.findMany({ + where: whereClause, + orderBy: [desc(trafficRecordings.createdAt)], + limit: pageSize, + offset: (page - 1) * pageSize, + }); + + const [{ totalSizeBytes, latestCreatedAt }] = await db + .select({ + totalSizeBytes: sql`coalesce(sum(${trafficRecordings.fixtureSizeBytes}), 0)`, + latestCreatedAt: sql`max(${trafficRecordings.createdAt})`, + }) + .from(trafficRecordings); + + return { + items: rows.map(mapRecording), + total, + page, + pageSize, + totalPages: total > 0 ? Math.ceil(total / pageSize) : 1, + stats: { + total, + totalSizeBytes: Number(totalSizeBytes ?? 0), + latestCreatedAt: normalizeOptionalDate(latestCreatedAt), + }, + }; +} + +/** Read a traffic recording index together with its fixture JSON content. */ +export async function getTrafficRecordingDetail( + id: string +): Promise { + const row = await db.query.trafficRecordings.findFirst({ + where: eq(trafficRecordings.id, id), + }); + if (!row) return null; + + const recording = mapRecording(row); + const filePath = assertPathInsideRecordingRoot(recording.fixturePath); + const fixture = JSON.parse(await readFile(filePath, "utf-8")); + + return { + ...recording, + fixture, + }; +} + +/** Delete a traffic recording index and its fixture file when the file still exists. */ +export async function deleteTrafficRecording(id: string): Promise { + const row = await db.query.trafficRecordings.findFirst({ + where: eq(trafficRecordings.id, id), + }); + if (!row) return false; + + try { + const filePath = assertPathInsideRecordingRoot(row.fixturePath); + await unlink(filePath); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? error.code : null; + if (code !== "ENOENT") { + log.warn({ err: error, recordingId: id }, "failed to delete traffic recording fixture"); + } + } + + await db.delete(trafficRecordings).where(eq(trafficRecordings.id, id)); + return true; +} + +/** Delete traffic recordings older than the configured retention window. */ +export async function cleanupExpiredTrafficRecordings( + now: Date = new Date() +): Promise { + const settings = await getTrafficRecordingSettings(); + const cutoff = new Date(now.getTime() - settings.retentionDays * 24 * 60 * 60 * 1000); + const expired = await db.query.trafficRecordings.findMany({ + where: lt(trafficRecordings.createdAt, cutoff), + orderBy: [desc(trafficRecordings.createdAt)], + }); + + let deletedCount = 0; + let failureCount = 0; + const failures: string[] = []; + + for (const recording of expired) { + const deleted = await deleteTrafficRecording(recording.id); + if (deleted) { + deletedCount += 1; + } else { + failureCount += 1; + failures.push(recording.id); + } + } + + return { + deletedCount, + failureCount, + errorSummary: + failures.length > 0 ? `Failed to delete recordings: ${failures.join(", ")}` : null, + }; +} diff --git a/src/lib/utils/api-transformers.ts b/src/lib/utils/api-transformers.ts index 4028591f..14dc1ee7 100644 --- a/src/lib/utils/api-transformers.ts +++ b/src/lib/utils/api-transformers.ts @@ -53,6 +53,13 @@ import type { BackgroundSyncExecuteResult, BackgroundSyncTaskState, } from "@/lib/services/background-sync-types"; +import type { + PaginatedTrafficRecordings, + TrafficRecordingDetail, + TrafficRecordingListItem, + TrafficRecordingSettingsValue, + TrafficRecordingStats, +} from "@/lib/services/traffic-recording-service"; // ========== Helper Utilities ========== @@ -901,6 +908,114 @@ export function transformPaginatedRequestLogs( }; } +// ========== Traffic Recording API Response Types ========== + +export interface TrafficRecordingSettingsApiResponse { + enabled: boolean; + mode: "all" | "success" | "failure"; + redact_sensitive: boolean; + retention_days: number; + updated_at: string; +} + +export interface TrafficRecordingApiResponse { + id: string; + request_log_id: string | null; + api_key_id: string | null; + upstream_id: string | null; + method: string | null; + path: string | null; + model: string | null; + status_code: number | null; + outcome: "success" | "failure"; + fixture_path: string; + fixture_size_bytes: number; + request_size_bytes: number; + response_size_bytes: number; + redacted: boolean; + created_at: string; +} + +export interface TrafficRecordingDetailApiResponse extends TrafficRecordingApiResponse { + fixture: unknown; +} + +export interface TrafficRecordingStatsApiResponse { + total: number; + total_size_bytes: number; + latest_created_at: string | null; +} + +export interface PaginatedTrafficRecordingsApiResponse extends PaginatedApiResponse { + stats: TrafficRecordingStatsApiResponse; +} + +export function transformTrafficRecordingSettingsToApi( + settings: TrafficRecordingSettingsValue +): TrafficRecordingSettingsApiResponse { + return { + enabled: settings.enabled, + mode: settings.mode, + redact_sensitive: settings.redactSensitive, + retention_days: settings.retentionDays, + updated_at: settings.updatedAt.toISOString(), + }; +} + +export function transformTrafficRecordingToApi( + recording: TrafficRecordingListItem +): TrafficRecordingApiResponse { + return { + id: recording.id, + request_log_id: recording.requestLogId, + api_key_id: recording.apiKeyId, + upstream_id: recording.upstreamId, + method: recording.method, + path: recording.path, + model: recording.model, + status_code: recording.statusCode, + outcome: recording.outcome, + fixture_path: recording.fixturePath, + fixture_size_bytes: recording.fixtureSizeBytes, + request_size_bytes: recording.requestSizeBytes, + response_size_bytes: recording.responseSizeBytes, + redacted: recording.redacted, + created_at: recording.createdAt.toISOString(), + }; +} + +export function transformTrafficRecordingDetailToApi( + recording: TrafficRecordingDetail +): TrafficRecordingDetailApiResponse { + return { + ...transformTrafficRecordingToApi(recording), + fixture: recording.fixture, + }; +} + +export function transformTrafficRecordingStatsToApi( + stats: TrafficRecordingStats +): TrafficRecordingStatsApiResponse { + return { + total: stats.total, + total_size_bytes: stats.totalSizeBytes, + latest_created_at: toISOStringOrNull(stats.latestCreatedAt), + }; +} + +export function transformPaginatedTrafficRecordingsToApi( + result: PaginatedTrafficRecordings +): PaginatedTrafficRecordingsApiResponse { + return { + items: result.items.map(transformTrafficRecordingToApi), + total: result.total, + page: result.page, + page_size: result.pageSize, + total_pages: result.totalPages, + stats: transformTrafficRecordingStatsToApi(result.stats), + }; +} + // ========== Billing API Response Types ========== export interface BillingOverviewApiResponse { diff --git a/src/messages/en.json b/src/messages/en.json index 9ebf4847..dd9c8c29 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -59,6 +59,7 @@ "system": "System", "headerCompensation": "Header Compensation", "backgroundSync": "Background Tasks", + "trafficRecording": "Traffic Recording", "globalFailureRules": "Global Failure Rules", "billing": "Billing" }, @@ -1252,6 +1253,8 @@ "taskBillingPriceCatalogSyncDesc": "Syncs model price data for request cost and tiered pricing.", "taskUpstreamModelCatalogSync": "Model Catalog Auto Refresh", "taskUpstreamModelCatalogSyncDesc": "Refreshes model list caches for opted-in upstreams.", + "taskTrafficRecordingCleanup": "Traffic Recording Cleanup", + "taskTrafficRecordingCleanupDesc": "Deletes expired recording files and indexes by the configured retention window.", "config": "Config", "enabled": "Enabled", "disabled": "Disabled", @@ -1290,6 +1293,55 @@ "configSaved": "Background task config saved", "configSaveError": "Failed to save task config: {message}" }, + "trafficRecording": { + "title": "Traffic Recording", + "pageTitle": "TRAFFIC RECORDING", + "settingsDescription": "Control request recording in real time and manage stored recording files.", + "description": "Control proxy request recording, inspect disk usage, and search, view, or delete saved recording details.", + "enabled": "Enabled", + "disabled": "Disabled", + "redacted": "Redacted", + "notRedacted": "Not redacted", + "recordCount": "Records", + "diskUsage": "Disk usage", + "latestRecord": "Latest record", + "mode": "Recording mode", + "mode_failure": "Failures only", + "mode_success": "Successes only", + "mode_all": "All requests", + "redactSensitive": "Redact sensitive data", + "retentionDays": "Retention days", + "save": "Save", + "settingsSaved": "Traffic recording settings saved", + "settingsSaveFailed": "Failed to save traffic recording settings: {message}", + "filters": "Filters", + "cleanupExpired": "Clean expired", + "cleanupComplete": "Cleaned {count} expired recordings", + "cleanupFailed": "Cleanup failed: {message}", + "statusFilter": "Status filter", + "statusAll": "All statuses", + "modelSearchPlaceholder": "Search by model name", + "apiKeyFilterPlaceholder": "Filter by API key ID", + "upstreamFilterPlaceholder": "Filter by upstream ID", + "loading": "Loading traffic recordings...", + "empty": "No traffic recordings", + "tableTime": "Time", + "tableStatus": "Status", + "tableModel": "Model", + "tablePath": "Path", + "tableSize": "Size", + "tableRedaction": "Redaction", + "tableActions": "Actions", + "viewDetail": "View", + "hideDetail": "Hide", + "delete": "Delete", + "deleteConfirmAction": "Delete recording", + "recordDeleted": "Traffic recording deleted", + "recordDeleteFailed": "Failed to delete traffic recording: {message}", + "detailTitle": "Recording detail", + "loadingDetail": "Loading recording detail...", + "detailLoadFailed": "Failed to load recording detail" + }, "upstreamFailureRules": { "title": "Global Failure Rules", "settingsDescription": "Manage shared ignore rules for upstream circuit breaker failures.", diff --git a/src/messages/zh-CN.json b/src/messages/zh-CN.json index d7543ab2..71cb9317 100644 --- a/src/messages/zh-CN.json +++ b/src/messages/zh-CN.json @@ -59,6 +59,7 @@ "system": "系统", "headerCompensation": "请求头补偿", "backgroundSync": "后台任务", + "trafficRecording": "请求录制", "globalFailureRules": "全局失败规则", "billing": "计费" }, @@ -1257,6 +1258,8 @@ "taskBillingPriceCatalogSyncDesc": "同步模型价格数据,用于请求成本和分层价格计算。", "taskUpstreamModelCatalogSync": "模型目录自动刷新", "taskUpstreamModelCatalogSyncDesc": "刷新已开启上游的模型列表缓存。", + "taskTrafficRecordingCleanup": "请求录制清理", + "taskTrafficRecordingCleanupDesc": "按请求录制保留天数删除过期录制文件与索引。", "config": "配置", "enabled": "开启", "disabled": "关闭", @@ -1295,6 +1298,55 @@ "configSaved": "后台任务配置已保存", "configSaveError": "保存任务配置失败:{message}" }, + "trafficRecording": { + "title": "请求录制", + "pageTitle": "请求录制", + "settingsDescription": "实时控制请求录制并管理已保存的录制文件。", + "description": "控制代理请求录制,查看录制文件占用,并按条件检索、查看或删除录制详情。", + "enabled": "已开启", + "disabled": "已关闭", + "redacted": "已脱敏", + "notRedacted": "未脱敏", + "recordCount": "记录数量", + "diskUsage": "磁盘占用", + "latestRecord": "最近记录", + "mode": "录制模式", + "mode_failure": "仅失败", + "mode_success": "仅成功", + "mode_all": "全部", + "redactSensitive": "敏感信息脱敏", + "retentionDays": "保留天数", + "save": "保存", + "settingsSaved": "请求录制配置已保存", + "settingsSaveFailed": "保存请求录制配置失败:{message}", + "filters": "筛选", + "cleanupExpired": "清理过期记录", + "cleanupComplete": "已清理 {count} 条过期录制", + "cleanupFailed": "清理失败:{message}", + "statusFilter": "状态码筛选", + "statusAll": "全部状态", + "modelSearchPlaceholder": "按模型名称搜索", + "apiKeyFilterPlaceholder": "按 API key ID 筛选", + "upstreamFilterPlaceholder": "按上游 ID 筛选", + "loading": "正在加载录制记录...", + "empty": "暂无录制记录", + "tableTime": "时间", + "tableStatus": "状态", + "tableModel": "模型", + "tablePath": "接口", + "tableSize": "大小", + "tableRedaction": "脱敏", + "tableActions": "操作", + "viewDetail": "查看", + "hideDetail": "收起", + "delete": "删除", + "deleteConfirmAction": "删除录制", + "recordDeleted": "录制记录已删除", + "recordDeleteFailed": "删除录制记录失败:{message}", + "detailTitle": "录制详情", + "loadingDetail": "正在加载录制详情...", + "detailLoadFailed": "录制详情加载失败" + }, "upstreamFailureRules": { "title": "全局失败规则", "settingsDescription": "管理所有上游共用的熔断失败忽略规则。", diff --git a/src/types/api.ts b/src/types/api.ts index 1f77bc9a..5f7469da 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -781,6 +781,55 @@ export type PaginatedAPIKeysResponse = PaginatedResponse; export type PaginatedUpstreamsResponse = PaginatedResponse; export type PaginatedRequestLogsResponse = PaginatedResponse; +export type TrafficRecordingMode = "all" | "success" | "failure"; + +export interface TrafficRecordingSettingsResponse { + enabled: boolean; + mode: TrafficRecordingMode; + redact_sensitive: boolean; + retention_days: number; + updated_at: string; +} + +export interface TrafficRecordingSettingsUpdate { + enabled?: boolean; + mode?: TrafficRecordingMode; + redact_sensitive?: boolean; + retention_days?: number; +} + +export interface TrafficRecordingResponse { + id: string; + request_log_id: string | null; + api_key_id: string | null; + upstream_id: string | null; + method: string | null; + path: string | null; + model: string | null; + status_code: number | null; + outcome: "success" | "failure"; + fixture_path: string; + fixture_size_bytes: number; + request_size_bytes: number; + response_size_bytes: number; + redacted: boolean; + created_at: string; +} + +export interface TrafficRecordingDetailResponse extends TrafficRecordingResponse { + fixture: unknown; +} + +export interface TrafficRecordingStatsResponse { + total: number; + total_size_bytes: number; + latest_created_at: string | null; +} + +export interface PaginatedTrafficRecordingsResponse extends PaginatedResponse { + stats: TrafficRecordingStatsResponse; +} + // ========== Billing Types ========== export interface BillingSyncResponse { diff --git a/tests/components/settings-page.test.tsx b/tests/components/settings-page.test.tsx index 6bcddd3d..e59b434c 100644 --- a/tests/components/settings-page.test.tsx +++ b/tests/components/settings-page.test.tsx @@ -82,6 +82,7 @@ vi.mock("@/components/ui/card", () => ({ vi.mock("lucide-react", () => ({ ArrowUpRight: () => , ArrowLeftRight: () => , + DatabaseZap: () => , Globe: () => , Github: () => , LogOut: () => , @@ -119,4 +120,12 @@ describe("SettingsPage", () => { expect(failureRulesLink).toHaveAttribute("href", "/system/failure-rules"); expect(screen.getByText("upstreamFailureRules.settingsDescription")).toBeInTheDocument(); }); + + it("renders traffic recording settings entry", () => { + render(); + + const trafficRecordingLink = screen.getByRole("link", { name: /trafficRecording.title/i }); + expect(trafficRecordingLink).toHaveAttribute("href", "/system/traffic-recording"); + expect(screen.getByText("trafficRecording.settingsDescription")).toBeInTheDocument(); + }); }); diff --git a/tests/components/sidebar.test.tsx b/tests/components/sidebar.test.tsx index f5c6fc1b..492361d5 100644 --- a/tests/components/sidebar.test.tsx +++ b/tests/components/sidebar.test.tsx @@ -44,6 +44,7 @@ vi.mock("lucide-react", () => ({ LogOut: () => , Settings: () => , Check: () => , + DatabaseZap: () => , Globe: () => , Sun: () => , Moon: () => , @@ -148,6 +149,7 @@ describe("Sidebar", () => { expect(screen.getAllByText("upstreams").length).toBeGreaterThan(0); expect(screen.getAllByText("settings").length).toBeGreaterThan(0); expect(screen.getAllByText("billing").length).toBeGreaterThan(0); + expect(screen.getAllByText("trafficRecording").length).toBeGreaterThan(0); expect(screen.getAllByText("globalFailureRules").length).toBeGreaterThan(0); }); @@ -160,6 +162,7 @@ describe("Sidebar", () => { expect(screen.getAllByTestId("icon-server").length).toBeGreaterThan(0); expect(screen.getAllByTestId("icon-settings").length).toBeGreaterThan(0); expect(screen.getAllByTestId("icon-wallet").length).toBeGreaterThan(0); + expect(screen.getAllByTestId("icon-database-zap").length).toBeGreaterThan(0); expect(screen.getAllByTestId("icon-shield-alert").length).toBeGreaterThan(0); }); @@ -196,6 +199,7 @@ describe("Sidebar", () => { expect(hrefs).toContain("/keys"); expect(hrefs).toContain("/upstreams"); expect(hrefs).toContain("/logs"); + expect(hrefs).toContain("/system/traffic-recording"); expect(hrefs).toContain("/system/failure-rules"); expect(hrefs).toContain(APP_REPOSITORY_URL); }); diff --git a/tests/components/traffic-recording-page.test.tsx b/tests/components/traffic-recording-page.test.tsx new file mode 100644 index 00000000..cd2e8fe0 --- /dev/null +++ b/tests/components/traffic-recording-page.test.tsx @@ -0,0 +1,244 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import TrafficRecordingPage from "@/app/[locale]/(dashboard)/system/traffic-recording/page"; + +const updateMutate = vi.fn(); +const deleteMutate = vi.fn(); +const cleanupMutate = vi.fn(); +const useTrafficRecordingsMock = vi.fn(); +const writeTextMock = vi.fn(); + +Object.assign(navigator, { + clipboard: { + writeText: writeTextMock, + }, +}); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("next-intl", () => ({ + useLocale: () => "en-US", + useTranslations: (namespace?: string) => (key: string) => + namespace ? `${namespace}.${key}` : key, +})); + +vi.mock("@/components/admin/topbar", () => ({ + Topbar: ({ title }: { title: string }) =>
{title}
, +})); + +vi.mock("@/components/admin/pagination-controls", () => ({ + PaginationControls: () =>