diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml
index 1d4ec5d8..150e62f1 100644
--- a/.github/workflows/verify.yml
+++ b/.github/workflows/verify.yml
@@ -69,7 +69,18 @@ jobs:
run: pnpm exec tsc --noEmit
- name: Unit and component tests with coverage
- run: pnpm test:run --coverage --reporter=junit --outputFile=test-report.junit.xml
+ run: pnpm test:run --coverage --reporter=default --reporter=junit --outputFile=test-report.junit.xml
+
+ - name: Upload vitest artifacts
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: vitest-report
+ path: |
+ test-report.junit.xml
+ coverage/lcov.info
+ if-no-files-found: warn
+ retention-days: 7
- name: Upload coverage to Codecov
if: ${{ !cancelled() && env.CODECOV_TOKEN != '' }}
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/changes/traffic-recording-logs-integration/.openspec.yaml b/openspec/changes/traffic-recording-logs-integration/.openspec.yaml
new file mode 100644
index 00000000..231e3abc
--- /dev/null
+++ b/openspec/changes/traffic-recording-logs-integration/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-05-18
diff --git a/openspec/changes/traffic-recording-logs-integration/design.md b/openspec/changes/traffic-recording-logs-integration/design.md
new file mode 100644
index 00000000..1e9d1821
--- /dev/null
+++ b/openspec/changes/traffic-recording-logs-integration/design.md
@@ -0,0 +1,136 @@
+## Context
+
+请求录制功能已落在 `traffic-recording-runtime-control` change 上:录制 fixture 文件存盘、`traffic_recordings` 数据库索引承载列表查询、`/system/traffic-recording` 页面承载录制管理。索引表中已经有 `request_log_id` 外键到 `request_logs.id`,但当前列表过滤器没有暴露这个字段,前端两个页面之间也没有任何跳转。
+
+请求日志页面 `/logs` 由 `src/components/admin/logs-table.tsx` 实现,行规模 3697 行,已经有 `expandedRows` 状态与 `renderExpandedDetails` 钩子,每行可以展开 failover 详情。日志 API `/api/admin/logs` 当前只支持分页 + apiKeyId/upstreamId/statusCode/start_time/end_time 五个过滤参数,没有按 ID 查询单条的入口。
+
+JSON 树形渲染组件(`RecordingJsonBlock`、`JsonTreeNode`、`JsonPrimitiveValue`、`collectExpandedJsonPaths`、`isJsonBranch`、`getJsonBranchEntries`、`getJsonBranchSummary`)目前是 `traffic-recording/page.tsx` 的私有函数,需要抽出到 `src/components/admin/recording-json-block.tsx` 才能在日志展开行里复用。
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- 管理员在日志展开行内可以直接看到对应录制的元信息与完整 fixture,无需跳页搜索。
+- 管理员在录制管理页表格行可以一键回跳到对应的原始日志,并自动展开该行。
+- 日志列表 API 支持按 ID 精确查询,录制列表 API 支持按 `request_log_id` 精确反查。
+- JSON 树形渲染组件抽出后可被两个页面共享,行为完全等价。
+
+**Non-Goals:**
+
+- 不重做日志详情,不改变现有 failover 展开区的视觉或字段。
+- 不把录制管理功能(设置、清理、删除)合并进日志页面。
+- 不实现「按 ID 分页定位」回跳,聚焦查询只返回该单条记录。
+- 不引入新的数据库表或迁移。
+
+## Decisions
+
+### 1. 共享 JSON 树形组件抽出到 `src/components/admin/recording-json-block.tsx`
+
+把 7 个相关函数与组件原样迁出,保持 props 签名不变。新文件导出 `RecordingJsonBlock`(默认 export 或具名 export 二选一,倾向具名),同时把内部辅助函数(`collectExpandedJsonPaths` 等)一并导出,便于日志表内嵌时复用展开/折叠默认行为。
+
+录制页改为 `import { RecordingJsonBlock } from "@/components/admin/recording-json-block"`,删除原文件内的函数定义。
+
+选择理由:组件已经在录制页用熟,行为可控;日志表的展开行需要相同的 JSON 树体验。直接抽出比新写一个简化版本更经济。
+
+替代方案:在日志表里用 `
{JSON.stringify(...)}` 简单渲染。问题是 fixture 可能很大、嵌套很深,没有树形折叠会让展开行高度爆炸。
+
+### 2. 录制嵌入分区的加载策略:展开即按需,两段式拉取
+
+日志表的某一行被展开时(`expandedRows.has(log.id)` 为 `true`),通过 `useTrafficRecordingByLogId(logId)` 探测:
+
+1. 先 `GET /admin/traffic-recordings?request_log_id=&page_size=1`,得到 `items[0]` 或空。
+2. 命中后再 `GET /admin/traffic-recordings/` 拉详情。
+
+四种 UI 状态:
+
+| 状态 | 触发条件 | 展示 |
+| --- | --- | --- |
+| 加载中 | 探测或详情请求处于 `isLoading` | `Loader2` + 文案 |
+| 未录制 | 探测成功,`items` 为空 | 灰色提示「该请求未被录制」+ 「打开录制管理」次要链接 |
+| 已录制 | 详情成功 | 元信息条(状态码、模型、大小、脱敏标记、创建时间)+ `RecordingJsonBlock` + 「在录制页打开」按钮 |
+| 文件缺失 | 详情请求 4xx/5xx | 错误文案 + 「删除录制索引」操作(链接到录制页) |
+
+未展开时不发请求,避免列表展开率低的场景下产生大量无谓查询。
+
+选择理由:fixture 体积可能上 MiB,所有行预拉会浪费带宽与渲染算力;列表接口扩字段为 `has_recording` 又会改 schema、影响快照测试。两段式探测把成本严格控制在「用户主动展开」上。
+
+替代方案:让 `/api/admin/logs` 直接 join `traffic_recordings` 返回 `has_recording` 标志。该方案能省一次探测请求,但侵入日志接口、扩大返回 payload,性价比不高。
+
+### 3. 录制行回跳:链接到 `/logs?focus=`
+
+录制表格行的「操作」列追加一个按钮(条件渲染),仅在 `request_log_id` 非空时显示。点击后导航到 `/logs?focus=`。
+
+日志页面解析 `focus` query 参数后:
+
+- 调用 `useRequestLogs(1, 1, { id: focusId })`,列表 API 命中则只返回单条。
+- 进入页面时把该条记录的 `id` 放入 `expandedRows` 初始集合。
+- 在列表顶部增加一个面包屑式的「聚焦提示」条:显示「正在查看日志 ``,[清除聚焦]」。点击清除后回到普通列表视图。
+
+选择理由:日志页已支持每行展开,最小改动就是在挂载时塞入 `expandedRows`。`focus` 用单条查询而非「按 ID 算所在页号」,避免在分页接口里加复杂逻辑。
+
+替代方案:在录制页内嵌一个简化的日志详情浮层。问题是日志展开行有 failover 决策细节、思考信息等复杂渲染,复用一遍代价大于直接跳转。
+
+### 4. API filter 命名与解析
+
+- 录制列表:`GET /admin/traffic-recordings?request_log_id=`,服务端将其解析为 `TrafficRecordingListFilters.requestLogId`,加入到 `listTrafficRecordings` 的 `eq(trafficRecordings.requestLogId, ...)` 条件。
+- 日志列表:`GET /admin/logs?id=`,服务端将其解析为 `ListRequestLogsFilter.id`,加入到 `listRequestLogs` 的 `eq(requestLogs.id, ...)` 条件。空字符串视为未提供(与现有 `apiKeyId` 解析一致)。
+
+选择理由:命名沿用现有 snake_case 风格,与 `api_key_id`、`upstream_id` 保持一致;服务层用 camelCase 字段。
+
+### 5. UI 布局草图
+
+日志展开行新增分区(位于现有 failover 详情之后):
+
+```text
+[原有展开内容:路由决策 / failover 历史 / 思考信息 / ...]
+
+┌─────────────────────────────────────────────────────────────────┐
+│ ▾ 请求录制 [在录制页打开 ⇗] │
+├─────────────────────────────────────────────────────────────────┤
+│ 状态码 200 · 模型 gpt-4o · 大小 12.4 KiB · 已脱敏 · 12:34:56 │
+├─────────────────────────────────────────────────────────────────┤
+│ { [展开] [折叠] [复制] │
+│ meta: { ... }, │
+│ inbound: { ... }, │
+│ outbound: { ... } │
+│ } │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+未录制状态:
+
+```text
+┌─────────────────────────────────────────────────────────────────┐
+│ 请求录制 │
+├─────────────────────────────────────────────────────────────────┤
+│ 该请求未被录制。当前录制配置:失败模式,已脱敏。 │
+│ [打开录制管理 ⇗] │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+录制页表格行新增按钮(条件渲染):
+
+```text
+| 时间 | 状态 | 模型 | 接口 | 大小 | 脱敏 | 操作 |
+| ... | 200 | ... | ... | ... | 是 | [查看详情] [打开原始日志 ⇗] [删除] |
+```
+
+日志页 `focus` 模式顶部提示条:
+
+```text
+┌─────────────────────────────────────────────────────────────────┐
+│ 正在查看单条日志 a1b2c3d4... [× 清除聚焦] │
+└─────────────────────────────────────────────────────────────────┘
+[展开的单条日志行]
+```
+
+视觉层级:录制分区沿用现有展开区的紧凑表格风格(深色面板、细边框、单一琥珀色强调),不引入新视觉单元。元信息条与状态码、模型等已有标签的颜色一致。「打开」类按钮一律次要样式(`variant="outline"` 或 `variant="ghost"`),避免抢夺主操作权重。
+
+## Risks / Trade-offs
+
+- 日志表已经 3697 行,再加一段录制分区会让组件继续膨胀 → 录制分区单独写一个 `LogRecordingSection` 子组件,文件不变长太多。
+- 共享组件抽出过程中可能不慎改变原录制页行为 → phase 1 完成后必须运行原录制页的组件测试(`tests/components/traffic-recording-page.test.tsx`)验证等价;新文件不写新功能,只搬迁。
+- 两段式拉取在日志列表大量展开时会产生 2N 次请求 → 通常一次只展开一行;多行同时展开是非典型操作;TanStack Query 自带请求去重和缓存,相同 logId 第二次展开不会重新请求。
+- 日志 API 接受 `id` 参数后,分页统计仍按全表计算可能造成「total=1, page=1, total_pages=1」与实际情况不符 → 这是聚焦模式的预期行为,不算缺陷。
+- `focus` 参数命中失败(日志已被清理)→ 日志页显示空列表 + 顶部「找不到该日志,可能已被清理」提示,并提供「清除聚焦」按钮。
+- 录制详情接口对超大 fixture 没有大小保护,内嵌在日志展开行后影响更明显 → 本 change 不解决该问题,由后续单独 change 处理。
diff --git a/openspec/changes/traffic-recording-logs-integration/proposal.md b/openspec/changes/traffic-recording-logs-integration/proposal.md
new file mode 100644
index 00000000..4227f869
--- /dev/null
+++ b/openspec/changes/traffic-recording-logs-integration/proposal.md
@@ -0,0 +1,35 @@
+## Why
+
+请求录制页与请求日志页目前完全独立:日志详情里看不到对应录制,录制列表也无法跳回原始日志,管理员想看「这条日志对应的完整请求/响应正文」必须跨页用时间或模型模糊搜索后人工核对。Issue #160 评审中提出该体验缺口,需要在不重做任意一侧详情的前提下打通两个页面。
+
+## What Changes
+
+- 抽出 `RecordingJsonBlock`、`JsonTreeNode`、`JsonPrimitiveValue`、`collectExpandedJsonPaths`、`isJsonBranch`、`getJsonBranchEntries`、`getJsonBranchSummary` 为共享组件 `src/components/admin/recording-json-block.tsx`,原录制管理页改为引用。
+- 录制列表 API 增加 `request_log_id` 过滤参数,可按日志 ID 精确反查录制记录。
+- 日志列表 API 增加 `id` 过滤参数,命中时只返回该条记录,用于支持聚焦定位。
+- 日志表展开行新增「请求录制」分区:按需用 `useQuery({ enabled: isExpanded })` 探测并加载录制详情,覆盖未录制、加载中、已录制、文件缺失四种状态;分区内复用共享的 JSON 树组件渲染 fixture,并提供「在录制页打开」入口。
+- 录制管理页表格行新增「打开原始日志」入口,仅在 `request_log_id` 非空时显示,链接到 `/logs?focus=`。
+- 日志页读取 `focus` query 参数,将其作为列表过滤条件并将该行默认放入 `expandedRows`,进入页面即展开录制分区。
+- 补齐两个方向跳转入口、加载状态与空状态的中英文翻译。
+
+## Capabilities
+
+### New Capabilities
+- `request-log-record-integration`: 在请求日志与请求录制之间提供双向定位能力,包括日志展开行的录制嵌入展示、日志列表按 ID 聚焦查询、录制行回跳日志页等需求。
+
+### Modified Capabilities
+- `traffic-recording-runtime-control`: 录制列表查询新增按 `request_log_id` 过滤的能力,扩展现有「按条件筛选录制记录」Scenario。
+
+## Impact
+
+- 后端:`src/lib/services/traffic-recording-service.ts` 的 `TrafficRecordingListFilters` 与 `listTrafficRecordings`,`src/lib/services/request-logger.ts` 的 `ListRequestLogsFilter` 与 `listRequestLogs`,对应 route `src/app/api/admin/traffic-recordings/route.ts`、`src/app/api/admin/logs/route.ts`。
+- 前端:
+ - 新增 `src/components/admin/recording-json-block.tsx` 共享组件。
+ - 修改 `src/app/[locale]/(dashboard)/system/traffic-recording/page.tsx` 改为引用共享组件并新增回跳按钮。
+ - 修改 `src/components/admin/logs-table.tsx` 在 `renderExpandedDetails` 内嵌录制分区。
+ - 修改 `src/app/[locale]/(dashboard)/logs/page.tsx` 处理 `focus` query 参数。
+ - 新增 hook `useTrafficRecordingByLogId`(或扩展现有 `useTrafficRecordings` 的调用方式)。
+- 国际化:`src/messages/en.json`、`src/messages/zh-CN.json` 补充录制分区、回跳按钮、聚焦提示的翻译键。
+- 类型:`src/types/api.ts` 中 `TrafficRecordingFilters`、`RequestLogFilters` 增加新字段。
+- 数据库:无 schema 变更,不需要迁移。
+- 测试:服务层 filter 测试、route 参数测试、共享组件等价行为测试、日志表录制分区组件测试、回跳与 focus 组件测试。
diff --git a/openspec/changes/traffic-recording-logs-integration/specs/request-log-record-integration/spec.md b/openspec/changes/traffic-recording-logs-integration/specs/request-log-record-integration/spec.md
new file mode 100644
index 00000000..50aa1b6b
--- /dev/null
+++ b/openspec/changes/traffic-recording-logs-integration/specs/request-log-record-integration/spec.md
@@ -0,0 +1,90 @@
+## ADDED Requirements
+
+### Requirement: 请求日志展开行必须显示对应的请求录制
+系统 MUST 在请求日志展开行内显示该日志对应的录制元信息与完整 fixture 内容,使管理员可以在不离开日志页的前提下查看请求与响应正文。
+
+#### Scenario: 已录制的日志展开
+- **WHEN** 管理员展开某条日志行
+- **AND** 该日志存在对应的请求录制
+- **THEN** 展开区 SHALL 显示录制元信息(状态码、模型、文件大小、脱敏标记、创建时间)
+- **AND** 展开区 SHALL 渲染录制 fixture 的 JSON 树
+- **AND** 展开区 SHALL 提供「在录制页打开」入口,导航到对应录制详情
+
+#### Scenario: 未录制的日志展开
+- **WHEN** 管理员展开某条日志行
+- **AND** 该日志没有对应的请求录制
+- **THEN** 展开区 SHALL 显示「该请求未被录制」状态
+- **AND** 展开区 SHALL 提供「打开录制管理」次要入口
+
+#### Scenario: 录制文件缺失
+- **WHEN** 管理员展开某条日志行
+- **AND** 该日志的录制索引存在但 fixture 文件无法读取
+- **THEN** 展开区 SHALL 显示可解释的文件缺失错误
+- **AND** 展开区 SHALL 提供前往录制页清理失效索引的入口
+
+#### Scenario: 录制查询失败
+- **WHEN** 管理员展开某条日志行
+- **AND** 录制探测或详情请求返回错误
+- **THEN** 展开区 SHALL 显示错误状态
+- **AND** 错误状态 SHALL 不阻塞日志展开行的其他内容(路由决策、failover 详情等)
+
+### Requirement: 日志展开行的录制查询必须按需触发
+系统 MUST 仅在管理员展开某条日志时才发起对应的录制查询,避免日志列表本身加载多余数据。
+
+#### Scenario: 折叠状态下不查询
+- **WHEN** 日志列表渲染完成且没有任何行被展开
+- **THEN** 系统 SHALL 不发起录制查询
+
+#### Scenario: 展开后触发查询
+- **WHEN** 管理员展开某条日志行
+- **THEN** 系统 SHALL 按该日志 ID 触发一次录制探测查询
+- **AND** 探测命中后 SHALL 触发一次录制详情查询
+
+### Requirement: 请求录制行必须能反向跳转到原始请求日志
+系统 MUST 在请求录制管理页的列表行内为存在 `request_log_id` 的记录提供回跳入口,使管理员可以从录制定位到对应日志。
+
+#### Scenario: 录制存在关联日志
+- **WHEN** 管理员查看录制管理页表格
+- **AND** 某条录制的 `request_log_id` 非空
+- **THEN** 该行 SHALL 显示「打开原始日志」入口
+- **AND** 入口 SHALL 链接到 `/logs?focus=`
+
+#### Scenario: 录制缺失关联日志
+- **WHEN** 某条录制的 `request_log_id` 为空
+- **THEN** 该行 SHALL 不显示回跳入口
+
+### Requirement: 请求日志页必须支持按 ID 聚焦查询
+系统 MUST 允许请求日志页通过 query 参数定位单条日志,并在进入时自动展开该日志的详情。
+
+#### Scenario: 通过 focus 参数进入日志页
+- **WHEN** 管理员访问 `/logs?focus=` 且该 ID 对应的日志存在
+- **THEN** 系统 SHALL 只列出该条日志
+- **AND** 系统 SHALL 在初始渲染时把该日志放入已展开集合
+- **AND** 页面顶部 SHALL 显示聚焦提示条与「清除聚焦」入口
+
+#### Scenario: focus 参数命中失败
+- **WHEN** 管理员访问 `/logs?focus=` 但该 ID 对应的日志不存在
+- **THEN** 系统 SHALL 显示「找不到该日志」提示
+- **AND** 系统 SHALL 提供「清除聚焦」入口以回到普通列表
+
+#### Scenario: 清除聚焦
+- **WHEN** 管理员在聚焦模式下点击清除聚焦
+- **THEN** 系统 SHALL 移除 `focus` query 参数
+- **AND** 系统 SHALL 恢复默认的分页日志列表
+
+### Requirement: 请求日志列表 API 必须支持按 ID 精确查询
+系统 MUST 在 `/api/admin/logs` 列表接口上提供按日志 ID 精确过滤的能力。
+
+#### Scenario: 提供有效的 id 参数
+- **WHEN** 管理员请求 `GET /api/admin/logs?id=`
+- **THEN** 接口 SHALL 只返回该条日志
+- **AND** 响应分页字段 SHALL 反映过滤后的结果
+
+#### Scenario: 提供不存在的 id 参数
+- **WHEN** 管理员请求 `GET /api/admin/logs?id=`
+- **THEN** 接口 SHALL 返回空 `items` 列表
+- **AND** 响应分页字段 SHALL 显示总数为零
+
+#### Scenario: 缺少管理员认证
+- **WHEN** 请求缺少有效的管理员认证
+- **THEN** 接口 SHALL 拒绝该请求
diff --git a/openspec/changes/traffic-recording-logs-integration/specs/traffic-recording-runtime-control/spec.md b/openspec/changes/traffic-recording-logs-integration/specs/traffic-recording-runtime-control/spec.md
new file mode 100644
index 00000000..015c02ec
--- /dev/null
+++ b/openspec/changes/traffic-recording-logs-integration/specs/traffic-recording-runtime-control/spec.md
@@ -0,0 +1,28 @@
+## MODIFIED Requirements
+
+### Requirement: 管理员必须能查询和查看请求录制记录
+系统 MUST 提供录制记录查询和详情读取能力,支持管理员按常用条件查找录制记录并查看对应 fixture 内容。
+
+#### Scenario: 分页查询录制记录
+- **WHEN** 管理员请求录制记录列表
+- **THEN** 系统 SHALL 返回按创建时间倒序排列的分页结果
+- **AND** 响应 SHALL 包含总数、当前页和总页数
+
+#### Scenario: 按条件筛选录制记录
+- **WHEN** 管理员提供时间范围、状态码、模型、API key、上游或请求日志 ID 筛选条件
+- **THEN** 系统 SHALL 只返回匹配条件的录制记录
+
+#### Scenario: 按请求日志 ID 精确反查
+- **WHEN** 管理员通过 `request_log_id` 参数请求录制列表
+- **AND** 该日志存在对应的录制记录
+- **THEN** 系统 SHALL 返回与该日志关联的录制记录
+- **WHEN** 该日志不存在对应的录制记录
+- **THEN** 系统 SHALL 返回空结果
+
+#### Scenario: 读取录制详情
+- **WHEN** 管理员请求某条录制记录详情
+- **THEN** 系统 SHALL 返回录制索引元信息和 fixture 内容
+
+#### Scenario: 录制文件缺失
+- **WHEN** 录制索引存在但对应 fixture 文件无法读取
+- **THEN** 详情接口 SHALL 返回可解释的文件缺失错误
diff --git a/openspec/changes/traffic-recording-logs-integration/tasks.md b/openspec/changes/traffic-recording-logs-integration/tasks.md
new file mode 100644
index 00000000..97521670
--- /dev/null
+++ b/openspec/changes/traffic-recording-logs-integration/tasks.md
@@ -0,0 +1,67 @@
+## 1. 共享 JSON 树形组件抽出
+
+- [x] 1.1 新建 `src/components/admin/recording-json-block.tsx`,把 `RecordingJsonBlock`、`JsonTreeNode`、`JsonPrimitiveValue`、`collectExpandedJsonPaths`、`isJsonBranch`、`getJsonBranchEntries`、`getJsonBranchSummary` 从 `src/app/[locale]/(dashboard)/system/traffic-recording/page.tsx` 原样迁出,保持 props 与行为不变。
+- [x] 1.2 把录制页改为引用共享组件,删除原文件内的函数定义,确保 import 顺序与 lint 通过。
+- [x] 1.3 为共享组件编写单元测试 `tests/components/recording-json-block.test.tsx`,覆盖空值、原始类型、对象、数组、展开/折叠、复制按钮等关键行为。
+- [x] 1.4 运行 `pnpm exec tsc --noEmit --pretty false`、`pnpm lint`、`pnpm test:run -- tests/components/recording-json-block.test.tsx tests/components/traffic-recording-page.test.tsx`,确认抽出后原录制页行为等价。
+- [x] 1.5 提交 phase 1 代码,提交信息体现「抽出共享 JSON 树组件」。
+
+## 2. 录制列表 API 支持按 `request_log_id` 过滤
+
+- [x] 2.1 在 `src/lib/services/traffic-recording-service.ts` 的 `TrafficRecordingListFilters` 中增加 `requestLogId?: string`,在 `listTrafficRecordings` 中追加 `eq(trafficRecordings.requestLogId, ...)` 条件。
+- [x] 2.2 在 `src/app/api/admin/traffic-recordings/route.ts` 中解析 `request_log_id` query 参数并传入服务层;空值视为未提供。
+- [x] 2.3 扩充 `tests/unit/services/traffic-recording-service.test.ts`,新增按 `requestLogId` 过滤命中、未命中两个用例。
+- [x] 2.4 扩充 `tests/unit/api/admin/traffic-recording-routes.test.ts`,新增 `request_log_id` 参数生效与未授权两个用例。
+- [x] 2.5 运行 `pnpm exec tsc --noEmit --pretty false`、`pnpm lint`、`pnpm test:run -- tests/unit/services/traffic-recording-service.test.ts tests/unit/api/admin/traffic-recording-routes.test.ts`。
+- [x] 2.6 提交 phase 2 代码。
+
+## 3. 日志列表 API 支持按 `id` 精确查询
+
+- [x] 3.1 在 `src/lib/services/request-logger.ts` 的 `ListRequestLogsFilter` 中增加 `id?: string`,在 `listRequestLogs` 中追加 `eq(requestLogs.id, ...)` 条件。
+- [x] 3.2 在 `src/app/api/admin/logs/route.ts` 中解析 `id` query 参数并传入服务层;空值视为未提供。
+- [x] 3.3 扩充 `tests/unit/services/request-logger.test.ts`(若不存在则新建),新增按 `id` 过滤命中、未命中两个用例。
+- [x] 3.4 扩充 `tests/unit/api/admin/logs-route.test.ts`(若不存在则新建),覆盖 `id` 参数生效与未授权两个用例。
+- [x] 3.5 运行 `pnpm exec tsc --noEmit --pretty false`、`pnpm lint`、对应测试。
+- [x] 3.6 提交 phase 3 代码。
+
+## 4. 日志展开行内嵌录制分区
+
+- [x] 4.1 在 `src/hooks/use-traffic-recording.ts` 中新增 `useTrafficRecordingByLogId(logId, enabled)`:先用 `request_log_id` filter 调用列表接口(`page_size=1`),命中后再调用详情接口;返回包含 status、recording、detail、error 的复合状态。
+- [x] 4.2 新建 `src/components/admin/log-recording-section.tsx`,承载日志展开行内的录制分区,覆盖加载中、未录制、已录制、文件缺失四种 UI 状态;复用 `RecordingJsonBlock` 渲染 fixture。
+- [x] 4.3 在 `src/components/admin/logs-table.tsx` 的 `renderExpandedDetails` 末尾插入 `LogRecordingSection`,传入当前日志 ID 与 `expandedRows.has(log.id)` 作为 `enabled` 标志。
+- [x] 4.4 补齐 `src/messages/en.json`、`src/messages/zh-CN.json` 的录制分区翻译键:标题、加载中、未录制、已录制元信息标签、文件缺失、跳转按钮文案。
+- [x] 4.5 新增 `tests/components/log-recording-section.test.tsx`,覆盖四种状态。
+- [x] 4.6 扩充 `tests/components/logs-table.test.tsx`(若不存在则新建),验证展开行能挂载录制分区组件。
+- [x] 4.7 运行 `pnpm exec tsc --noEmit --pretty false`、`pnpm lint`、`pnpm format:check`、相关测试。
+- [x] 4.8 提交 phase 4 代码。
+
+## 5. 录制行回跳到日志页
+
+- [x] 5.1 在 `src/app/[locale]/(dashboard)/system/traffic-recording/page.tsx` 表格的「操作」列追加「打开原始日志」按钮,条件渲染 `request_log_id` 非空时显示,链接到 `/logs?focus=`。
+- [x] 5.2 补齐 `src/messages/en.json`、`src/messages/zh-CN.json` 中按钮文案翻译键。(`openSourceLog` 在 phase 4 已合并加入)
+- [x] 5.3 扩充 `tests/components/traffic-recording-page.test.tsx`,覆盖按钮在 `request_log_id` 非空与为空两种情况下的渲染差异。
+- [x] 5.4 运行 `pnpm exec tsc --noEmit --pretty false`、`pnpm lint`、`pnpm test:run -- tests/components/traffic-recording-page.test.tsx`。
+- [x] 5.5 提交 phase 5 代码。
+
+## 6. 日志页支持 `focus` query 参数
+
+- [x] 6.1 在 `src/app/[locale]/(dashboard)/logs/page.tsx` 中读取 `focus` query 参数,通过 `useRequestLogs(1, 1, { id: focus })` 加载单条日志。
+- [x] 6.2 把 focus 命中的日志 ID 注入 `LogsTable` 的初始 `expandedRows` 集合(需要把 `expandedRows` 提升到页面层或新增 `initialExpandedIds` prop)。
+- [x] 6.3 在页面顶部新增聚焦提示条:展示当前聚焦 ID 与「清除聚焦」按钮;命中失败时显示「找不到该日志」状态。
+- [x] 6.4 补齐 `src/messages/en.json`、`src/messages/zh-CN.json` 的聚焦提示与「清除聚焦」翻译键。
+- [x] 6.5 扩充 `tests/components/logs-page.test.tsx`(若不存在则新建),覆盖 focus 命中、focus 未命中、清除聚焦三种行为。
+- [x] 6.6 运行 `pnpm exec tsc --noEmit --pretty false`、`pnpm lint`、`pnpm format:check`、相关测试。
+- [x] 6.7 提交 phase 6 代码。
+
+## 7. 集成校验与交接
+
+- [x] 7.1 全量运行 `pnpm test:run`,修复直接相关的失败。(修复了 migrate-sqlite.test.ts 中硬编码 `Applied 11 migration(s)` 的回归,更新为 12 并加入 `0011_lush_kitty_pryde` hash)
+- [x] 7.2 运行 `pnpm exec tsc --noEmit --pretty false`、`pnpm format:check`、`pnpm lint`、`git diff --check`,整理剩余限制。
+- [x] 7.3 启动 `pnpm dev`,按以下场景手动验证并记录截图位置(仅截图,不入库):
+ - 已录制日志展开 → 显示录制分区与 JSON 树。
+ - 未录制日志展开 → 显示未录制提示。
+ - 录制行点击「打开原始日志」 → 跳转到 `/logs?focus=` 且自动展开。
+ - 在聚焦模式下点击「清除聚焦」 → 回到普通列表。
+
+ 代理实施备注:浏览器内手动验证由用户在合并前完成。代理已通过组件测试覆盖上述四种状态(`tests/components/log-recording-section.test.tsx` 覆盖录制分区四态;`tests/components/logs-page.test.tsx` 覆盖 focus 命中/未命中/清除)。
+- [x] 7.4 提交 phase 7 代码。
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)/logs/page.tsx b/src/app/[locale]/(dashboard)/logs/page.tsx
index 7568f3ef..57f537a3 100644
--- a/src/app/[locale]/(dashboard)/logs/page.tsx
+++ b/src/app/[locale]/(dashboard)/logs/page.tsx
@@ -1,14 +1,17 @@
"use client";
-import { useState, useCallback } from "react";
+import { useState, useCallback, useMemo } from "react";
+import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
-import { ScrollText } from "lucide-react";
+import { ScrollText, X } from "lucide-react";
import { LogsTable } from "@/components/admin/logs-table";
import { PaginationControls } from "@/components/admin/pagination-controls";
import { RefreshIntervalSelect } from "@/components/admin/refresh-interval-select";
import { Topbar } from "@/components/admin/topbar";
+import { Link, usePathname } from "@/i18n/navigation";
import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -124,13 +127,26 @@ export default function LogsPage() {
const t = useTranslations("logs");
const tCommon = useTranslations("common");
- const { connectionState, fallbackRefetchIntervalMs } = useRequestLogLive({ enabled: true });
+ const searchParams = useSearchParams();
+ const pathname = usePathname();
+ const focusId = searchParams.get("focus")?.trim() || null;
+
+ const { connectionState, fallbackRefetchIntervalMs } = useRequestLogLive({
+ enabled: focusId === null,
+ });
const effectiveRefetchInterval =
refetchInterval !== false ? refetchInterval : fallbackRefetchIntervalMs;
- const { data, isLoading, refetch } = useRequestLogs(page, pageSize, undefined, {
- refetchInterval: effectiveRefetchInterval,
- });
+ const focusFilters = useMemo(() => (focusId ? { id: focusId } : undefined), [focusId]);
+ const focusInitialExpanded = useMemo(() => (focusId ? [focusId] : []), [focusId]);
+ const { data, isLoading, refetch } = useRequestLogs(
+ focusId ? 1 : page,
+ focusId ? 1 : pageSize,
+ focusFilters,
+ {
+ refetchInterval: focusId ? false : effectiveRefetchInterval,
+ }
+ );
const handleIntervalChange = useCallback((interval: number | false) => {
setRefetchInterval(interval);
@@ -152,54 +168,83 @@ export default function LogsPage() {
? "animate-log-badge-connect motion-reduce:animate-none"
: "";
+ const focusedItems = data?.items ?? [];
+ const focusNotFound = focusId !== null && !isLoading && focusedItems.length === 0;
+
return (
<>
-
-
-
-
-
-
{t("management")}
+ {focusId ? (
+
+
+
+
+ {focusNotFound ? t("focusNotFound") : t("focusActive")}
+
+
{focusId}
- {t("managementDesc")}
-
-
+
+
+ {t("focusClear")}
+
+
+
+
+ ) : (
+
+
+
+
+
+ {t("management")}
+
+
{t("managementDesc")}
+
+
+ {connectionState === "live"
+ ? t("liveStatusLive")
: connectionState === "connecting"
- ? "info"
- : "warning"
- }
- className={cn("px-2 py-0.5 text-[10px] leading-none", liveStatusMotionClass)}
- >
- {connectionState === "live"
- ? t("liveStatusLive")
- : connectionState === "connecting"
- ? t("liveStatusConnecting")
- : t("liveStatusFallback")}
-
-
- {connectionState === "fallback"
- ? t("liveStatusFallbackDesc")
- : t("liveStatusLiveDesc")}
-
+ ? t("liveStatusConnecting")
+ : t("liveStatusFallback")}
+
+
+ {connectionState === "fallback"
+ ? t("liveStatusFallbackDesc")
+ : t("liveStatusLiveDesc")}
+
+
-
-
-
-
+
+
+
+ )}
{isLoading ? (
@@ -209,12 +254,16 @@ export default function LogsPage() {
<>
- {data && data.total_pages > 1 && (
+ {!focusId && data && data.total_pages > 1 && (
{
+ 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 {};
+}
+
+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("title")}
+
+
+ {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)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("filters")}
+
+
+
+
+
+
+ {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")}
+
+
+
+
+
+ {recording.request_log_id ? (
+
+ ) : null}
+ {confirmingDeleteId === recording.id ? (
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+ ))}
+
+
+ )}
+
+ {recordings.data && recordings.data.total_pages > 1 ? (
+
+ ) : null}
+
+
+
+ {selectedId ? (
+
+
+
+
+ {t("detailTitle")}
+
+ {detail.isLoading ? (
+
+
+ {t("loadingDetail")}
+
+ ) : detail.isError ? (
+ {t("detailLoadFailed")}
+ ) : (
+
+ )}
+
+
+ ) : null}
+
+ >
+ );
+}
diff --git a/src/app/api/admin/logs/route.ts b/src/app/api/admin/logs/route.ts
index 4f5eee7c..e4793dd7 100644
--- a/src/app/api/admin/logs/route.ts
+++ b/src/app/api/admin/logs/route.ts
@@ -13,6 +13,7 @@ const log = createLogger("admin-logs");
* Query params:
* - page: number
* - page_size: number
+ * - id: string (filter - exact log id, used by /logs?focus=)
* - api_key_id: string (filter)
* - upstream_id: string (filter)
* - status_code: number (filter)
@@ -31,6 +32,9 @@ export async function GET(request: NextRequest) {
const filters: ListRequestLogsFilter = {};
+ const id = url.searchParams.get("id");
+ if (id) filters.id = id;
+
const apiKeyId = url.searchParams.get("api_key_id");
if (apiKeyId) filters.apiKeyId = apiKeyId;
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..3e1bc825
--- /dev/null
+++ b/src/app/api/admin/traffic-recordings/route.ts
@@ -0,0 +1,74 @@
+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 requestLogId = url.searchParams.get("request_log_id");
+ if (requestLogId) filters.requestLogId = requestLogId;
+
+ 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 {
+ if (!value) return "-";
+ return new Intl.DateTimeFormat(locale, {
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(new Date(value));
+ };
+
+ return (
+
+
+
+
+ {t("logSectionTitle")}
+
+ {result.status === "present" && result.summary ? (
+
+ ) : null}
+
+
+
+ {result.status === "idle" ? (
+
{t("logSectionIdle")}
+ ) : null}
+
+ {result.status === "loading" ? (
+
+
+ {t("logSectionLoading")}
+
+ ) : null}
+
+ {result.status === "absent" ? (
+
+ {t("logSectionAbsent")}
+
+
+ ) : null}
+
+ {result.status === "missing-file" ? (
+
+
+
+ {t("logSectionMissingFile")}
+
+
+
+ ) : null}
+
+ {result.status === "error" && result.error ? (
+
+ {t("logSectionLoadFailed", { message: result.error.message ?? "" })}
+
+ ) : null}
+
+ {result.status === "present" && result.summary && result.detail ? (
+
+
+ = 500
+ ? "error"
+ : result.summary.status_code != null && result.summary.status_code >= 400
+ ? "warning"
+ : result.summary.status_code != null && result.summary.status_code >= 200
+ ? "success"
+ : "neutral"
+ }
+ >
+ {result.summary.status_code ?? result.summary.outcome}
+
+ {result.summary.model ? (
+ {result.summary.model}
+ ) : null}
+ {formatBytes(result.summary.fixture_size_bytes)}
+
+ {result.summary.redacted ? t("redacted") : t("notRedacted")}
+
+ {formatDate(result.summary.created_at)}
+
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/admin/logs-table.tsx b/src/components/admin/logs-table.tsx
index b0f93b53..007a0905 100644
--- a/src/components/admin/logs-table.tsx
+++ b/src/components/admin/logs-table.tsx
@@ -35,6 +35,7 @@ import {
import { RoutingDecisionTimeline } from "@/components/admin/routing-decision-timeline";
import { LifecycleTrack } from "@/components/admin/lifecycle-track";
import { HeaderDiffPanel } from "@/components/logs/header-diff-panel";
+import { LogRecordingSection } from "@/components/admin/log-recording-section";
import { matchRouteCapability } from "@/lib/services/route-capability-matcher";
import { ROUTE_CAPABILITY_DEFINITIONS } from "@/lib/route-capabilities";
import { getRequestThinkingBadgeLabel } from "@/lib/utils/request-thinking-config";
@@ -42,6 +43,8 @@ import { getRequestThinkingBadgeLabel } from "@/lib/utils/request-thinking-confi
interface LogsTableProps {
logs: RequestLog[];
isLive?: boolean;
+ /** Log IDs to mark as expanded on initial mount (e.g. when arriving via /logs?focus=). */
+ initialExpandedIds?: readonly string[];
}
type PerformancePreset = "all" | "high_ttft" | "low_tps" | "slow_duration";
@@ -711,7 +714,7 @@ function getPercentile(values: number[], percentile: number): number | null {
return sorted[Math.max(0, Math.min(index, sorted.length - 1))];
}
-export function LogsTable({ logs, isLive = false }: LogsTableProps) {
+export function LogsTable({ logs, isLive = false, initialExpandedIds }: LogsTableProps) {
const t = useTranslations("logs");
const locale = useLocale();
const [desktopTableContainerElement, setDesktopTableContainerElement] =
@@ -820,7 +823,9 @@ export function LogsTable({ logs, isLive = false }: LogsTableProps) {
const [hydratedAt, setHydratedAt] = useState(null);
// Expanded rows state for failover details
- const [expandedRows, setExpandedRows] = useState>(new Set());
+ const [expandedRows, setExpandedRows] = useState>(
+ () => new Set(initialExpandedIds ?? [])
+ );
const [hasExpansionInteraction, setHasExpansionInteraction] = useState(false);
const [focusedJourneySteps, setFocusedJourneySteps] = useState>({});
const [journeyViewMode, setJourneyViewMode] = useState<"focused" | "sequential">("focused");
@@ -2848,6 +2853,9 @@ export function LogsTable({ logs, isLive = false }: LogsTableProps) {
)}
+
+ {/* Recording Section */}
+
);
};
diff --git a/src/components/admin/recording-json-block.tsx b/src/components/admin/recording-json-block.tsx
new file mode 100644
index 00000000..341cc621
--- /dev/null
+++ b/src/components/admin/recording-json-block.tsx
@@ -0,0 +1,252 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { useTranslations } from "next-intl";
+import { Check, ChevronDown, ChevronRight, Copy, FileJson } from "lucide-react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+export function isJsonBranch(value: unknown): value is Record
| unknown[] {
+ return typeof value === "object" && value !== null;
+}
+
+export function getJsonBranchEntries(value: Record | unknown[]) {
+ return Array.isArray(value)
+ ? value.map((entry, index) => [String(index), entry] as const)
+ : Object.entries(value);
+}
+
+export function getJsonBranchSummary(value: Record | unknown[]) {
+ const count = getJsonBranchEntries(value).length;
+ return Array.isArray(value) ? `Array(${count})` : `Object(${count})`;
+}
+
+export function collectExpandedJsonPaths(
+ value: unknown,
+ maxDepth: number = 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;
+}
+
+export 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)};
+}
+
+export 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}
+
+ );
+}
+
+export 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 (
+
+
+
+
+ JSON
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/admin/sidebar.tsx b/src/components/admin/sidebar.tsx
index 0e8dc7b2..cae6d224 100644
--- a/src/components/admin/sidebar.tsx
+++ b/src/components/admin/sidebar.tsx
@@ -7,6 +7,7 @@ import { useTheme } from "next-themes";
import {
Check,
ChevronLeft,
+ DatabaseZap,
Globe,
Github,
Key,
@@ -58,7 +59,12 @@ type NavigationItem = {
type SystemNavigationItem = {
href: string;
icon: typeof LayoutDashboard;
- labelKey: "headerCompensation" | "billing" | "backgroundSync" | "globalFailureRules";
+ labelKey:
+ | "headerCompensation"
+ | "billing"
+ | "backgroundSync"
+ | "trafficRecording"
+ | "globalFailureRules";
};
const navigation: NavigationItem[] = [
@@ -71,6 +77,7 @@ const navigation: NavigationItem[] = [
const systemNavigation: SystemNavigationItem[] = [
{ href: "/system/billing", icon: Wallet, labelKey: "billing" },
{ href: "/system/background-sync", icon: RefreshCw, labelKey: "backgroundSync" },
+ { href: "/system/traffic-recording", icon: DatabaseZap, labelKey: "trafficRecording" },
{ href: "/system/failure-rules", icon: ShieldAlert, labelKey: "globalFailureRules" },
{ href: "/system/header-compensation", icon: ArrowLeftRight, labelKey: "headerCompensation" },
];
diff --git a/src/hooks/use-request-logs.ts b/src/hooks/use-request-logs.ts
index d66fdc13..feb2885c 100644
--- a/src/hooks/use-request-logs.ts
+++ b/src/hooks/use-request-logs.ts
@@ -3,6 +3,7 @@ import { useAuth } from "@/providers/auth-provider";
import type { PaginatedRequestLogsResponse } from "@/types/api";
export interface RequestLogsFilters {
+ id?: string;
api_key_id?: string;
upstream_id?: string;
status_code?: number;
@@ -32,6 +33,9 @@ export function useRequestLogs(
params.set("page", String(page));
params.set("page_size", String(pageSize));
+ if (filters?.id) {
+ params.set("id", filters.id);
+ }
if (filters?.api_key_id) {
params.set("api_key_id", filters.api_key_id);
}
diff --git a/src/hooks/use-traffic-recording.ts b/src/hooks/use-traffic-recording.ts
new file mode 100644
index 00000000..056d3e03
--- /dev/null
+++ b/src/hooks/use-traffic-recording.ts
@@ -0,0 +1,230 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useTranslations } from "next-intl";
+import { toast } from "sonner";
+import { useAuth } from "@/providers/auth-provider";
+import type {
+ PaginatedTrafficRecordingsResponse,
+ TrafficRecordingDetailResponse,
+ TrafficRecordingResponse,
+ TrafficRecordingSettingsResponse,
+ TrafficRecordingSettingsUpdate,
+} from "@/types/api";
+
+export interface TrafficRecordingFilters {
+ api_key_id?: string;
+ upstream_id?: string;
+ status_code?: number;
+ model?: string;
+ start_time?: string;
+ end_time?: string;
+}
+
+type TrafficRecordingTranslator = (
+ key: string,
+ values?: Record
+) => 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 type TrafficRecordingByLogIdStatus =
+ | "idle"
+ | "loading"
+ | "absent"
+ | "present"
+ | "missing-file"
+ | "error";
+
+export interface TrafficRecordingByLogIdResult {
+ status: TrafficRecordingByLogIdStatus;
+ summary: TrafficRecordingResponse | null;
+ detail: TrafficRecordingDetailResponse | null;
+ error: Error | null;
+}
+
+/**
+ * Probe whether a request log has an associated traffic recording, and
+ * fetch its fixture detail on hit. Both queries stay disabled until the
+ * caller opts in (typically when the log row is expanded).
+ */
+export function useTrafficRecordingByLogId(
+ logId: string | null | undefined,
+ enabled: boolean
+): TrafficRecordingByLogIdResult {
+ const { apiClient } = useAuth();
+ const trimmedLogId = logId?.trim() ?? "";
+ const probeEnabled = enabled && trimmedLogId.length > 0;
+
+ const probe = useQuery({
+ queryKey: ["traffic-recording", "by-log", trimmedLogId],
+ queryFn: () => {
+ const params = new URLSearchParams();
+ params.set("request_log_id", trimmedLogId);
+ params.set("page_size", "1");
+ return apiClient.get(
+ `/admin/traffic-recordings?${params.toString()}`
+ );
+ },
+ enabled: probeEnabled,
+ });
+
+ const summary = probe.data?.items[0] ?? null;
+ const detailQuery = useQuery({
+ queryKey: ["traffic-recording", "detail", summary?.id ?? null],
+ queryFn: () =>
+ apiClient.get(
+ `/admin/traffic-recordings/${encodeURIComponent(summary?.id ?? "")}`
+ ),
+ enabled: probeEnabled && Boolean(summary?.id),
+ });
+
+ if (!probeEnabled) {
+ return { status: "idle", summary: null, detail: null, error: null };
+ }
+
+ if (probe.isLoading) {
+ return { status: "loading", summary: null, detail: null, error: null };
+ }
+
+ if (probe.isError) {
+ return {
+ status: "error",
+ summary: null,
+ detail: null,
+ error: probe.error instanceof Error ? probe.error : new Error(String(probe.error)),
+ };
+ }
+
+ if (!summary) {
+ return { status: "absent", summary: null, detail: null, error: null };
+ }
+
+ if (detailQuery.isLoading) {
+ return { status: "loading", summary, detail: null, error: null };
+ }
+
+ if (detailQuery.isError) {
+ const detailError =
+ detailQuery.error instanceof Error ? detailQuery.error : new Error(String(detailQuery.error));
+ const message = detailError.message ?? "";
+ const isMissingFile = /missing|enoent|not.*found|文件/i.test(message);
+ return {
+ status: isMissingFile ? "missing-file" : "error",
+ summary,
+ detail: null,
+ error: detailError,
+ };
+ }
+
+ return {
+ status: "present",
+ summary,
+ detail: detailQuery.data ?? null,
+ error: null,
+ };
+}
+
+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/request-logger.ts b/src/lib/services/request-logger.ts
index 8a048549..f7b20a45 100644
--- a/src/lib/services/request-logger.ts
+++ b/src/lib/services/request-logger.ts
@@ -228,6 +228,7 @@ export interface PaginatedRequestLogs {
}
export interface ListRequestLogsFilter {
+ id?: string;
apiKeyId?: string;
upstreamId?: string;
statusCode?: number;
@@ -707,6 +708,9 @@ export async function listRequestLogs(
// Build filter conditions
const conditions = [];
+ if (filters.id) {
+ conditions.push(eq(requestLogs.id, filters.id));
+ }
if (filters.apiKeyId) {
conditions.push(eq(requestLogs.apiKeyId, filters.apiKeyId));
}
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..5961de16
--- /dev/null
+++ b/src/lib/services/traffic-recording-service.ts
@@ -0,0 +1,401 @@
+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;
+ requestLogId?: 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.requestLogId)
+ conditions.push(eq(trafficRecordings.requestLogId, filters.requestLogId));
+ 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)
+ .where(whereClause);
+
+ 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..a3c4bae0 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"
},
@@ -289,6 +290,9 @@
"pageTitle": "REQUEST LOGS",
"management": "REQUEST LOGS",
"managementDesc": "View and analyze API request history",
+ "focusActive": "Viewing a single log",
+ "focusNotFound": "This log was not found - it may already have been pruned.",
+ "focusClear": "Clear focus",
"noLogs": "NO LOGS FOUND",
"noLogsDesc": "Request logs will appear here once API calls are made",
"tableTime": "Time",
@@ -1252,6 +1256,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 +1296,64 @@
"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",
+ "logSectionTitle": "Request recording",
+ "logSectionIdle": "Expand this row to load the matching recording.",
+ "logSectionLoading": "Loading recording...",
+ "logSectionAbsent": "This request was not recorded.",
+ "logSectionMissingFile": "The recording index exists but the fixture file is missing.",
+ "logSectionLoadFailed": "Failed to load recording: {message}",
+ "logSectionOpenRecordings": "Open in recordings",
+ "logSectionOpenRecordingSettings": "Open recording settings",
+ "openSourceLog": "Open source log"
+ },
"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..3e7c8184 100644
--- a/src/messages/zh-CN.json
+++ b/src/messages/zh-CN.json
@@ -59,6 +59,7 @@
"system": "系统",
"headerCompensation": "请求头补偿",
"backgroundSync": "后台任务",
+ "trafficRecording": "请求录制",
"globalFailureRules": "全局失败规则",
"billing": "计费"
},
@@ -289,6 +290,9 @@
"pageTitle": "REQUEST LOGS",
"management": "REQUEST LOGS",
"managementDesc": "查看和分析 API 请求历史记录",
+ "focusActive": "正在查看单条日志",
+ "focusNotFound": "找不到该日志,可能已被清理。",
+ "focusClear": "清除聚焦",
"noLogs": "NO LOGS FOUND",
"noLogsDesc": "API 请求日志将在发起调用后显示在这里",
"tableTime": "时间",
@@ -1257,6 +1261,8 @@
"taskBillingPriceCatalogSyncDesc": "同步模型价格数据,用于请求成本和分层价格计算。",
"taskUpstreamModelCatalogSync": "模型目录自动刷新",
"taskUpstreamModelCatalogSyncDesc": "刷新已开启上游的模型列表缓存。",
+ "taskTrafficRecordingCleanup": "请求录制清理",
+ "taskTrafficRecordingCleanupDesc": "按请求录制保留天数删除过期录制文件与索引。",
"config": "配置",
"enabled": "开启",
"disabled": "关闭",
@@ -1295,6 +1301,64 @@
"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": "录制详情加载失败",
+ "logSectionTitle": "请求录制",
+ "logSectionIdle": "展开此行后会加载对应的录制内容。",
+ "logSectionLoading": "正在加载录制内容...",
+ "logSectionAbsent": "该请求未被录制。",
+ "logSectionMissingFile": "录制索引存在,但 fixture 文件已缺失。",
+ "logSectionLoadFailed": "录制加载失败:{message}",
+ "logSectionOpenRecordings": "在录制页打开",
+ "logSectionOpenRecordingSettings": "打开录制管理",
+ "openSourceLog": "打开原始日志"
+ },
"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/log-recording-section.test.tsx b/tests/components/log-recording-section.test.tsx
new file mode 100644
index 00000000..bbce118b
--- /dev/null
+++ b/tests/components/log-recording-section.test.tsx
@@ -0,0 +1,179 @@
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { LogRecordingSection } from "@/components/admin/log-recording-section";
+import type { TrafficRecordingByLogIdResult } from "@/hooks/use-traffic-recording";
+
+const useTrafficRecordingByLogIdMock = vi.fn();
+
+vi.mock("@/hooks/use-traffic-recording", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTrafficRecordingByLogId: (...args: unknown[]) => useTrafficRecordingByLogIdMock(...args),
+ };
+});
+
+vi.mock("next-intl", () => ({
+ useLocale: () => "en-US",
+ useTranslations: (namespace?: string) => (key: string, values?: Record) => {
+ const prefix = namespace ? `${namespace}.${key}` : key;
+ return values && Object.keys(values).length > 0
+ ? `${prefix}(${JSON.stringify(values)})`
+ : prefix;
+ },
+}));
+
+vi.mock("@/i18n/navigation", () => ({
+ Link: ({ children, href }: { children: React.ReactNode; href: string }) => (
+ {children}
+ ),
+}));
+
+vi.mock("@/components/ui/badge", () => ({
+ Badge: ({ children }: { children: React.ReactNode }) => {children},
+}));
+
+vi.mock("@/components/ui/button", () => ({
+ Button: ({
+ children,
+ asChild,
+ }: {
+ children: React.ReactNode;
+ asChild?: boolean;
+ variant?: string;
+ size?: string;
+ className?: string;
+ }) => {
+ if (asChild) return <>{children}>;
+ return ;
+ },
+}));
+
+vi.mock("@/components/admin/recording-json-block", () => ({
+ RecordingJsonBlock: ({ value }: { value: unknown }) => (
+ {JSON.stringify(value)}
+ ),
+}));
+
+const baseResult = (
+ overrides: Partial
+): TrafficRecordingByLogIdResult => ({
+ status: "idle",
+ summary: null,
+ detail: null,
+ error: null,
+ ...overrides,
+});
+
+describe("LogRecordingSection", () => {
+ beforeEach(() => {
+ useTrafficRecordingByLogIdMock.mockReset();
+ });
+
+ it("renders idle state when not yet enabled", () => {
+ useTrafficRecordingByLogIdMock.mockReturnValueOnce(baseResult({ status: "idle" }));
+
+ render();
+
+ expect(screen.getByText("trafficRecording.logSectionIdle")).toBeInTheDocument();
+ });
+
+ it("renders loading state while probing", () => {
+ useTrafficRecordingByLogIdMock.mockReturnValueOnce(baseResult({ status: "loading" }));
+
+ render();
+
+ expect(screen.getByText("trafficRecording.logSectionLoading")).toBeInTheDocument();
+ });
+
+ it("renders absent state when no recording exists", () => {
+ useTrafficRecordingByLogIdMock.mockReturnValueOnce(baseResult({ status: "absent" }));
+
+ render();
+
+ expect(screen.getByText("trafficRecording.logSectionAbsent")).toBeInTheDocument();
+ expect(
+ screen.getByText("trafficRecording.logSectionOpenRecordingSettings")
+ ).toBeInTheDocument();
+ });
+
+ it("renders missing-file state with warning copy", () => {
+ useTrafficRecordingByLogIdMock.mockReturnValueOnce(
+ baseResult({
+ status: "missing-file",
+ error: new Error("Fixture file missing"),
+ })
+ );
+
+ render();
+
+ expect(screen.getByText("trafficRecording.logSectionMissingFile")).toBeInTheDocument();
+ expect(screen.getByText("trafficRecording.logSectionOpenRecordings")).toBeInTheDocument();
+ });
+
+ it("renders error state with the underlying message", () => {
+ useTrafficRecordingByLogIdMock.mockReturnValueOnce(
+ baseResult({
+ status: "error",
+ error: new Error("boom"),
+ })
+ );
+
+ render();
+
+ expect(screen.getByText(/trafficRecording\.logSectionLoadFailed.*boom/)).toBeInTheDocument();
+ });
+
+ it("renders summary metadata, fixture, and 'open in recordings' link in present state", () => {
+ useTrafficRecordingByLogIdMock.mockReturnValueOnce(
+ baseResult({
+ status: "present",
+ summary: {
+ id: "rec-1",
+ request_log_id: "log-1",
+ api_key_id: null,
+ upstream_id: null,
+ method: "POST",
+ path: "/v1/chat/completions",
+ model: "gpt-4o",
+ status_code: 200,
+ outcome: "success",
+ fixture_path: "data/.../latest.json",
+ fixture_size_bytes: 12345,
+ request_size_bytes: 100,
+ response_size_bytes: 1000,
+ redacted: true,
+ created_at: "2026-05-18T12:00:00.000Z",
+ },
+ detail: {
+ id: "rec-1",
+ request_log_id: "log-1",
+ api_key_id: null,
+ upstream_id: null,
+ method: "POST",
+ path: "/v1/chat/completions",
+ model: "gpt-4o",
+ status_code: 200,
+ outcome: "success",
+ fixture_path: "data/.../latest.json",
+ fixture_size_bytes: 12345,
+ request_size_bytes: 100,
+ response_size_bytes: 1000,
+ redacted: true,
+ created_at: "2026-05-18T12:00:00.000Z",
+ fixture: { meta: { requestId: "req-1" } },
+ },
+ })
+ );
+
+ render();
+
+ expect(screen.getByText("200")).toBeInTheDocument();
+ expect(screen.getByText("gpt-4o")).toBeInTheDocument();
+ expect(screen.getByText("12.1 KiB")).toBeInTheDocument();
+ expect(screen.getByText("trafficRecording.redacted")).toBeInTheDocument();
+ expect(screen.getByTestId("recording-json-block")).toHaveTextContent("requestId");
+ expect(screen.getByText("trafficRecording.logSectionOpenRecordings")).toBeInTheDocument();
+ });
+});
diff --git a/tests/components/logs-page.test.tsx b/tests/components/logs-page.test.tsx
new file mode 100644
index 00000000..e432c2fa
--- /dev/null
+++ b/tests/components/logs-page.test.tsx
@@ -0,0 +1,176 @@
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import LogsPage from "@/app/[locale]/(dashboard)/logs/page";
+
+const useSearchParamsMock = vi.fn();
+const useRequestLogsMock = vi.fn();
+const useRequestLogLiveMock = vi.fn();
+
+vi.mock("next/navigation", () => ({
+ useSearchParams: () => useSearchParamsMock(),
+}));
+
+vi.mock("@/i18n/navigation", () => ({
+ Link: ({ children, href }: { children: React.ReactNode; href: string }) => (
+ {children}
+ ),
+ usePathname: () => "/logs",
+}));
+
+vi.mock("next-intl", () => ({
+ useTranslations: (namespace?: string) => (key: string) =>
+ namespace ? `${namespace}.${key}` : key,
+}));
+
+vi.mock("@/hooks/use-request-logs", () => ({
+ useRequestLogs: (...args: unknown[]) => useRequestLogsMock(...args),
+}));
+
+vi.mock("@/hooks/use-request-log-live", () => ({
+ useRequestLogLive: (...args: unknown[]) => useRequestLogLiveMock(...args),
+}));
+
+vi.mock("@/components/admin/logs-table", () => ({
+ LogsTable: ({
+ logs,
+ initialExpandedIds,
+ }: {
+ logs: Array<{ id: string }>;
+ isLive?: boolean;
+ initialExpandedIds?: readonly string[];
+ }) => (
+
+ ),
+}));
+
+vi.mock("@/components/admin/pagination-controls", () => ({
+ PaginationControls: () => ,
+}));
+
+vi.mock("@/components/admin/refresh-interval-select", () => ({
+ RefreshIntervalSelect: () => ,
+}));
+
+vi.mock("@/components/admin/topbar", () => ({
+ Topbar: ({ title }: { title: string }) => {title}
,
+}));
+
+vi.mock("@/components/ui/badge", () => ({
+ Badge: ({ children }: { children: React.ReactNode }) => {children},
+}));
+
+vi.mock("@/components/ui/button", () => ({
+ Button: ({
+ children,
+ asChild,
+ }: {
+ children: React.ReactNode;
+ asChild?: boolean;
+ variant?: string;
+ size?: string;
+ }) => {
+ if (asChild) return <>{children}>;
+ return ;
+ },
+}));
+
+vi.mock("@/components/ui/card", () => ({
+ Card: ({ children }: { children: React.ReactNode }) => {children}
,
+ CardContent: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock("@/components/ui/skeleton", () => ({
+ Skeleton: () => ,
+}));
+
+vi.mock("@/components/ui/table", () => ({
+ Table: ({ children }: { children: React.ReactNode }) => ,
+ TableBody: ({ children }: { children: React.ReactNode }) => {children},
+ TableCell: ({ children }: { children: React.ReactNode }) => {children} | ,
+ TableHead: ({ children }: { children: React.ReactNode }) => {children} | ,
+ TableHeader: ({ children }: { children: React.ReactNode }) => {children},
+ TableRow: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock("lucide-react", () => ({
+ ScrollText: () => ,
+ X: () => ,
+}));
+
+function setFocusParam(value: string | null) {
+ useSearchParamsMock.mockReturnValue({
+ get: (key: string) => (key === "focus" ? value : null),
+ });
+}
+
+describe("LogsPage focus query param", () => {
+ beforeEach(() => {
+ useSearchParamsMock.mockReset();
+ useRequestLogsMock.mockReset();
+ useRequestLogLiveMock.mockReset();
+ useRequestLogLiveMock.mockReturnValue({
+ connectionState: "fallback",
+ fallbackRefetchIntervalMs: 5000,
+ });
+ });
+
+ it("renders the standard management header when no focus param is present", () => {
+ setFocusParam(null);
+ useRequestLogsMock.mockReturnValueOnce({
+ isLoading: false,
+ data: { items: [], total: 0, total_pages: 1, page: 1, page_size: 20 },
+ refetch: vi.fn(),
+ });
+
+ render();
+
+ expect(screen.getByText("logs.management")).toBeInTheDocument();
+ expect(screen.queryByText("logs.focusActive")).not.toBeInTheDocument();
+ });
+
+ it("shows focus banner and forwards id filter + initial expanded ID when focus hits", () => {
+ setFocusParam("log-1");
+ useRequestLogsMock.mockReturnValueOnce({
+ isLoading: false,
+ data: { items: [{ id: "log-1" }], total: 1, total_pages: 1, page: 1, page_size: 1 },
+ refetch: vi.fn(),
+ });
+
+ render();
+
+ expect(useRequestLogsMock).toHaveBeenCalledWith(
+ 1,
+ 1,
+ { id: "log-1" },
+ expect.objectContaining({ refetchInterval: false })
+ );
+
+ expect(screen.getByText("logs.focusActive")).toBeInTheDocument();
+ expect(screen.getByText("log-1")).toBeInTheDocument();
+ const table = screen.getByTestId("logs-table");
+ expect(table.getAttribute("data-log-count")).toBe("1");
+ expect(table.getAttribute("data-initial-expanded")).toBe("log-1");
+
+ const clearLink = screen.getByRole("link", { name: /logs.focusClear/i });
+ expect(clearLink).toHaveAttribute("href", "/logs");
+ });
+
+ it("shows 'not found' banner when focus param does not match any log", () => {
+ setFocusParam("missing");
+ useRequestLogsMock.mockReturnValueOnce({
+ isLoading: false,
+ data: { items: [], total: 0, total_pages: 1, page: 1, page_size: 1 },
+ refetch: vi.fn(),
+ });
+
+ render();
+
+ expect(screen.getByText("logs.focusNotFound")).toBeInTheDocument();
+ expect(screen.getByText("missing")).toBeInTheDocument();
+ });
+});
diff --git a/tests/components/logs-table.test.tsx b/tests/components/logs-table.test.tsx
index 1309a6d8..db594c3f 100644
--- a/tests/components/logs-table.test.tsx
+++ b/tests/components/logs-table.test.tsx
@@ -14,6 +14,13 @@ vi.mock("@/lib/date-locale", () => ({
getDateLocale: () => undefined,
}));
+// Mock the log recording section so we can assert on its mount without firing real queries.
+vi.mock("@/components/admin/log-recording-section", () => ({
+ LogRecordingSection: ({ logId, enabled }: { logId: string; enabled: boolean }) => (
+
+ ),
+}));
+
/**
* LogsTable Component Tests
*
@@ -2451,4 +2458,19 @@ describe("LogsTable", () => {
expect(screen.getByText("authorization")).toBeInTheDocument();
});
});
+
+ describe("Log Recording Section", () => {
+ it("mounts the recording section inside the expanded row with the log id and enabled flag", () => {
+ render();
+
+ expect(screen.queryByTestId("log-recording-section")).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "expandDetails" }));
+
+ const section = screen.getByTestId("log-recording-section");
+ expect(section).toBeInTheDocument();
+ expect(section.getAttribute("data-log-id")).toBe(mockLog.id);
+ expect(section.getAttribute("data-enabled")).toBe("true");
+ });
+ });
});
diff --git a/tests/components/recording-json-block.test.tsx b/tests/components/recording-json-block.test.tsx
new file mode 100644
index 00000000..4b84f454
--- /dev/null
+++ b/tests/components/recording-json-block.test.tsx
@@ -0,0 +1,171 @@
+import { fireEvent, render, screen, within } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ RecordingJsonBlock,
+ collectExpandedJsonPaths,
+ getJsonBranchEntries,
+ getJsonBranchSummary,
+ isJsonBranch,
+} from "@/components/admin/recording-json-block";
+
+const writeTextMock = vi.fn();
+const toastSuccess = vi.fn();
+const toastError = vi.fn();
+
+Object.assign(navigator, {
+ clipboard: {
+ writeText: writeTextMock,
+ },
+});
+
+vi.mock("sonner", () => ({
+ toast: {
+ success: (...args: unknown[]) => toastSuccess(...args),
+ error: (...args: unknown[]) => toastError(...args),
+ },
+}));
+
+vi.mock("next-intl", () => ({
+ useTranslations: (namespace?: string) => (key: string) =>
+ namespace ? `${namespace}.${key}` : key,
+}));
+
+vi.mock("@/components/ui/button", () => ({
+ Button: ({
+ children,
+ onClick,
+ "aria-label": ariaLabel,
+ }: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ "aria-label"?: string;
+ }) => (
+
+ ),
+}));
+
+describe("recording-json-block helpers", () => {
+ it("isJsonBranch identifies objects and arrays only", () => {
+ expect(isJsonBranch({})).toBe(true);
+ expect(isJsonBranch([])).toBe(true);
+ expect(isJsonBranch(null)).toBe(false);
+ expect(isJsonBranch("text")).toBe(false);
+ expect(isJsonBranch(42)).toBe(false);
+ expect(isJsonBranch(undefined)).toBe(false);
+ });
+
+ it("getJsonBranchEntries returns indexed entries for arrays and key/value pairs for objects", () => {
+ expect(getJsonBranchEntries(["a", "b"])).toEqual([
+ ["0", "a"],
+ ["1", "b"],
+ ]);
+ expect(getJsonBranchEntries({ foo: 1, bar: 2 })).toEqual([
+ ["foo", 1],
+ ["bar", 2],
+ ]);
+ });
+
+ it("getJsonBranchSummary reports array vs object size", () => {
+ expect(getJsonBranchSummary(["a", "b", "c"])).toBe("Array(3)");
+ expect(getJsonBranchSummary({ foo: 1, bar: 2 })).toBe("Object(2)");
+ });
+
+ it("collectExpandedJsonPaths returns root + nested branches respecting maxDepth", () => {
+ const value = { a: { b: { c: 1 } }, d: [1, 2] };
+
+ const full = collectExpandedJsonPaths(value);
+ expect(full.has("$")).toBe(true);
+ expect(full.has("$.a")).toBe(true);
+ expect(full.has("$.a.b")).toBe(true);
+ expect(full.has("$.d")).toBe(true);
+
+ const shallow = collectExpandedJsonPaths(value, 1);
+ expect(shallow.has("$")).toBe(true);
+ expect(shallow.has("$.a")).toBe(true);
+ expect(shallow.has("$.a.b")).toBe(false);
+ });
+});
+
+describe("RecordingJsonBlock component", () => {
+ beforeEach(() => {
+ writeTextMock.mockReset();
+ toastSuccess.mockReset();
+ toastError.mockReset();
+ });
+
+ it("renders primitive root value without crashing", () => {
+ render();
+ expect(screen.getByText('"hello"')).toBeInTheDocument();
+ });
+
+ it("renders null root value as literal null", () => {
+ render();
+ expect(screen.getByText("null")).toBeInTheDocument();
+ });
+
+ it("renders object root with depth-1 branches expanded by default", () => {
+ render();
+
+ expect(screen.getByText('"outer":')).toBeInTheDocument();
+ expect(screen.getByText('"inner":')).toBeInTheDocument();
+ expect(screen.getByText("Object(1)")).toBeInTheDocument();
+ });
+
+ it("expand-all reveals deeper branches that start collapsed", () => {
+ render();
+
+ expect(screen.queryByText('"leaf":')).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByText("common.expand"));
+
+ expect(screen.getByText('"leaf":')).toBeInTheDocument();
+ });
+
+ it("collapse-all hides every branch", () => {
+ render();
+
+ fireEvent.click(screen.getByText("common.expand"));
+ expect(screen.getByText('"leaf":')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByText("common.collapse"));
+ expect(screen.queryByText('"leaf":')).not.toBeInTheDocument();
+ });
+
+ it("copy button writes the formatted JSON text to clipboard and toasts success", async () => {
+ writeTextMock.mockResolvedValueOnce(undefined);
+ const value = { foo: "bar" };
+ render();
+
+ const copyButton = screen.getByRole("button", { name: "common.copy" });
+ fireEvent.click(copyButton);
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(writeTextMock).toHaveBeenCalledWith(JSON.stringify(value, null, 2));
+ expect(toastSuccess).toHaveBeenCalledWith("common.copied");
+ });
+
+ it("copy button reports failure via toast when clipboard rejects", async () => {
+ writeTextMock.mockRejectedValueOnce(new Error("clipboard blocked"));
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "common.copy" }));
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(toastError).toHaveBeenCalledWith("common.error");
+ });
+
+ it("clicking a branch toggle collapses an already expanded node", () => {
+ render();
+
+ const toggle = screen.getByRole("button", { name: /collapse outer/i });
+ fireEvent.click(toggle);
+
+ const container = toggle.parentElement?.parentElement as HTMLElement;
+ expect(within(container).getByText("Object(1)")).toBeInTheDocument();
+ });
+});
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..a9c8624b
--- /dev/null
+++ b/tests/components/traffic-recording-page.test.tsx
@@ -0,0 +1,310 @@
+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: () => ,
+}));
+
+vi.mock("@/components/dashboard/time-range-selector", () => ({
+ TimeRangeSelector: ({ onChange }: { onChange: (value: string) => void }) => (
+
+
+
+
+
+
+ ),
+}));
+
+vi.mock("@/hooks/use-traffic-recording", () => ({
+ useTrafficRecordingSettings: () => ({
+ data: {
+ enabled: true,
+ mode: "failure",
+ redact_sensitive: true,
+ retention_days: 7,
+ updated_at: "2026-01-01T00:00:00.000Z",
+ },
+ }),
+ useUpdateTrafficRecordingSettings: () => ({ mutate: updateMutate, isPending: false }),
+ useDeleteTrafficRecording: () => ({ mutate: deleteMutate, isPending: false }),
+ useCleanupTrafficRecordings: () => ({ mutate: cleanupMutate, isPending: false }),
+ useTrafficRecordings: (...args: unknown[]) => useTrafficRecordingsMock(...args),
+ useTrafficRecordingDetail: (id: string | null) => ({
+ isLoading: false,
+ isError: false,
+ data: id ? { fixture: { meta: { requestId: "req-1" } } } : undefined,
+ }),
+}));
+
+vi.mock("@/components/ui/badge", () => ({
+ Badge: ({ children }: { children: React.ReactNode }) => {children},
+}));
+
+vi.mock("@/components/ui/button", () => ({
+ Button: ({
+ children,
+ onClick,
+ disabled,
+ }: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ disabled?: boolean;
+ }) => (
+
+ ),
+}));
+
+vi.mock("@/components/ui/card", () => ({
+ Card: ({ children }: { children: React.ReactNode }) => ,
+ CardContent: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock("@/components/ui/input", () => ({
+ Input: (props: React.InputHTMLAttributes) => ,
+}));
+
+vi.mock("@/components/ui/select", () => ({
+ Select: ({ children }: { children: React.ReactNode }) => {children}
,
+ SelectContent: ({ children }: { children: React.ReactNode }) => {children}
,
+ SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => (
+
+ ),
+ SelectTrigger: ({
+ children,
+ "aria-label": ariaLabel,
+ }: {
+ children: React.ReactNode;
+ "aria-label"?: string;
+ }) => ,
+ SelectValue: () => ,
+}));
+
+vi.mock("@/components/ui/switch", () => ({
+ Switch: ({
+ checked,
+ onCheckedChange,
+ }: {
+ checked: boolean;
+ onCheckedChange: (checked: boolean) => void;
+ }) => (
+ onCheckedChange(event.target.checked)}
+ />
+ ),
+}));
+
+vi.mock("@/components/ui/table", () => ({
+ Table: ({ children }: { children: React.ReactNode }) => ,
+ TableHeader: ({ children }: { children: React.ReactNode }) => {children},
+ TableBody: ({ children }: { children: React.ReactNode }) => {children},
+ TableRow: ({ children }: { children: React.ReactNode }) => {children}
,
+ TableHead: ({ children }: { children: React.ReactNode }) => {children} | ,
+ TableCell: ({ children }: { children: React.ReactNode }) => {children} | ,
+}));
+
+vi.mock("lucide-react", () => ({
+ Check: () => ,
+ ChevronDown: () => ,
+ ChevronRight: () => ,
+ Copy: () => ,
+ DatabaseZap: () => ,
+ ExternalLink: () => ,
+ FileJson: () => ,
+ Loader2: () => ,
+ Save: () => ,
+ Search: () => ,
+ Trash2: () => ,
+}));
+
+vi.mock("@/i18n/navigation", () => ({
+ Link: ({ children, href }: { children: React.ReactNode; href: string }) => (
+ {children}
+ ),
+}));
+
+describe("TrafficRecordingPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ writeTextMock.mockResolvedValue(undefined);
+ });
+
+ it("renders runtime controls, filters, table rows, and detail view", () => {
+ useTrafficRecordingsMock.mockReturnValue({
+ isLoading: false,
+ data: {
+ items: [
+ {
+ id: "recording-1",
+ request_log_id: "log-1",
+ api_key_id: "key-1",
+ upstream_id: "upstream-1",
+ method: "POST",
+ path: "v1/chat/completions",
+ model: "gpt-4.1",
+ status_code: 200,
+ outcome: "success",
+ fixture_path: "data/traffic-recordings/openai/chat/fixture.json",
+ fixture_size_bytes: 512,
+ request_size_bytes: 64,
+ response_size_bytes: 256,
+ redacted: true,
+ created_at: "2026-01-02T00:00:00.000Z",
+ },
+ ],
+ total: 1,
+ page: 1,
+ page_size: 20,
+ total_pages: 1,
+ stats: {
+ total: 1,
+ total_size_bytes: 512,
+ latest_created_at: "2026-01-02T00:00:00.000Z",
+ },
+ },
+ });
+
+ render();
+
+ expect(screen.getByText("trafficRecording.pageTitle")).toBeInTheDocument();
+ expect(screen.getByText("trafficRecording.description")).toBeInTheDocument();
+ expect(screen.getByLabelText("trafficRecording.modelSearchPlaceholder")).toBeInTheDocument();
+ expect(screen.getByLabelText("trafficRecording.apiKeyFilterPlaceholder")).toBeInTheDocument();
+ expect(screen.getByLabelText("trafficRecording.upstreamFilterPlaceholder")).toBeInTheDocument();
+ expect(screen.getByText("dashboard.timeRange.today")).toBeInTheDocument();
+ expect(screen.getByText("dashboard.timeRange.7d")).toBeInTheDocument();
+ expect(screen.getByText("dashboard.timeRange.30d")).toBeInTheDocument();
+ expect(screen.getByText("dashboard.timeRange.custom")).toBeInTheDocument();
+ expect(screen.getByText("gpt-4.1")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: /trafficRecording.viewDetail/i }));
+ expect(screen.getByText('"meta":')).toBeInTheDocument();
+ expect(screen.getByText('"requestId":')).toBeInTheDocument();
+ expect(screen.getByText('"req-1"')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "common.copy" }));
+ expect(writeTextMock).toHaveBeenCalledWith(
+ JSON.stringify({ meta: { requestId: "req-1" } }, null, 2)
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "trafficRecording.delete" }));
+ expect(deleteMutate).not.toHaveBeenCalled();
+ expect(screen.getByRole("button", { name: "common.cancel" })).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "trafficRecording.deleteConfirmAction" })
+ ).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "common.cancel" }));
+ expect(
+ screen.queryByRole("button", { name: "trafficRecording.deleteConfirmAction" })
+ ).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "trafficRecording.delete" }));
+ fireEvent.click(screen.getByRole("button", { name: "trafficRecording.deleteConfirmAction" }));
+ expect(deleteMutate).toHaveBeenCalledWith("recording-1");
+ });
+
+ it("renders 'open source log' link only when request_log_id is present", () => {
+ useTrafficRecordingsMock.mockReturnValue({
+ isLoading: false,
+ data: {
+ items: [
+ {
+ id: "rec-with-log",
+ request_log_id: "log-1",
+ api_key_id: null,
+ upstream_id: null,
+ method: "POST",
+ path: "v1/chat/completions",
+ model: "gpt-4.1",
+ status_code: 200,
+ outcome: "success",
+ fixture_path: "data/.../latest.json",
+ fixture_size_bytes: 1,
+ request_size_bytes: 0,
+ response_size_bytes: 0,
+ redacted: true,
+ created_at: "2026-01-02T00:00:00.000Z",
+ },
+ {
+ id: "rec-without-log",
+ request_log_id: null,
+ api_key_id: null,
+ upstream_id: null,
+ method: "POST",
+ path: "v1/chat/completions",
+ model: "gpt-4.1",
+ status_code: 200,
+ outcome: "success",
+ fixture_path: "data/.../other.json",
+ fixture_size_bytes: 1,
+ request_size_bytes: 0,
+ response_size_bytes: 0,
+ redacted: true,
+ created_at: "2026-01-02T00:00:00.000Z",
+ },
+ ],
+ total: 2,
+ page: 1,
+ page_size: 20,
+ total_pages: 1,
+ stats: {
+ total: 2,
+ total_size_bytes: 2,
+ latest_created_at: "2026-01-02T00:00:00.000Z",
+ },
+ },
+ });
+
+ render();
+
+ const links = screen.getAllByRole("link", { name: /trafficRecording.openSourceLog/i });
+ expect(links).toHaveLength(1);
+ expect(links[0]).toHaveAttribute("href", "/logs?focus=log-1");
+ });
+});
diff --git a/tests/unit/api/admin/logs-route.test.ts b/tests/unit/api/admin/logs-route.test.ts
new file mode 100644
index 00000000..c464307c
--- /dev/null
+++ b/tests/unit/api/admin/logs-route.test.ts
@@ -0,0 +1,89 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { NextRequest } from "next/server";
+
+const { listRequestLogsMock } = vi.hoisted(() => ({
+ listRequestLogsMock: vi.fn(),
+}));
+
+vi.mock("@/lib/utils/auth", () => ({
+ validateAdminAuth: vi.fn((authHeader) => authHeader === "Bearer valid-token"),
+}));
+
+vi.mock("@/lib/utils/logger", () => ({
+ createLogger: () => ({
+ error: vi.fn(),
+ warn: vi.fn(),
+ info: vi.fn(),
+ debug: vi.fn(),
+ }),
+}));
+
+vi.mock("@/lib/services/request-logger", () => ({
+ listRequestLogs: (...args: unknown[]) => listRequestLogsMock(...args),
+}));
+
+vi.mock("@/lib/utils/api-transformers", () => ({
+ transformPaginatedRequestLogs: (input: unknown) => input,
+}));
+
+const AUTH_HEADER = "Bearer valid-token";
+
+describe("admin logs route", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("forwards the id query parameter into list filters", async () => {
+ const { GET } = await import("@/app/api/admin/logs/route");
+ listRequestLogsMock.mockResolvedValueOnce({
+ items: [{ id: "log-1" }],
+ total: 1,
+ page: 1,
+ pageSize: 1,
+ totalPages: 1,
+ });
+
+ const response = await GET(
+ new NextRequest("http://localhost/api/admin/logs?id=log-1&page_size=1", {
+ headers: { authorization: AUTH_HEADER },
+ })
+ );
+
+ expect(response.status).toBe(200);
+ expect(listRequestLogsMock).toHaveBeenCalledWith(
+ 1,
+ 1,
+ expect.objectContaining({ id: "log-1" })
+ );
+ });
+
+ it("ignores empty id query parameter", async () => {
+ const { GET } = await import("@/app/api/admin/logs/route");
+ listRequestLogsMock.mockResolvedValueOnce({
+ items: [],
+ total: 0,
+ page: 1,
+ pageSize: 20,
+ totalPages: 1,
+ });
+
+ const response = await GET(
+ new NextRequest("http://localhost/api/admin/logs?id=", {
+ headers: { authorization: AUTH_HEADER },
+ })
+ );
+
+ expect(response.status).toBe(200);
+ const callArgs = listRequestLogsMock.mock.calls[0][2] as Record;
+ expect(callArgs.id).toBeUndefined();
+ });
+
+ it("rejects requests without admin auth", async () => {
+ const { GET } = await import("@/app/api/admin/logs/route");
+
+ const response = await GET(new NextRequest("http://localhost/api/admin/logs?id=log-1"));
+
+ expect(response.status).toBe(401);
+ expect(listRequestLogsMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/api/admin/traffic-recording-routes.test.ts b/tests/unit/api/admin/traffic-recording-routes.test.ts
new file mode 100644
index 00000000..e125fdb2
--- /dev/null
+++ b/tests/unit/api/admin/traffic-recording-routes.test.ts
@@ -0,0 +1,278 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { NextRequest } from "next/server";
+
+const {
+ getSettingsMock,
+ updateSettingsMock,
+ listRecordingsMock,
+ getDetailMock,
+ deleteRecordingMock,
+ cleanupMock,
+} = vi.hoisted(() => ({
+ getSettingsMock: vi.fn(),
+ updateSettingsMock: vi.fn(),
+ listRecordingsMock: vi.fn(),
+ getDetailMock: vi.fn(),
+ deleteRecordingMock: vi.fn(),
+ cleanupMock: vi.fn(),
+}));
+
+vi.mock("@/lib/utils/auth", () => ({
+ validateAdminAuth: vi.fn((authHeader) => authHeader === "Bearer valid-token"),
+}));
+
+vi.mock("@/lib/utils/logger", () => ({
+ createLogger: () => ({
+ error: vi.fn(),
+ warn: vi.fn(),
+ info: vi.fn(),
+ debug: vi.fn(),
+ }),
+}));
+
+vi.mock("@/lib/services/traffic-recording-service", () => ({
+ getTrafficRecordingSettings: (...args: unknown[]) => getSettingsMock(...args),
+ updateTrafficRecordingSettings: (...args: unknown[]) => updateSettingsMock(...args),
+ listTrafficRecordings: (...args: unknown[]) => listRecordingsMock(...args),
+ getTrafficRecordingDetail: (...args: unknown[]) => getDetailMock(...args),
+ deleteTrafficRecording: (...args: unknown[]) => deleteRecordingMock(...args),
+ cleanupExpiredTrafficRecordings: (...args: unknown[]) => cleanupMock(...args),
+}));
+
+const AUTH_HEADER = "Bearer valid-token";
+
+const settings = {
+ enabled: true,
+ mode: "failure" as const,
+ redactSensitive: true,
+ retentionDays: 7,
+ updatedAt: new Date("2026-01-01T00:00:00.000Z"),
+};
+
+const recording = {
+ id: "recording-1",
+ requestLogId: "log-1",
+ apiKeyId: "key-1",
+ upstreamId: "upstream-1",
+ method: "POST",
+ path: "v1/chat/completions",
+ model: "gpt-4.1",
+ statusCode: 200,
+ outcome: "success" as const,
+ fixturePath: "data/traffic-recordings/openai/chat/fixture.json",
+ fixtureSizeBytes: 512,
+ requestSizeBytes: 64,
+ responseSizeBytes: 256,
+ redacted: true,
+ createdAt: new Date("2026-01-02T00:00:00.000Z"),
+};
+
+describe("traffic recording admin routes", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("guards settings with admin auth", async () => {
+ const { GET } = await import("@/app/api/admin/traffic-recording/settings/route");
+ const response = await GET(
+ new NextRequest("http://localhost/api/admin/traffic-recording/settings")
+ );
+
+ expect(response.status).toBe(401);
+ });
+
+ it("returns settings as API fields", async () => {
+ const { GET } = await import("@/app/api/admin/traffic-recording/settings/route");
+ getSettingsMock.mockResolvedValueOnce(settings);
+
+ const response = await GET(
+ new NextRequest("http://localhost/api/admin/traffic-recording/settings", {
+ headers: { authorization: AUTH_HEADER },
+ })
+ );
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body).toEqual({
+ enabled: true,
+ mode: "failure",
+ redact_sensitive: true,
+ retention_days: 7,
+ updated_at: "2026-01-01T00:00:00.000Z",
+ });
+ });
+
+ it("updates settings from API fields", async () => {
+ const { PATCH } = await import("@/app/api/admin/traffic-recording/settings/route");
+ updateSettingsMock.mockResolvedValueOnce({ ...settings, enabled: false, mode: "all" });
+
+ const response = await PATCH(
+ new NextRequest("http://localhost/api/admin/traffic-recording/settings", {
+ method: "PATCH",
+ headers: { authorization: AUTH_HEADER, "content-type": "application/json" },
+ body: JSON.stringify({
+ enabled: false,
+ mode: "all",
+ redact_sensitive: true,
+ retention_days: 14,
+ }),
+ })
+ );
+
+ expect(response.status).toBe(200);
+ expect(updateSettingsMock).toHaveBeenCalledWith({
+ enabled: false,
+ mode: "all",
+ redactSensitive: true,
+ retentionDays: 14,
+ });
+ });
+
+ it("lists recordings with supported filters", async () => {
+ const { GET } = await import("@/app/api/admin/traffic-recordings/route");
+ listRecordingsMock.mockResolvedValueOnce({
+ items: [recording],
+ total: 1,
+ page: 2,
+ pageSize: 10,
+ totalPages: 3,
+ stats: {
+ total: 1,
+ totalSizeBytes: 512,
+ latestCreatedAt: recording.createdAt,
+ },
+ });
+
+ const response = await GET(
+ new NextRequest(
+ "http://localhost/api/admin/traffic-recordings?page=2&page_size=10&status_code=200&model=gpt&start_time=2026-01-01T00:00:00.000Z&end_time=2026-01-03T00:00:00.000Z",
+ { headers: { authorization: AUTH_HEADER } }
+ )
+ );
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(listRecordingsMock).toHaveBeenCalledWith(
+ 2,
+ 10,
+ expect.objectContaining({
+ statusCode: 200,
+ model: "gpt",
+ startTime: new Date("2026-01-01T00:00:00.000Z"),
+ endTime: new Date("2026-01-03T00:00:00.000Z"),
+ })
+ );
+ expect(body.items[0].fixture_path).toBe(recording.fixturePath);
+ expect(body.stats.latest_created_at).toBe("2026-01-02T00:00:00.000Z");
+ });
+
+ it("forwards request_log_id query parameter into list filters", async () => {
+ const { GET } = await import("@/app/api/admin/traffic-recordings/route");
+ listRecordingsMock.mockResolvedValueOnce({
+ items: [recording],
+ total: 1,
+ page: 1,
+ pageSize: 20,
+ totalPages: 1,
+ stats: {
+ total: 1,
+ totalSizeBytes: 512,
+ latestCreatedAt: recording.createdAt,
+ },
+ });
+
+ const response = await GET(
+ new NextRequest(
+ "http://localhost/api/admin/traffic-recordings?request_log_id=log-1&page_size=1",
+ { headers: { authorization: AUTH_HEADER } }
+ )
+ );
+
+ expect(response.status).toBe(200);
+ expect(listRecordingsMock).toHaveBeenCalledWith(
+ 1,
+ 1,
+ expect.objectContaining({ requestLogId: "log-1" })
+ );
+ });
+
+ it("rejects list requests without admin auth", async () => {
+ const { GET } = await import("@/app/api/admin/traffic-recordings/route");
+
+ const response = await GET(
+ new NextRequest("http://localhost/api/admin/traffic-recordings?request_log_id=log-1")
+ );
+
+ expect(response.status).toBe(401);
+ expect(listRecordingsMock).not.toHaveBeenCalled();
+ });
+
+ it("rejects invalid list filters", async () => {
+ const { GET } = await import("@/app/api/admin/traffic-recordings/route");
+
+ const response = await GET(
+ new NextRequest("http://localhost/api/admin/traffic-recordings?status_code=abc", {
+ headers: { authorization: AUTH_HEADER },
+ })
+ );
+
+ expect(response.status).toBe(400);
+ expect(listRecordingsMock).not.toHaveBeenCalled();
+ });
+
+ it("returns recording detail and deletes recordings", async () => {
+ const route = await import("@/app/api/admin/traffic-recordings/[id]/route");
+ getDetailMock.mockResolvedValueOnce({
+ ...recording,
+ fixture: { meta: { requestId: "req-1" } },
+ });
+ deleteRecordingMock.mockResolvedValueOnce(true);
+
+ const getResponse = await route.GET(
+ new NextRequest("http://localhost/api/admin/traffic-recordings/recording-1", {
+ headers: { authorization: AUTH_HEADER },
+ }),
+ { params: Promise.resolve({ id: "recording-1" }) }
+ );
+ const getBody = await getResponse.json();
+
+ expect(getResponse.status).toBe(200);
+ expect(getBody.fixture).toEqual({ meta: { requestId: "req-1" } });
+
+ const deleteResponse = await route.DELETE(
+ new NextRequest("http://localhost/api/admin/traffic-recordings/recording-1", {
+ method: "DELETE",
+ headers: { authorization: AUTH_HEADER },
+ }),
+ { params: Promise.resolve({ id: "recording-1" }) }
+ );
+ const deleteBody = await deleteResponse.json();
+
+ expect(deleteResponse.status).toBe(200);
+ expect(deleteBody).toEqual({ deleted: true });
+ });
+
+ it("runs cleanup from the manual endpoint", async () => {
+ const { POST } = await import("@/app/api/admin/traffic-recordings/cleanup/route");
+ cleanupMock.mockResolvedValueOnce({
+ deletedCount: 2,
+ failureCount: 1,
+ errorSummary: "Failed to delete recordings: recording-3",
+ });
+
+ const response = await POST(
+ new NextRequest("http://localhost/api/admin/traffic-recordings/cleanup", {
+ method: "POST",
+ headers: { authorization: AUTH_HEADER },
+ })
+ );
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body).toEqual({
+ deleted_count: 2,
+ failure_count: 1,
+ error_summary: "Failed to delete recordings: recording-3",
+ });
+ });
+});
diff --git a/tests/unit/api/proxy/route.test.ts b/tests/unit/api/proxy/route.test.ts
index ef0fcc1e..7a2e0879 100644
--- a/tests/unit/api/proxy/route.test.ts
+++ b/tests/unit/api/proxy/route.test.ts
@@ -376,12 +376,16 @@ vi.mock("@/lib/services/traffic-recorder", () => ({
isRecorderEnabled: vi.fn(
() => process.env.RECORDER_ENABLED === "true" || process.env.RECORDER_ENABLED === "1"
),
- shouldRecordFixture: vi.fn((outcome: "success" | "failure") => {
- const enabled = process.env.RECORDER_ENABLED === "true" || process.env.RECORDER_ENABLED === "1";
- if (!enabled) return false;
- const mode = (process.env.RECORDER_MODE ?? "all").trim().toLowerCase();
- return mode === "all" || mode === outcome;
- }),
+ shouldRecordFixture: vi.fn(
+ (
+ outcome: "success" | "failure",
+ settings?: { enabled: boolean; mode: "all" | "success" | "failure" }
+ ) => {
+ if (!settings?.enabled) return false;
+ const mode = settings.mode;
+ return mode === "all" || mode === outcome;
+ }
+ ),
readRequestBody: vi.fn(async (request: Request) => {
const text = await request.clone().text();
if (!text) return { text: null, json: null, buffer: null };
@@ -397,6 +401,21 @@ vi.mock("@/lib/services/traffic-recorder", () => ({
recordTrafficFixture: vi.fn(async () => "/tmp/mock-fixture.json"),
}));
+vi.mock("@/lib/services/traffic-recording-service", () => ({
+ getTrafficRecordingSettings: vi.fn(async () => {
+ const enabled = process.env.RECORDER_ENABLED === "true" || process.env.RECORDER_ENABLED === "1";
+ const rawMode = (process.env.RECORDER_MODE ?? "all").trim().toLowerCase();
+ const mode = rawMode === "success" || rawMode === "failure" ? rawMode : "all";
+ return {
+ enabled,
+ mode,
+ redactSensitive: true,
+ retentionDays: 7,
+ updatedAt: new Date("2026-01-01T00:00:00.000Z"),
+ };
+ }),
+}));
+
vi.mock("@/lib/utils/logger", () => {
const mockLogger = {
debug: vi.fn(),
diff --git a/tests/unit/lib/schema.test.ts b/tests/unit/lib/schema.test.ts
index 04d7ad8d..3530f245 100644
--- a/tests/unit/lib/schema.test.ts
+++ b/tests/unit/lib/schema.test.ts
@@ -6,12 +6,15 @@ import {
upstreamProbeResults,
apiKeyUpstreams,
requestLogs,
+ trafficRecordingSettings,
+ trafficRecordings,
backgroundSyncTasks,
backgroundSyncTaskRuns,
apiKeysRelations,
upstreamsRelations,
apiKeyUpstreamsRelations,
requestLogsRelations,
+ trafficRecordingsRelations,
type ApiKey,
type NewApiKey,
type Upstream,
@@ -22,6 +25,10 @@ import {
type NewApiKeyUpstream,
type RequestLog,
type NewRequestLog,
+ type TrafficRecordingSettings,
+ type NewTrafficRecordingSettings,
+ type TrafficRecording,
+ type NewTrafficRecording,
type BackgroundSyncTask,
type NewBackgroundSyncTask,
type BackgroundSyncTaskRun,
@@ -440,6 +447,45 @@ describe("lib/db/schema", () => {
});
});
+ describe("traffic recording tables", () => {
+ it("has runtime settings columns", () => {
+ expect(trafficRecordingSettings.id.name).toBe("id");
+ expect(trafficRecordingSettings.enabled.name).toBe("enabled");
+ expect(trafficRecordingSettings.mode.name).toBe("mode");
+ expect(trafficRecordingSettings.redactSensitive.name).toBe("redact_sensitive");
+ expect(trafficRecordingSettings.retentionDays.name).toBe("retention_days");
+ expect(trafficRecordingSettings.updatedAt.name).toBe("updated_at");
+ });
+
+ it("has searchable recording index columns", () => {
+ expect(trafficRecordings.id.name).toBe("id");
+ expect(trafficRecordings.requestLogId.name).toBe("request_log_id");
+ expect(trafficRecordings.apiKeyId.name).toBe("api_key_id");
+ expect(trafficRecordings.upstreamId.name).toBe("upstream_id");
+ expect(trafficRecordings.method.name).toBe("method");
+ expect(trafficRecordings.path.name).toBe("path");
+ expect(trafficRecordings.model.name).toBe("model");
+ expect(trafficRecordings.statusCode.name).toBe("status_code");
+ expect(trafficRecordings.outcome.name).toBe("outcome");
+ expect(trafficRecordings.fixturePath.name).toBe("fixture_path");
+ expect(trafficRecordings.redacted.name).toBe("redacted");
+ expect(trafficRecordings.createdAt.name).toBe("created_at");
+ });
+
+ it("has indexes for common traffic recording filters", () => {
+ const tableConfig = getTableConfig(trafficRecordings);
+ const indexNames = tableConfig.indexes.map((i) => i.config.name);
+ expect(indexNames).toContain("traffic_recordings_api_key_id_idx");
+ expect(indexNames).toContain("traffic_recordings_upstream_id_idx");
+ expect(indexNames).toContain("traffic_recordings_status_code_idx");
+ expect(indexNames).toContain("traffic_recordings_model_idx");
+ expect(indexNames).toContain("traffic_recordings_created_at_idx");
+ expect(tableConfig.uniqueConstraints.map((i) => i.name)).toContain(
+ "traffic_recordings_fixture_path_unique"
+ );
+ });
+ });
+
describe("Relations", () => {
it("exports apiKeysRelations", () => {
expect(apiKeysRelations).toBeDefined();
@@ -456,6 +502,10 @@ describe("lib/db/schema", () => {
it("exports requestLogsRelations", () => {
expect(requestLogsRelations).toBeDefined();
});
+
+ it("exports trafficRecordingsRelations", () => {
+ expect(trafficRecordingsRelations).toBeDefined();
+ });
});
describe("Type exports", () => {
@@ -507,6 +557,17 @@ describe("lib/db/schema", () => {
expect(_typeCheck).toBeNull();
});
+ it("exports traffic recording types", () => {
+ const _settings: TrafficRecordingSettings | null = null;
+ const _newSettings: NewTrafficRecordingSettings | null = null;
+ const _recording: TrafficRecording | null = null;
+ const _newRecording: NewTrafficRecording | null = null;
+ expect(_settings).toBeNull();
+ expect(_newSettings).toBeNull();
+ expect(_recording).toBeNull();
+ expect(_newRecording).toBeNull();
+ });
+
it("exports background sync task types", () => {
const _task: BackgroundSyncTask | null = null;
const _newTask: NewBackgroundSyncTask | null = null;
diff --git a/tests/unit/scripts/migrate-sqlite.test.ts b/tests/unit/scripts/migrate-sqlite.test.ts
index a28c4c09..7689eba2 100644
--- a/tests/unit/scripts/migrate-sqlite.test.ts
+++ b/tests/unit/scripts/migrate-sqlite.test.ts
@@ -97,7 +97,7 @@ describe("db:migrate:sqlite", () => {
});
expect(firstRun.status).toBe(0);
- expect(firstRun.stdout).toContain("Applied 11 migration(s)");
+ expect(firstRun.stdout).toContain("Applied 12 migration(s)");
const migrations = await queryRows<{ hash: string }>(
dbPath,
@@ -115,6 +115,7 @@ describe("db:migrate:sqlite", () => {
"0008_cloudy_photon",
"0009_numerous_night_thrasher",
"0010_confused_mister_fear",
+ "0011_lush_kitty_pryde",
]);
const upstreamColumns = await queryRows<{ name: string }>(
diff --git a/tests/unit/services/request-logger-db.test.ts b/tests/unit/services/request-logger-db.test.ts
index e901be60..e110dd90 100644
--- a/tests/unit/services/request-logger-db.test.ts
+++ b/tests/unit/services/request-logger-db.test.ts
@@ -386,6 +386,77 @@ describe("request-logger (db flows)", () => {
expect(result.items[1].thinkingConfig).toBeNull();
});
+ it("listRequestLogs adds an eq filter on requestLogs.id when filters.id is set", async () => {
+ const { listRequestLogs } = await import("@/lib/services/request-logger");
+ const { eq } = await import("drizzle-orm");
+
+ const whereMock = vi.fn().mockResolvedValueOnce([{ value: 1 }]);
+ dbSelectMock.mockReturnValueOnce({
+ from: vi.fn().mockReturnValue({ where: whereMock }),
+ });
+ requestLogsFindManyMock.mockResolvedValueOnce([
+ {
+ id: "log-focus",
+ apiKeyId: null,
+ upstreamId: null,
+ upstream: null,
+ method: null,
+ path: null,
+ model: null,
+ promptTokens: 0,
+ completionTokens: 0,
+ totalTokens: 0,
+ cachedTokens: 0,
+ reasoningTokens: 0,
+ reasoningEffort: null,
+ cacheCreationTokens: 0,
+ cacheReadTokens: 0,
+ statusCode: 200,
+ durationMs: 1,
+ routingDurationMs: null,
+ errorMessage: null,
+ routingType: null,
+ priorityTier: null,
+ groupName: null,
+ lbStrategy: null,
+ failoverAttempts: 0,
+ failoverHistory: null,
+ routingDecision: null,
+ thinkingConfig: null,
+ sessionId: null,
+ affinityHit: false,
+ affinityMigrated: false,
+ ttftMs: null,
+ isStream: false,
+ sessionIdCompensated: false,
+ headerDiff: null,
+ billingSnapshot: null,
+ createdAt: new Date("2026-03-01T00:00:00.000Z"),
+ },
+ ]);
+
+ const result = await listRequestLogs(1, 1, { id: "log-focus" });
+
+ expect(eq).toHaveBeenCalledWith("id", "log-focus");
+ expect(result.total).toBe(1);
+ expect(result.items[0].id).toBe("log-focus");
+ });
+
+ it("listRequestLogs returns an empty result set when filters.id does not match", async () => {
+ const { listRequestLogs } = await import("@/lib/services/request-logger");
+
+ const whereMock = vi.fn().mockResolvedValueOnce([{ value: 0 }]);
+ dbSelectMock.mockReturnValueOnce({
+ from: vi.fn().mockReturnValue({ where: whereMock }),
+ });
+ requestLogsFindManyMock.mockResolvedValueOnce([]);
+
+ const result = await listRequestLogs(1, 1, { id: "missing" });
+
+ expect(result.total).toBe(0);
+ expect(result.items).toEqual([]);
+ });
+
it("reconcileStaleInProgressRequestLogs skips streams and persists billing snapshots", async () => {
const { reconcileStaleInProgressRequestLogs } = await import("@/lib/services/request-logger");
diff --git a/tests/unit/services/traffic-recorder.test.ts b/tests/unit/services/traffic-recorder.test.ts
index 04902fdb..75b0cfbf 100644
--- a/tests/unit/services/traffic-recorder.test.ts
+++ b/tests/unit/services/traffic-recorder.test.ts
@@ -95,89 +95,41 @@ describe("traffic recorder", () => {
});
describe("isRecorderEnabled", () => {
- it("returns true when RECORDER_ENABLED is 'true'", () => {
+ it("does not use RECORDER_ENABLED for runtime decisions", () => {
process.env.RECORDER_ENABLED = "true";
- expect(isRecorderEnabled()).toBe(true);
- });
-
- it("returns true when RECORDER_ENABLED is '1'", () => {
- process.env.RECORDER_ENABLED = "1";
- expect(isRecorderEnabled()).toBe(true);
- });
-
- it("returns false when RECORDER_ENABLED is not set", () => {
- expect(isRecorderEnabled()).toBe(false);
- });
-
- it("returns false when RECORDER_ENABLED is 'false'", () => {
- process.env.RECORDER_ENABLED = "false";
- expect(isRecorderEnabled()).toBe(false);
- });
-
- it("returns false when RECORDER_ENABLED is '0'", () => {
- process.env.RECORDER_ENABLED = "0";
- expect(isRecorderEnabled()).toBe(false);
- });
-
- it("returns false when RECORDER_ENABLED is any other value", () => {
- process.env.RECORDER_ENABLED = "yes";
expect(isRecorderEnabled()).toBe(false);
});
});
describe("getRecorderMode", () => {
- it("defaults to all when RECORDER_MODE is not set", () => {
- expect(getRecorderMode()).toBe("all");
- });
-
- it("returns success when RECORDER_MODE is success", () => {
- process.env.RECORDER_MODE = "success";
- expect(getRecorderMode()).toBe("success");
- });
-
- it("returns failure when RECORDER_MODE is failure", () => {
- process.env.RECORDER_MODE = "failure";
+ it("keeps a static failure default for deprecated env accessors", () => {
+ process.env.RECORDER_MODE = "all";
expect(getRecorderMode()).toBe("failure");
});
-
- it("returns all for invalid RECORDER_MODE values", () => {
- process.env.RECORDER_MODE = "unexpected";
- expect(getRecorderMode()).toBe("all");
- });
});
describe("shouldRecordFixture", () => {
- it("returns false when recorder is disabled", () => {
+ it("returns false without a runtime settings snapshot", () => {
expect(shouldRecordFixture("success")).toBe(false);
expect(shouldRecordFixture("failure")).toBe(false);
});
it("returns true for both outcomes in all mode", () => {
- process.env.RECORDER_ENABLED = "true";
- process.env.RECORDER_MODE = "all";
- expect(shouldRecordFixture("success")).toBe(true);
- expect(shouldRecordFixture("failure")).toBe(true);
+ const settings = { enabled: true, mode: "all" as const };
+ expect(shouldRecordFixture("success", settings)).toBe(true);
+ expect(shouldRecordFixture("failure", settings)).toBe(true);
});
it("returns true only for success in success mode", () => {
- process.env.RECORDER_ENABLED = "true";
- process.env.RECORDER_MODE = "success";
- expect(shouldRecordFixture("success")).toBe(true);
- expect(shouldRecordFixture("failure")).toBe(false);
+ const settings = { enabled: true, mode: "success" as const };
+ expect(shouldRecordFixture("success", settings)).toBe(true);
+ expect(shouldRecordFixture("failure", settings)).toBe(false);
});
it("returns true only for failure in failure mode", () => {
- process.env.RECORDER_ENABLED = "true";
- process.env.RECORDER_MODE = "failure";
- expect(shouldRecordFixture("success")).toBe(false);
- expect(shouldRecordFixture("failure")).toBe(true);
- });
-
- it("falls back to all mode for invalid values", () => {
- process.env.RECORDER_ENABLED = "true";
- process.env.RECORDER_MODE = "invalid-mode";
- expect(shouldRecordFixture("success")).toBe(true);
- expect(shouldRecordFixture("failure")).toBe(true);
+ const settings = { enabled: true, mode: "failure" as const };
+ expect(shouldRecordFixture("success", settings)).toBe(false);
+ expect(shouldRecordFixture("failure", settings)).toBe(true);
});
});
@@ -188,12 +140,12 @@ describe("traffic recorder", () => {
});
it("returns default directory when RECORDER_FIXTURES_DIR is not set", () => {
- expect(getFixtureRoot()).toBe("tests/fixtures");
+ expect(getFixtureRoot()).toBe("data/traffic-recordings");
});
it("returns default directory when RECORDER_FIXTURES_DIR is empty", () => {
process.env.RECORDER_FIXTURES_DIR = "";
- expect(getFixtureRoot()).toBe("tests/fixtures");
+ expect(getFixtureRoot()).toBe("data/traffic-recordings");
});
});
@@ -207,14 +159,9 @@ describe("traffic recorder", () => {
expect(isRecorderRedactionEnabled()).toBe(true);
});
- it("returns false when RECORDER_REDACT_SENSITIVE is 'false'", () => {
+ it("ignores RECORDER_REDACT_SENSITIVE because runtime settings control redaction", () => {
process.env.RECORDER_REDACT_SENSITIVE = "false";
- expect(isRecorderRedactionEnabled()).toBe(false);
- });
-
- it("returns false when RECORDER_REDACT_SENSITIVE is '0'", () => {
- process.env.RECORDER_REDACT_SENSITIVE = "0";
- expect(isRecorderRedactionEnabled()).toBe(false);
+ expect(isRecorderRedactionEnabled()).toBe(true);
});
});
@@ -222,39 +169,48 @@ describe("traffic recorder", () => {
it("builds correct path with valid inputs", () => {
const result = buildFixturePath("openai", "chat/completions", "2024-01-01T12-00-00");
expect(result).toBe(
- path.join("tests/fixtures", "openai", "chat_completions", "2024-01-01T12-00-00.json")
+ path.join(
+ "data/traffic-recordings",
+ "openai",
+ "chat_completions",
+ "2024-01-01T12-00-00.json"
+ )
);
});
it("sanitizes special characters in provider and route", () => {
const result = buildFixturePath("open@ai!", "chat/completions?v=1", "2024-01-01");
expect(result).toBe(
- path.join("tests/fixtures", "open_ai_", "chat_completions_v_1", "2024-01-01.json")
+ path.join("data/traffic-recordings", "open_ai_", "chat_completions_v_1", "2024-01-01.json")
);
});
it("handles empty provider and route", () => {
const result = buildFixturePath("", "", "2024-01-01");
- expect(result).toBe(path.join("tests/fixtures", "unknown", "unknown", "2024-01-01.json"));
+ expect(result).toBe(
+ path.join("data/traffic-recordings", "unknown", "unknown", "2024-01-01.json")
+ );
});
it("sanitizes slashes in route", () => {
const result = buildFixturePath("anthropic", "v1/messages", "2024-01-01");
expect(result).toBe(
- path.join("tests/fixtures", "anthropic", "v1_messages", "2024-01-01.json")
+ path.join("data/traffic-recordings", "anthropic", "v1_messages", "2024-01-01.json")
);
});
it("handles null provider and route", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = buildFixturePath(null as any, null as any, "2024-01-01");
- expect(result).toBe(path.join("tests/fixtures", "unknown", "unknown", "2024-01-01.json"));
+ expect(result).toBe(
+ path.join("data/traffic-recordings", "unknown", "unknown", "2024-01-01.json")
+ );
});
it("preserves alphanumeric characters and common separators", () => {
const result = buildFixturePath("provider-1.0", "route_v2.1", "2024-01-01");
expect(result).toBe(
- path.join("tests/fixtures", "provider-1.0", "route_v2.1", "2024-01-01.json")
+ path.join("data/traffic-recordings", "provider-1.0", "route_v2.1", "2024-01-01.json")
);
});
@@ -262,7 +218,7 @@ describe("traffic recorder", () => {
const result = buildFixturePath("open@@@ai", "chat///completions", "2024-01-01");
// The regex replaces consecutive special chars with a single underscore
expect(result).toBe(
- path.join("tests/fixtures", "open_ai", "chat_completions", "2024-01-01.json")
+ path.join("data/traffic-recordings", "open_ai", "chat_completions", "2024-01-01.json")
);
});
});
@@ -347,7 +303,7 @@ describe("traffic recorder", () => {
const baseParams = {
requestId: "test-123",
startTime: Date.now() - 100,
- provider: "openai",
+ providerType: "openai",
route: "responses",
model: "gpt-4",
inboundRequest: {
@@ -512,9 +468,8 @@ describe("traffic recorder", () => {
expect(fixture.outbound.responseSource).toBe("gateway");
});
- it("preserves sensitive fields when RECORDER_REDACT_SENSITIVE=false", () => {
- process.env.RECORDER_REDACT_SENSITIVE = "false";
- const fixture = buildFixture(baseParams);
+ it("preserves sensitive fields when runtime redaction is disabled", () => {
+ const fixture = buildFixture({ ...baseParams, redactSensitive: false });
expect(fixture.inbound.headers.authorization).toBe("Bearer sk-test");
expect(fixture.outbound.request.headers.authorization).toBe("Bearer sk-upstream");
@@ -559,10 +514,10 @@ describe("traffic recorder", () => {
expect(fixture.failover?.history[0]?.response_body_text).toBeUndefined();
});
- it("keeps failover history body when RECORDER_REDACT_SENSITIVE=false", () => {
- process.env.RECORDER_REDACT_SENSITIVE = "false";
+ it("keeps failover history body when runtime redaction is disabled", () => {
const fixture = buildFixture({
...baseParams,
+ redactSensitive: false,
failoverHistory: [
{
upstream_id: "upstream-1",
diff --git a/tests/unit/services/traffic-recording-service.test.ts b/tests/unit/services/traffic-recording-service.test.ts
new file mode 100644
index 00000000..34a4691e
--- /dev/null
+++ b/tests/unit/services/traffic-recording-service.test.ts
@@ -0,0 +1,344 @@
+import path from "node:path";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { dbInsertMock, dbUpdateMock, dbSelectMock, dbDeleteMock, findFirstMock, findManyMock } =
+ vi.hoisted(() => ({
+ dbInsertMock: vi.fn(),
+ dbUpdateMock: vi.fn(),
+ dbSelectMock: vi.fn(),
+ dbDeleteMock: vi.fn(),
+ findFirstMock: vi.fn(),
+ findManyMock: vi.fn(),
+ }));
+
+const { statMock, readFileMock, unlinkMock } = vi.hoisted(() => ({
+ statMock: vi.fn(),
+ readFileMock: vi.fn(),
+ unlinkMock: vi.fn(),
+}));
+
+vi.mock("fs/promises", () => ({
+ default: {
+ stat: statMock,
+ readFile: readFileMock,
+ unlink: unlinkMock,
+ },
+ stat: statMock,
+ readFile: readFileMock,
+ unlink: unlinkMock,
+}));
+
+vi.mock("drizzle-orm", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ and: vi.fn((...args) => ({ op: "and", args })),
+ count: vi.fn(() => ({ op: "count" })),
+ desc: vi.fn((column) => ({ op: "desc", column })),
+ eq: vi.fn((column, value) => ({ op: "eq", column, value })),
+ gte: vi.fn((column, value) => ({ op: "gte", column, value })),
+ lte: vi.fn((column, value) => ({ op: "lte", column, value })),
+ lt: vi.fn((column, value) => ({ op: "lt", column, value })),
+ sql: vi.fn((strings, ...values) => ({ raw: strings.join("?"), values })),
+ };
+});
+
+vi.mock("@/lib/db", () => ({
+ db: {
+ insert: (...args: unknown[]) => dbInsertMock(...args),
+ update: (...args: unknown[]) => dbUpdateMock(...args),
+ select: (...args: unknown[]) => dbSelectMock(...args),
+ delete: (...args: unknown[]) => dbDeleteMock(...args),
+ query: {
+ trafficRecordingSettings: {
+ findFirst: (...args: unknown[]) => findFirstMock(...args),
+ },
+ trafficRecordings: {
+ findFirst: (...args: unknown[]) => findFirstMock(...args),
+ findMany: (...args: unknown[]) => findManyMock(...args),
+ },
+ },
+ },
+ trafficRecordingSettings: {
+ id: "settings.id",
+ },
+ trafficRecordings: {
+ id: "recordings.id",
+ requestLogId: "recordings.request_log_id",
+ apiKeyId: "recordings.api_key_id",
+ upstreamId: "recordings.upstream_id",
+ statusCode: "recordings.status_code",
+ model: "recordings.model",
+ fixturePath: "recordings.fixture_path",
+ fixtureSizeBytes: "recordings.fixture_size_bytes",
+ createdAt: "recordings.created_at",
+ },
+}));
+
+vi.mock("@/lib/utils/logger", () => ({
+ createLogger: () => ({
+ warn: vi.fn(),
+ error: vi.fn(),
+ info: vi.fn(),
+ debug: vi.fn(),
+ }),
+}));
+
+function settingsRow(overrides = {}) {
+ return {
+ id: "default",
+ enabled: false,
+ mode: "failure",
+ redactSensitive: true,
+ retentionDays: 7,
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
+ updatedAt: "2026-01-02T00:00:00.000Z",
+ ...overrides,
+ };
+}
+
+function recordingRow(overrides = {}) {
+ return {
+ id: "recording-1",
+ requestLogId: "log-1",
+ apiKeyId: "key-1",
+ upstreamId: "upstream-1",
+ method: "POST",
+ path: "v1/chat/completions",
+ model: "gpt-4.1",
+ statusCode: 200,
+ outcome: "success",
+ fixturePath: "data/traffic-recordings/openai/chat/fixture.json",
+ fixtureSizeBytes: 512,
+ requestSizeBytes: 64,
+ responseSizeBytes: 256,
+ redacted: true,
+ createdAt: "2026-01-03T00:00:00.000Z",
+ ...overrides,
+ };
+}
+
+describe("traffic-recording-service", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ delete process.env.RECORDER_FIXTURES_DIR;
+ });
+
+ it("reads default settings after initializing the singleton row", async () => {
+ const { getTrafficRecordingSettings } =
+ await import("@/lib/services/traffic-recording-service");
+
+ dbInsertMock.mockReturnValueOnce({
+ values: vi.fn().mockReturnValue({
+ onConflictDoNothing: vi.fn().mockResolvedValue(undefined),
+ }),
+ });
+ findFirstMock.mockResolvedValueOnce(settingsRow());
+
+ const result = await getTrafficRecordingSettings();
+
+ expect(result).toEqual({
+ enabled: false,
+ mode: "failure",
+ redactSensitive: true,
+ retentionDays: 7,
+ updatedAt: new Date("2026-01-02T00:00:00.000Z"),
+ });
+ });
+
+ it("updates runtime settings fields", async () => {
+ const { updateTrafficRecordingSettings } =
+ await import("@/lib/services/traffic-recording-service");
+
+ dbInsertMock.mockReturnValueOnce({
+ values: vi.fn().mockReturnValue({
+ onConflictDoNothing: vi.fn().mockResolvedValue(undefined),
+ }),
+ });
+ findFirstMock.mockResolvedValueOnce(settingsRow());
+
+ const setMock = vi.fn().mockReturnValue({
+ where: vi.fn().mockReturnValue({
+ returning: vi.fn().mockResolvedValue([
+ settingsRow({
+ enabled: true,
+ mode: "all",
+ redactSensitive: false,
+ retentionDays: 14,
+ }),
+ ]),
+ }),
+ });
+ dbUpdateMock.mockReturnValueOnce({ set: setMock });
+
+ const result = await updateTrafficRecordingSettings({
+ enabled: true,
+ mode: "all",
+ redactSensitive: false,
+ retentionDays: 14,
+ });
+
+ expect(setMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ enabled: true,
+ mode: "all",
+ redactSensitive: false,
+ retentionDays: 14,
+ updatedAt: expect.any(Date),
+ })
+ );
+ expect(result.enabled).toBe(true);
+ expect(result.mode).toBe("all");
+ expect(result.redactSensitive).toBe(false);
+ });
+
+ it("creates a searchable index row with measured fixture size", async () => {
+ const { createTrafficRecordingIndex } =
+ await import("@/lib/services/traffic-recording-service");
+
+ statMock.mockResolvedValueOnce({ size: 1024 });
+ dbInsertMock.mockReturnValueOnce({
+ values: vi.fn().mockReturnValue({
+ onConflictDoUpdate: vi.fn().mockReturnValue({
+ returning: vi.fn().mockResolvedValue([recordingRow({ fixtureSizeBytes: 1024 })]),
+ }),
+ }),
+ });
+
+ const result = await createTrafficRecordingIndex({
+ requestLogId: "log-1",
+ apiKeyId: "key-1",
+ upstreamId: "upstream-1",
+ method: "POST",
+ path: "v1/chat/completions",
+ model: "gpt-4.1",
+ statusCode: 200,
+ outcome: "success",
+ fixturePath: "data/traffic-recordings/openai/chat/fixture.json",
+ requestSizeBytes: 64,
+ responseSizeBytes: 256,
+ redacted: true,
+ });
+
+ expect(statMock).toHaveBeenCalledWith("data/traffic-recordings/openai/chat/fixture.json");
+ expect(result.fixtureSizeBytes).toBe(1024);
+ expect(result.createdAt).toEqual(new Date("2026-01-03T00:00:00.000Z"));
+ });
+
+ it("lists recordings with filters and normalized stats dates", async () => {
+ const { listTrafficRecordings } = await import("@/lib/services/traffic-recording-service");
+
+ dbSelectMock
+ .mockReturnValueOnce({
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue([{ value: 1 }]),
+ }),
+ })
+ .mockReturnValueOnce({
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue([
+ {
+ totalSizeBytes: 512,
+ latestCreatedAt: "2026-01-03T00:00:00.000Z",
+ },
+ ]),
+ }),
+ });
+ findManyMock.mockResolvedValueOnce([recordingRow()]);
+
+ const result = await listTrafficRecordings(1, 20, {
+ statusCode: 200,
+ model: "gpt",
+ startTime: new Date("2026-01-01T00:00:00.000Z"),
+ endTime: new Date("2026-01-04T00:00:00.000Z"),
+ });
+
+ expect(result.total).toBe(1);
+ expect(result.items[0].model).toBe("gpt-4.1");
+ expect(result.stats.latestCreatedAt).toEqual(new Date("2026-01-03T00:00:00.000Z"));
+ });
+
+ it("passes request_log_id filter into where clause when present", async () => {
+ const { listTrafficRecordings } = await import("@/lib/services/traffic-recording-service");
+ const { eq } = await import("drizzle-orm");
+
+ dbSelectMock
+ .mockReturnValueOnce({
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue([{ value: 1 }]),
+ }),
+ })
+ .mockReturnValueOnce({
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue([{ totalSizeBytes: 128, latestCreatedAt: null }]),
+ }),
+ });
+ findManyMock.mockResolvedValueOnce([recordingRow()]);
+
+ const result = await listTrafficRecordings(1, 20, { requestLogId: "log-1" });
+
+ expect(eq).toHaveBeenCalledWith("recordings.request_log_id", "log-1");
+ expect(result.total).toBe(1);
+ });
+
+ it("returns empty result when request_log_id filter matches no row", async () => {
+ const { listTrafficRecordings } = await import("@/lib/services/traffic-recording-service");
+
+ dbSelectMock
+ .mockReturnValueOnce({
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue([{ value: 0 }]),
+ }),
+ })
+ .mockReturnValueOnce({
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockResolvedValue([{ totalSizeBytes: 0, latestCreatedAt: null }]),
+ }),
+ });
+ findManyMock.mockResolvedValueOnce([]);
+
+ const result = await listTrafficRecordings(1, 20, { requestLogId: "missing-log" });
+
+ expect(result.total).toBe(0);
+ expect(result.items).toEqual([]);
+ });
+
+ it("reads detail fixture from the configured recording root", async () => {
+ const { getTrafficRecordingDetail } = await import("@/lib/services/traffic-recording-service");
+
+ findFirstMock.mockResolvedValueOnce(recordingRow());
+ readFileMock.mockResolvedValueOnce(JSON.stringify({ meta: { requestId: "req-1" } }));
+
+ const result = await getTrafficRecordingDetail("recording-1");
+
+ expect(readFileMock).toHaveBeenCalledWith(
+ expect.stringContaining(
+ path.join("data", "traffic-recordings", "openai", "chat", "fixture.json")
+ ),
+ "utf-8"
+ );
+ expect(result?.fixture).toEqual({ meta: { requestId: "req-1" } });
+ });
+
+ it("deletes the fixture file and index row", async () => {
+ const { deleteTrafficRecording } = await import("@/lib/services/traffic-recording-service");
+
+ findFirstMock.mockResolvedValueOnce(recordingRow());
+ unlinkMock.mockResolvedValueOnce(undefined);
+ dbDeleteMock.mockReturnValueOnce({
+ where: vi.fn().mockResolvedValue(undefined),
+ });
+
+ await expect(deleteTrafficRecording("recording-1")).resolves.toBe(true);
+ expect(unlinkMock).toHaveBeenCalledTimes(1);
+ expect(dbDeleteMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("uses runtime settings when deciding whether to record traffic", async () => {
+ const { shouldRecordTraffic } = await import("@/lib/services/traffic-recording-service");
+
+ expect(shouldRecordTraffic({ enabled: false, mode: "all" }, "success")).toBe(false);
+ expect(shouldRecordTraffic({ enabled: true, mode: "failure" }, "success")).toBe(false);
+ expect(shouldRecordTraffic({ enabled: true, mode: "failure" }, "failure")).toBe(true);
+ });
+});