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 ( <>
- - -
-
-
); }; 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 ( +
+
+
+
+
+ + + +
+
+
+ +
+
+ ); +} 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: () =>