From 101c215e7d2e160bfbdfc30d3870f3b178b9c82a Mon Sep 17 00:00:00 2001 From: ding113 Date: Sat, 1 Aug 2026 18:11:24 +0800 Subject: [PATCH 1/4] feat: align active sessions and replay auditing --- drizzle/0116_gigantic_zombie.sql | 273 + drizzle/meta/0116_snapshot.json | 5305 +++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/en/dashboard.json | 11 +- messages/en/errors.json | 2 + messages/ja/dashboard.json | 11 +- messages/ja/errors.json | 2 + messages/ru/dashboard.json | 11 +- messages/ru/errors.json | 2 + messages/zh-CN/dashboard.json | 11 +- messages/zh-CN/errors.json | 2 + messages/zh-TW/dashboard.json | 11 +- messages/zh-TW/errors.json | 2 + src/actions/active-sessions.ts | 195 +- src/actions/concurrent-sessions.ts | 4 +- src/actions/session-origin-chain.ts | 52 +- src/actions/session-response.ts | 24 +- .../_components/bento/live-sessions-panel.tsx | 3 +- .../_components/error-details-dialog.test.tsx | 27 + .../components/LogicTraceTab.tsx | 20 +- .../error-details-dialog/index.tsx | 15 +- .../_components/error-details-dialog/types.ts | 4 + .../filters/active-filters-display.tsx | 8 + .../filters/status-filters.test.tsx | 80 + .../_components/filters/status-filters.tsx | 22 + .../logs/_components/filters/types.ts | 1 + .../logs/_components/usage-logs-filters.tsx | 9 +- .../_components/usage-logs-stats-panel.tsx | 1 + .../_components/usage-logs-table.test.tsx | 2 + .../usage-logs-view-virtualized.test.tsx | 113 +- .../usage-logs-view-virtualized.tsx | 2 + .../virtualized-logs-table.test.tsx | 60 + .../_components/virtualized-logs-table.tsx | 12 +- .../dashboard/logs/_utils/logs-query.test.ts | 11 +- .../dashboard/logs/_utils/logs-query.ts | 13 + .../_components/request-list-sidebar.tsx | 31 +- .../session-messages-client-actions.test.tsx | 46 +- .../_components/session-messages-client.tsx | 45 +- .../_components/active-sessions-table.tsx | 2 +- .../_components/session-messages-dialog.tsx | 8 +- src/app/api/v1/resources/sessions/handlers.ts | 28 +- src/app/api/v1/resources/sessions/router.ts | 4 +- .../api/v1/resources/usage-logs/handlers.ts | 1 + src/app/v1/_lib/proxy-handler.ts | 36 + .../_lib/proxy/affinity/affinity-recorder.ts | 10 +- .../v1/_lib/proxy/affinity/affinity-store.ts | 162 +- src/app/v1/_lib/proxy/message-service.test.ts | 24 + src/app/v1/_lib/proxy/message-service.ts | 6 + src/app/v1/_lib/proxy/provider-selector.ts | 43 +- src/app/v1/_lib/proxy/replay/replay-guard.ts | 127 +- src/app/v1/_lib/proxy/response-handler.ts | 7 + src/app/v1/_lib/proxy/session-guard.ts | 35 + src/app/v1/_lib/proxy/session.ts | 20 + src/drizzle/schema.ts | 42 +- .../api-client/v1/actions/active-sessions.ts | 27 +- .../v1/actions/session-origin-chain.ts | 17 +- .../api-client/v1/actions/session-response.ts | 13 +- src/lib/api-client/v1/openapi-types.gen.ts | 29 +- src/lib/api/v1/schemas/sessions.ts | 1 + src/lib/api/v1/schemas/usage-logs.ts | 4 + src/lib/availability/availability-service.ts | 2 + src/lib/config/env.schema.ts | 2 +- src/lib/config/system-settings-cache.ts | 11 +- src/lib/ledger-backfill/service.ts | 52 +- src/lib/ledger-backfill/trigger.sql | 21 +- src/lib/migrate.ts | 48 + .../session-replay-index-preflight.ts | 154 + src/lib/proxy-status-tracker.ts | 2 + src/lib/redis/active-session-keys.ts | 6 + src/lib/request-identity.ts | 16 + src/lib/session-request-locator.ts | 56 + src/lib/session-tracker.ts | 174 + src/lib/utils/error-messages.ts | 2 + src/repository/_shared/ledger-conditions.ts | 2 +- src/repository/_shared/usage-log-filters.ts | 9 + src/repository/cache-hit-rate-alert.ts | 2 + src/repository/key.ts | 1 + src/repository/message.ts | 270 +- src/repository/provider.ts | 2 + src/repository/usage-logs.ts | 53 +- src/types/message.ts | 16 + src/types/session.ts | 10 + tests/api/v1/sessions/sessions.test.ts | 32 +- tests/api/v1/usage-logs/usage-logs.test.ts | 41 + tests/integration/ledger-consistency.test.ts | 21 + tests/integration/usage-ledger.test.ts | 102 + .../active-sessions-detail-snapshots.test.ts | 119 + .../actions/active-sessions-requests.test.ts | 59 + .../active-sessions-special-settings.test.ts | 9 + .../active-sessions-termination.test.ts | 207 + .../session-origin-chain-integration.test.ts | 55 +- .../unit/actions/session-origin-chain.test.ts | 34 +- tests/unit/actions/session-response.test.ts | 56 + tests/unit/api/v1/api-client-actions.test.ts | 46 + .../drizzle/session-identity-indexes.test.ts | 36 + .../drizzle/session-replay-migration.test.ts | 64 + .../drizzle/usage-ledger-cost-indexes.test.ts | 32 + .../unit/i18n/session-request-errors.test.ts | 22 + tests/unit/lib/availability-service.test.ts | 4 + .../unit/lib/cache-effectiveness-gate.test.ts | 1 + .../lib/config/system-settings-cache.test.ts | 15 + tests/unit/lib/env-stream-gate-mode.test.ts | 12 + tests/unit/lib/proxy-status-tracker.test.ts | 42 + .../session-replay-index-preflight.test.ts | 151 + .../unit/lib/session-request-locator.test.ts | 61 + .../unit/lib/session-tracker-cleanup.test.ts | 64 +- tests/unit/proxy/affinity-recorder.test.ts | 9 +- tests/unit/proxy/affinity-store.test.ts | 239 +- .../connected-non-reader-lifetime.test.ts | 4 +- tests/unit/proxy/hedge-error-pipeline.test.ts | 1 + ...r-selector-affinity-ignore-session.test.ts | 168 +- ...rovider-selector-affinity-priority.test.ts | 36 +- ...r-selector-select-provider-by-type.test.ts | 1 + .../proxy-forwarder-hedge-first-byte.test.ts | 28 +- ...roxy-handler-concurrency-ownership.test.ts | 67 +- .../proxy/proxy-handler-public-errors.test.ts | 12 + .../proxy-handler-session-id-error.test.ts | 1 + tests/unit/proxy/replay-guard.test.ts | 63 +- .../cache-hit-rate-alert-integer-cast.test.ts | 39 +- .../message-aggregate-session-stats.test.ts | 11 + .../message-replay-audit-terminal.test.ts | 72 + .../message-session-readback.test.ts | 5 +- .../message-session-request-query.test.ts | 86 +- .../usage-logs-replay-filter.test.ts | 55 + .../usage-logs-replay-projection.test.ts | 194 + .../repository/warmup-stats-exclusion.test.ts | 25 +- tests/unit/usage-ledger/backfill.test.ts | 32 + tests/unit/usage-ledger/trigger.test.ts | 23 + 128 files changed, 10034 insertions(+), 429 deletions(-) create mode 100644 drizzle/0116_gigantic_zombie.sql create mode 100644 drizzle/meta/0116_snapshot.json create mode 100644 src/app/[locale]/dashboard/logs/_components/filters/status-filters.test.tsx create mode 100644 src/lib/migrations/session-replay-index-preflight.ts create mode 100644 src/lib/session-request-locator.ts create mode 100644 tests/unit/actions/active-sessions-requests.test.ts create mode 100644 tests/unit/actions/active-sessions-termination.test.ts create mode 100644 tests/unit/actions/session-response.test.ts create mode 100644 tests/unit/drizzle/session-identity-indexes.test.ts create mode 100644 tests/unit/drizzle/session-replay-migration.test.ts create mode 100644 tests/unit/i18n/session-request-errors.test.ts create mode 100644 tests/unit/lib/env-stream-gate-mode.test.ts create mode 100644 tests/unit/lib/proxy-status-tracker.test.ts create mode 100644 tests/unit/lib/session-replay-index-preflight.test.ts create mode 100644 tests/unit/lib/session-request-locator.test.ts create mode 100644 tests/unit/repository/message-replay-audit-terminal.test.ts create mode 100644 tests/unit/repository/usage-logs-replay-filter.test.ts create mode 100644 tests/unit/repository/usage-logs-replay-projection.test.ts diff --git a/drizzle/0116_gigantic_zombie.sql b/drizzle/0116_gigantic_zombie.sql new file mode 100644 index 000000000..e0423d297 --- /dev/null +++ b/drizzle/0116_gigantic_zombie.sql @@ -0,0 +1,273 @@ +ALTER TABLE "message_request" ADD COLUMN IF NOT EXISTS "session_identity" varchar(64);--> statement-breakpoint +ALTER TABLE "message_request" ADD COLUMN IF NOT EXISTS "session_identity_kind" varchar(20);--> statement-breakpoint +ALTER TABLE "message_request" ADD COLUMN IF NOT EXISTS "affinity_scope_tag" varchar(16);--> statement-breakpoint +ALTER TABLE "message_request" ADD COLUMN IF NOT EXISTS "affinity_fingerprint" varchar(64);--> statement-breakpoint +ALTER TABLE "message_request" ADD COLUMN IF NOT EXISTS "affinity_fingerprint_chain" jsonb;--> statement-breakpoint +ALTER TABLE "message_request" ADD COLUMN IF NOT EXISTS "is_replay" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "message_request" ADD COLUMN IF NOT EXISTS "replay_source_request_id" integer;--> statement-breakpoint +ALTER TABLE "usage_ledger" ADD COLUMN IF NOT EXISTS "session_identity" varchar(64);--> statement-breakpoint +ALTER TABLE "usage_ledger" ADD COLUMN IF NOT EXISTS "session_identity_kind" varchar(20);--> statement-breakpoint +ALTER TABLE "usage_ledger" ADD COLUMN IF NOT EXISTS "affinity_scope_tag" varchar(16);--> statement-breakpoint +ALTER TABLE "usage_ledger" ADD COLUMN IF NOT EXISTS "affinity_fingerprint" varchar(64);--> statement-breakpoint +ALTER TABLE "usage_ledger" ADD COLUMN IF NOT EXISTS "affinity_fingerprint_chain" jsonb;--> statement-breakpoint +ALTER TABLE "usage_ledger" ADD COLUMN IF NOT EXISTS "is_replay" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "usage_ledger" ADD COLUMN IF NOT EXISTS "replay_source_request_id" integer;--> statement-breakpoint + +-- Before 0116, Replay audit rows were identified by blocked_by='replay_serve'. +-- Convert that legacy marker to the formal audit fields without inventing unavailable provenance. +UPDATE message_request +SET is_replay = true, + blocked_by = NULL, + cost_usd = 0, + cost_breakdown = NULL +WHERE blocked_by = 'replay_serve';--> statement-breakpoint +UPDATE usage_ledger +SET is_replay = true, + blocked_by = NULL, + cost_usd = 0 +WHERE blocked_by = 'replay_serve';--> statement-breakpoint + +-- AUTO_MIGRATE prebuilds these indexes concurrently outside the Drizzle transaction. +-- The marker makes this transactional fallback a no-op after a successful preflight. +DO $$ +DECLARE + v_marker CONSTANT text := 'cch:migration:0116:session-replay-index:v1'; +BEGIN + IF obj_description(to_regclass('idx_message_request_session_identity_created_at'), 'pg_class') IS DISTINCT FROM v_marker THEN + DROP INDEX IF EXISTS "idx_message_request_session_identity_created_at"; + CREATE INDEX IF NOT EXISTS "idx_message_request_session_identity_created_at" ON "message_request" USING btree (COALESCE("session_identity", "session_id"),"created_at" DESC NULLS LAST) WHERE "message_request"."deleted_at" IS NULL; + COMMENT ON INDEX "idx_message_request_session_identity_created_at" IS 'cch:migration:0116:session-replay-index:v1'; + END IF; + + IF obj_description(to_regclass('idx_usage_ledger_session_identity_created_at'), 'pg_class') IS DISTINCT FROM v_marker THEN + DROP INDEX IF EXISTS "idx_usage_ledger_session_identity_created_at"; + CREATE INDEX IF NOT EXISTS "idx_usage_ledger_session_identity_created_at" ON "usage_ledger" USING btree (COALESCE("session_identity", "session_id"),"created_at" DESC NULLS LAST) WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false; + COMMENT ON INDEX "idx_usage_ledger_session_identity_created_at" IS 'cch:migration:0116:session-replay-index:v1'; + END IF; + + IF obj_description(to_regclass('idx_usage_ledger_user_created_at'), 'pg_class') IS DISTINCT FROM v_marker THEN + DROP INDEX IF EXISTS "idx_usage_ledger_user_created_at"; + CREATE INDEX IF NOT EXISTS "idx_usage_ledger_user_created_at" ON "usage_ledger" USING btree ("user_id","created_at") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false; + COMMENT ON INDEX "idx_usage_ledger_user_created_at" IS 'cch:migration:0116:session-replay-index:v1'; + END IF; + + IF obj_description(to_regclass('idx_usage_ledger_key_created_at'), 'pg_class') IS DISTINCT FROM v_marker THEN + DROP INDEX IF EXISTS "idx_usage_ledger_key_created_at"; + CREATE INDEX IF NOT EXISTS "idx_usage_ledger_key_created_at" ON "usage_ledger" USING btree ("key","created_at") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false; + COMMENT ON INDEX "idx_usage_ledger_key_created_at" IS 'cch:migration:0116:session-replay-index:v1'; + END IF; + + IF obj_description(to_regclass('idx_usage_ledger_provider_created_at'), 'pg_class') IS DISTINCT FROM v_marker THEN + DROP INDEX IF EXISTS "idx_usage_ledger_provider_created_at"; + CREATE INDEX IF NOT EXISTS "idx_usage_ledger_provider_created_at" ON "usage_ledger" USING btree ("final_provider_id","created_at") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false; + COMMENT ON INDEX "idx_usage_ledger_provider_created_at" IS 'cch:migration:0116:session-replay-index:v1'; + END IF; + + IF obj_description(to_regclass('idx_usage_ledger_key_cost'), 'pg_class') IS DISTINCT FROM v_marker THEN + DROP INDEX IF EXISTS "idx_usage_ledger_key_cost"; + CREATE INDEX IF NOT EXISTS "idx_usage_ledger_key_cost" ON "usage_ledger" USING btree ("key","created_at","cost_usd","endpoint") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false; + COMMENT ON INDEX "idx_usage_ledger_key_cost" IS 'cch:migration:0116:session-replay-index:v1'; + END IF; + + IF obj_description(to_regclass('idx_usage_ledger_user_cost_cover'), 'pg_class') IS DISTINCT FROM v_marker THEN + DROP INDEX IF EXISTS "idx_usage_ledger_user_cost_cover"; + CREATE INDEX IF NOT EXISTS "idx_usage_ledger_user_cost_cover" ON "usage_ledger" USING btree ("user_id","created_at","cost_usd","endpoint") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false; + COMMENT ON INDEX "idx_usage_ledger_user_cost_cover" IS 'cch:migration:0116:session-replay-index:v1'; + END IF; + + IF obj_description(to_regclass('idx_usage_ledger_provider_cost_cover'), 'pg_class') IS DISTINCT FROM v_marker THEN + DROP INDEX IF EXISTS "idx_usage_ledger_provider_cost_cover"; + CREATE INDEX IF NOT EXISTS "idx_usage_ledger_provider_cost_cover" ON "usage_ledger" USING btree ("final_provider_id","created_at","cost_usd","endpoint") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false; + COMMENT ON INDEX "idx_usage_ledger_provider_cost_cover" IS 'cch:migration:0116:session-replay-index:v1'; + END IF; + + IF obj_description(to_regclass('idx_usage_ledger_key_created_at_desc_cover'), 'pg_class') IS DISTINCT FROM v_marker THEN + DROP INDEX IF EXISTS "idx_usage_ledger_key_created_at_desc_cover"; + CREATE INDEX IF NOT EXISTS "idx_usage_ledger_key_created_at_desc_cover" ON "usage_ledger" USING btree ("key","created_at" DESC NULLS LAST,"final_provider_id") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false; + COMMENT ON INDEX "idx_usage_ledger_key_created_at_desc_cover" IS 'cch:migration:0116:session-replay-index:v1'; + END IF; +END $$;--> statement-breakpoint + +-- Existing ledger rows must receive the same identity and Replay provenance as their source request. +-- Replay cost is normalized at this projection boundary as an additional accounting safeguard. +UPDATE usage_ledger AS ul +SET session_identity = mr.session_identity, + session_identity_kind = mr.session_identity_kind, + affinity_scope_tag = mr.affinity_scope_tag, + affinity_fingerprint = mr.affinity_fingerprint, + affinity_fingerprint_chain = mr.affinity_fingerprint_chain, + is_replay = mr.is_replay, + replay_source_request_id = mr.replay_source_request_id, + cost_usd = CASE WHEN mr.is_replay THEN 0 ELSE ul.cost_usd END +FROM message_request AS mr +WHERE ul.request_id = mr.id + AND ( + ul.session_identity IS DISTINCT FROM mr.session_identity + OR ul.session_identity_kind IS DISTINCT FROM mr.session_identity_kind + OR ul.affinity_scope_tag IS DISTINCT FROM mr.affinity_scope_tag + OR ul.affinity_fingerprint IS DISTINCT FROM mr.affinity_fingerprint + OR ul.affinity_fingerprint_chain IS DISTINCT FROM mr.affinity_fingerprint_chain + OR ul.is_replay IS DISTINCT FROM mr.is_replay + OR ul.replay_source_request_id IS DISTINCT FROM mr.replay_source_request_id + OR (mr.is_replay AND ul.cost_usd IS DISTINCT FROM 0) + );--> statement-breakpoint + +CREATE OR REPLACE FUNCTION fn_upsert_usage_ledger() +RETURNS TRIGGER AS $$ +DECLARE + v_final_provider_id integer; + v_is_success boolean; + v_success_rate_outcome varchar; +BEGIN + v_success_rate_outcome := fn_compute_message_request_success_rate_outcome( + NEW.blocked_by, + NEW.status_code, + NEW.error_message, + NEW.provider_chain + ); + + IF NEW.blocked_by = 'warmup' THEN + UPDATE usage_ledger + SET blocked_by = 'warmup', + success_rate_outcome = v_success_rate_outcome, + actual_response_model = NEW.actual_response_model + WHERE request_id = NEW.id; + RETURN NEW; + END IF; + + IF LOWER(REGEXP_REPLACE(COALESCE(NEW.endpoint, ''), '/+$', '')) + IN ('/v1/messages/count_tokens', '/v1/responses/compact') THEN + DELETE FROM usage_ledger WHERE request_id = NEW.id; + RETURN NEW; + END IF; + + IF NEW.provider_chain IS NOT NULL + AND jsonb_typeof(NEW.provider_chain) = 'array' + AND jsonb_array_length(NEW.provider_chain) > 0 + AND jsonb_typeof(NEW.provider_chain -> -1) = 'object' + AND (NEW.provider_chain -> -1 ? 'id') + AND (NEW.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' THEN + v_final_provider_id := (NEW.provider_chain -> -1 ->> 'id')::integer; + ELSE + v_final_provider_id := NEW.provider_id; + END IF; + + v_is_success := (NEW.error_message IS NULL OR NEW.error_message = '') + AND (NEW.status_code IS NULL OR NEW.status_code < 400); + + INSERT INTO usage_ledger ( + request_id, user_id, key, provider_id, final_provider_id, + model, original_model, actual_response_model, endpoint, api_type, session_id, + session_identity, session_identity_kind, affinity_scope_tag, + affinity_fingerprint, affinity_fingerprint_chain, is_replay, replay_source_request_id, + status_code, is_success, success_rate_outcome, blocked_by, + cost_usd, cost_multiplier, group_cost_multiplier, + input_tokens, output_tokens, + cache_creation_input_tokens, cache_read_input_tokens, + cache_creation_5m_input_tokens, cache_creation_1h_input_tokens, + cache_ttl_applied, context_1m_applied, swap_cache_ttl_applied, + duration_ms, ttfb_ms, first_byte_ms, client_ip, created_at + ) VALUES ( + NEW.id, NEW.user_id, NEW.key, NEW.provider_id, v_final_provider_id, + NEW.model, NEW.original_model, NEW.actual_response_model, NEW.endpoint, NEW.api_type, NEW.session_id, + NEW.session_identity, NEW.session_identity_kind, NEW.affinity_scope_tag, + NEW.affinity_fingerprint, NEW.affinity_fingerprint_chain, NEW.is_replay, NEW.replay_source_request_id, + NEW.status_code, v_is_success, v_success_rate_outcome, NEW.blocked_by, + CASE WHEN NEW.is_replay THEN 0 ELSE NEW.cost_usd END, + NEW.cost_multiplier, NEW.group_cost_multiplier, + NEW.input_tokens, NEW.output_tokens, + NEW.cache_creation_input_tokens, NEW.cache_read_input_tokens, + NEW.cache_creation_5m_input_tokens, NEW.cache_creation_1h_input_tokens, + NEW.cache_ttl_applied, NEW.context_1m_applied, NEW.swap_cache_ttl_applied, + NEW.duration_ms, NEW.ttfb_ms, NEW.first_byte_ms, NEW.client_ip, NEW.created_at + ) + ON CONFLICT (request_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + key = EXCLUDED.key, + provider_id = EXCLUDED.provider_id, + final_provider_id = EXCLUDED.final_provider_id, + model = EXCLUDED.model, + original_model = EXCLUDED.original_model, + actual_response_model = EXCLUDED.actual_response_model, + endpoint = EXCLUDED.endpoint, + api_type = EXCLUDED.api_type, + session_id = EXCLUDED.session_id, + session_identity = EXCLUDED.session_identity, + session_identity_kind = EXCLUDED.session_identity_kind, + affinity_scope_tag = EXCLUDED.affinity_scope_tag, + affinity_fingerprint = EXCLUDED.affinity_fingerprint, + affinity_fingerprint_chain = EXCLUDED.affinity_fingerprint_chain, + is_replay = EXCLUDED.is_replay, + replay_source_request_id = EXCLUDED.replay_source_request_id, + status_code = EXCLUDED.status_code, + is_success = EXCLUDED.is_success, + success_rate_outcome = EXCLUDED.success_rate_outcome, + blocked_by = EXCLUDED.blocked_by, + cost_usd = EXCLUDED.cost_usd, + cost_multiplier = EXCLUDED.cost_multiplier, + group_cost_multiplier = EXCLUDED.group_cost_multiplier, + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + cache_creation_input_tokens = EXCLUDED.cache_creation_input_tokens, + cache_read_input_tokens = EXCLUDED.cache_read_input_tokens, + cache_creation_5m_input_tokens = EXCLUDED.cache_creation_5m_input_tokens, + cache_creation_1h_input_tokens = EXCLUDED.cache_creation_1h_input_tokens, + cache_ttl_applied = EXCLUDED.cache_ttl_applied, + context_1m_applied = EXCLUDED.context_1m_applied, + swap_cache_ttl_applied = EXCLUDED.swap_cache_ttl_applied, + duration_ms = EXCLUDED.duration_ms, + ttfb_ms = EXCLUDED.ttfb_ms, + first_byte_ms = EXCLUDED.first_byte_ms, + client_ip = EXCLUDED.client_ip; + + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'fn_upsert_usage_ledger failed for request_id=%: %', NEW.id, SQLERRM; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_upsert_usage_ledger ON message_request; + +CREATE TRIGGER trg_upsert_usage_ledger +AFTER INSERT OR UPDATE OF + blocked_by, + status_code, + error_message, + provider_chain, + actual_response_model, + endpoint, + provider_id, + user_id, + "key", + model, + original_model, + api_type, + session_id, + session_identity, + session_identity_kind, + affinity_scope_tag, + affinity_fingerprint, + affinity_fingerprint_chain, + is_replay, + replay_source_request_id, + cost_usd, + cost_multiplier, + group_cost_multiplier, + input_tokens, + output_tokens, + cache_creation_input_tokens, + cache_read_input_tokens, + cache_creation_5m_input_tokens, + cache_creation_1h_input_tokens, + cache_ttl_applied, + context_1m_applied, + swap_cache_ttl_applied, + duration_ms, + ttfb_ms, + first_byte_ms, + client_ip, + created_at +ON message_request +FOR EACH ROW +EXECUTE FUNCTION fn_upsert_usage_ledger(); diff --git a/drizzle/meta/0116_snapshot.json b/drizzle/meta/0116_snapshot.json new file mode 100644 index 000000000..a84b8ecea --- /dev/null +++ b/drizzle/meta/0116_snapshot.json @@ -0,0 +1,5305 @@ +{ + "id": "e98c1a23-aea9-459e-a634-fb82f3dacfbf", + "prevId": "6ef3f512-a210-4d69-801a-9cd1ee9e1e52", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "action_category": { + "name": "action_category", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "before_value": { + "name": "before_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_value": { + "name": "after_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operator_user_id": { + "name": "operator_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_user_name": { + "name": "operator_user_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_key_id": { + "name": "operator_key_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_key_name": { + "name": "operator_key_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_ip": { + "name": "operator_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_log_category_created_at": { + "name": "idx_audit_log_category_created_at", + "columns": [ + { + "expression": "action_category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_user_created_at": { + "name": "idx_audit_log_operator_user_created_at", + "columns": [ + { + "expression": "operator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_ip_created_at": { + "name": "idx_audit_log_operator_ip_created_at", + "columns": [ + { + "expression": "operator_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_target": { + "name": "idx_audit_log_target", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"target_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_created_at_id": { + "name": "idx_audit_log_created_at_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_pricing_catalog": { + "name": "cloud_pricing_catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "refreshed_at": { + "name": "refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "providers": { + "name": "providers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "vendors": { + "name": "vendors", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_count": { + "name": "model_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "cost_breakdown": { + "name": "cost_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "session_identity": { + "name": "session_identity", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "session_identity_kind": { + "name": "session_identity_kind", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "affinity_scope_tag": { + "name": "affinity_scope_tag", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "affinity_fingerprint": { + "name": "affinity_fingerprint", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "affinity_fingerprint_chain": { + "name": "affinity_fingerprint_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_replay": { + "name": "is_replay", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "replay_source_request_id": { + "name": "replay_source_request_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "routing_trace": { + "name": "routing_trace", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "actual_response_model": { + "name": "actual_response_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "first_byte_ms": { + "name": "first_byte_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "hedge_losers": { + "name": "hedge_losers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_compatibility_key": { + "name": "cache_compatibility_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "cache_score_eligible": { + "name": "cache_score_eligible", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cache_score_excluded_reason": { + "name": "cache_score_excluded_reason", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "theoretical_cache_tokens": { + "name": "theoretical_cache_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_bucket": { + "name": "cache_ttl_bucket", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_finalized_active": { + "name": "idx_message_request_provider_created_at_finalized_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_identity_created_at": { + "name": "idx_message_request_session_identity_created_at", + "columns": [ + { + "expression": "COALESCE(\"session_identity\", \"session_id\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_user_info": { + "name": "idx_message_request_session_user_info", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_client_ip_created_at": { + "name": "idx_message_request_client_ip_created_at", + "columns": [ + { + "expression": "client_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"client_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'cloud'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_vendor": { + "name": "idx_model_prices_vendor", + "columns": [ + { + "expression": "((\"price_data\" ->> 'vendor'))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_aliases": { + "name": "idx_model_prices_aliases", + "columns": [ + { + "expression": "((\"price_data\" -> 'aliases'))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "cache_hit_rate_alert_enabled": { + "name": "cache_hit_rate_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cache_hit_rate_alert_webhook": { + "name": "cache_hit_rate_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cache_hit_rate_alert_window_mode": { + "name": "cache_hit_rate_alert_window_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "cache_hit_rate_alert_check_interval": { + "name": "cache_hit_rate_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cache_hit_rate_alert_historical_lookback_days": { + "name": "cache_hit_rate_alert_historical_lookback_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "cache_hit_rate_alert_min_eligible_requests": { + "name": "cache_hit_rate_alert_min_eligible_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 20 + }, + "cache_hit_rate_alert_min_eligible_tokens": { + "name": "cache_hit_rate_alert_min_eligible_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_hit_rate_alert_abs_min": { + "name": "cache_hit_rate_alert_abs_min", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "cache_hit_rate_alert_drop_rel": { + "name": "cache_hit_rate_alert_drop_rel", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.3'" + }, + "cache_hit_rate_alert_drop_abs": { + "name": "cache_hit_rate_alert_drop_abs", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.1'" + }, + "cache_hit_rate_alert_cooldown_minutes": { + "name": "cache_hit_rate_alert_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cache_hit_rate_alert_top_n": { + "name": "cache_hit_rate_alert_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_batch_apply_operations": { + "name": "provider_batch_apply_operations", + "schema": "", + "columns": { + "claim_key": { + "name": "claim_key", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "preview_token": { + "name": "preview_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "payload_fingerprint": { + "name": "payload_fingerprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "undo_token": { + "name": "undo_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "undo_expires_at": { + "name": "undo_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "undo_consumed_at": { + "name": "undo_consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "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": { + "uniq_provider_batch_apply_operations_preview_token": { + "name": "uniq_provider_batch_apply_operations_preview_token", + "columns": [ + { + "expression": "preview_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uniq_provider_batch_apply_operations_operation_id": { + "name": "uniq_provider_batch_apply_operations_operation_id", + "columns": [ + { + "expression": "operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uniq_provider_batch_apply_operations_undo_token": { + "name": "uniq_provider_batch_apply_operations_undo_token", + "columns": [ + { + "expression": "undo_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_batch_apply_operations_expires_at": { + "name": "idx_provider_batch_apply_operations_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_cache_effectiveness": { + "name": "provider_cache_effectiveness", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "cache_ttl_bucket": { + "name": "cache_ttl_bucket", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "window_end": { + "name": "window_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "sample_count": { + "name": "sample_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "eligible_count": { + "name": "eligible_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "theoretical_cache_tokens": { + "name": "theoretical_cache_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observed_cache_read_tokens": { + "name": "observed_cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "raw_effectiveness_bp": { + "name": "raw_effectiveness_bp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "confidence_bp": { + "name": "confidence_bp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "effectiveness_bp": { + "name": "effectiveness_bp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_cache_effectiveness_window": { + "name": "idx_provider_cache_effectiveness_window", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_start", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_groups": { + "name": "provider_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_groups_name_unique": { + "name": "provider_groups_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disable_session_reuse": { + "name": "disable_session_reuse", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "active_time_start": { + "name": "active_time_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "active_time_end": { + "name": "active_time_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_image_generation_preference": { + "name": "codex_image_generation_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_service_tier_preference": { + "name": "codex_service_tier_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.replay_payloads": { + "name": "replay_payloads", + "schema": "", + "columns": { + "replay_id": { + "name": "replay_id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "verifier": { + "name": "verifier", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "scope_tag": { + "name": "scope_tag", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "headers_json": { + "name": "headers_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_message_request_id": { + "name": "source_message_request_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_replay_payloads_key_id": { + "name": "idx_replay_payloads_key_id", + "columns": [ + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_replay_payloads_expires_at": { + "name": "idx_replay_payloads_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rule_mode": { + "name": "rule_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'simple'" + }, + "execution_phase": { + "name": "execution_phase", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'guard'" + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_phase": { + "name": "idx_request_filters_phase", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_phase", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'CC Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "codex_priority_billing_source": { + "name": "codex_priority_billing_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "bill_non_successful_requests": { + "name": "bill_non_successful_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bill_hedge_losers": { + "name": "bill_hedge_losers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "discovery_enabled": { + "name": "discovery_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discovery_concurrency": { + "name": "discovery_concurrency", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "max_discovery_rounds": { + "name": "max_discovery_rounds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "discovery_sla_ms": { + "name": "discovery_sla_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "sticky_sla_ms": { + "name": "sticky_sla_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20000 + }, + "racing_total_timeout_ms": { + "name": "racing_total_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60000 + }, + "sticky_timeout_cooldown_ms": { + "name": "sticky_timeout_cooldown_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300000 + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pass_through_upstream_error_message": { + "name": "pass_through_upstream_error_message", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_openai_responses_websocket": { + "name": "enable_openai_responses_websocket", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_high_concurrency_mode": { + "name": "enable_high_concurrency_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_effort_conflict_rectifier": { + "name": "enable_thinking_effort_conflict_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_gemini_function_id_rectifier": { + "name": "enable_gemini_function_id_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_input_rectifier": { + "name": "enable_response_input_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_non_conversation_endpoint_provider_fallback": { + "name": "allow_non_conversation_endpoint_provider_fallback", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "fake_streaming_whitelist": { + "name": "fake_streaming_whitelist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "ip_extraction_config": { + "name": "ip_extraction_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_geo_lookup_enabled": { + "name": "ip_geo_lookup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "public_status_window_hours": { + "name": "public_status_window_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "public_status_aggregation_interval_minutes": { + "name": "public_status_aggregation_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "stream_gate_mode": { + "name": "stream_gate_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'enforce'" + }, + "affinity_ignore_client_session_id": { + "name": "affinity_ignore_client_session_id", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "replay_enabled": { + "name": "replay_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cache_effectiveness_enabled": { + "name": "cache_effectiveness_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "final_provider_id": { + "name": "final_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "actual_response_model": { + "name": "actual_response_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "session_identity": { + "name": "session_identity", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "session_identity_kind": { + "name": "session_identity_kind", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "affinity_scope_tag": { + "name": "affinity_scope_tag", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "affinity_fingerprint": { + "name": "affinity_fingerprint", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "affinity_fingerprint_chain": { + "name": "affinity_fingerprint_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_replay": { + "name": "is_replay", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "replay_source_request_id": { + "name": "replay_source_request_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_success": { + "name": "is_success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "success_rate_outcome": { + "name": "success_rate_outcome", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "first_byte_ms": { + "name": "first_byte_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_usage_ledger_request_id": { + "name": "idx_usage_ledger_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_created_at": { + "name": "idx_usage_ledger_user_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL AND \"usage_ledger\".\"is_replay\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at": { + "name": "idx_usage_ledger_key_created_at", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL AND \"usage_ledger\".\"is_replay\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_created_at": { + "name": "idx_usage_ledger_provider_created_at", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL AND \"usage_ledger\".\"is_replay\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_minute": { + "name": "idx_usage_ledger_created_at_minute", + "columns": [ + { + "expression": "date_trunc('minute', \"created_at\" AT TIME ZONE 'UTC')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_desc_id": { + "name": "idx_usage_ledger_created_at_desc_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_session_id": { + "name": "idx_usage_ledger_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_session_identity_created_at": { + "name": "idx_usage_ledger_session_identity_created_at", + "columns": [ + { + "expression": "COALESCE(\"session_identity\", \"session_id\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL AND \"usage_ledger\".\"is_replay\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_model": { + "name": "idx_usage_ledger_model", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_cost": { + "name": "idx_usage_ledger_key_cost", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL AND \"usage_ledger\".\"is_replay\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_cost_cover": { + "name": "idx_usage_ledger_user_cost_cover", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL AND \"usage_ledger\".\"is_replay\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_cost_cover": { + "name": "idx_usage_ledger_provider_cost_cover", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL AND \"usage_ledger\".\"is_replay\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at_desc_cover": { + "name": "idx_usage_ledger_key_created_at_desc_cover", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL AND \"usage_ledger\".\"is_replay\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_5h_cost_reset_at": { + "name": "limit_5h_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert", + "cache_hit_rate_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 45894cb76..eb8f7542a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -813,6 +813,13 @@ "when": 1785418573335, "tag": "0115_breezy_polaris", "breakpoints": true + }, + { + "idx": 116, + "version": "7", + "when": 1785563419224, + "tag": "0116_gigantic_zombie", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 5fbc5d622..3cb603209 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -88,6 +88,12 @@ "minRetryCount": "Retry Count ≥", "minRetryCountPlaceholder": "Enter minimum retries", "enabled": "On", + "replay": { + "label": "Replay", + "all": "All requests", + "only": "Replay only", + "exclude": "Exclude Replay" + }, "apply": "Apply Filter", "reset": "Reset", "last7days": "7d", @@ -164,6 +170,7 @@ "prevPage": "Previous Page", "nextPage": "Next Page", "blocked": "Blocked", + "replay": "Replay", "nonBilling": "Non-Billing", "skipped": "Skipped", "specialSettings": "Special", @@ -349,6 +356,7 @@ "title": "Performance", "ttfb": "TTFB", "tfft": "TFFT", + "tfftShort": "TFFT", "duration": "Total Duration", "outputRate": "Output Rate", "outputTokens": "Output Tokens" @@ -554,7 +562,8 @@ "replayServe": { "title": "Served from Replay Cache", "desc": "This request was served from the replay cache (identical request already in flight or completed). No upstream provider call was made and no cost was incurred.", - "replayId": "Replay ID" + "replayId": "Replay ID", + "sourceRequestId": "Source request" } }, "providerChain": { diff --git a/messages/en/errors.json b/messages/en/errors.json index d51d86dd9..47202ff9a 100644 --- a/messages/en/errors.json +++ b/messages/en/errors.json @@ -30,6 +30,8 @@ "PERMISSION_DENIED": "Permission denied", "TOKEN_REQUIRED": "Authentication token required", "INVALID_TOKEN": "Invalid authentication token", + "SESSION_REQUEST_SOURCE_MISMATCH": "The request source does not belong to this session", + "SESSION_REQUEST_SELECTOR_INCOMPLETE": "Prefix Session requests must specify both the physical source and request sequence", "PROXY_INVALID_API_KEY": "Invalid API key. The provided key does not exist or has been deleted.", "PROXY_API_KEY_DISABLED": "This API key has been disabled. Please contact your administrator to re-enable it, or use a different key.", "PROXY_API_KEY_EXPIRED": "This API key has expired. Please contact your administrator to renew it or rotate to a new key.", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 2176634b5..ae7fd69a3 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -88,6 +88,12 @@ "minRetryCount": "リトライ回数≥", "minRetryCountPlaceholder": "回数を入力(0 で制限なし)", "enabled": "オン", + "replay": { + "label": "Replay", + "all": "すべてのリクエスト", + "only": "Replay のみ", + "exclude": "Replay を除外" + }, "apply": "フィルターを適用", "reset": "リセット", "last7days": "7日", @@ -164,6 +170,7 @@ "prevPage": "前へ", "nextPage": "次へ", "blocked": "ブロック済み", + "replay": "Replay", "nonBilling": "非課金", "skipped": "スキップ", "specialSettings": "特殊設定", @@ -349,6 +356,7 @@ "title": "パフォーマンス", "ttfb": "TTFB", "tfft": "TFFT", + "tfftShort": "TFFT", "duration": "総所要時間", "outputRate": "出力速度", "outputTokens": "出力トークン" @@ -554,7 +562,8 @@ "replayServe": { "title": "Replay キャッシュから応答", "desc": "このリクエストは Replay キャッシュから直接応答されました(同一リクエストが進行中または完了済み)。上流プロバイダーへの呼び出しは行われず、費用は発生しません。", - "replayId": "Replay ID" + "replayId": "Replay ID", + "sourceRequestId": "元のリクエスト" } }, "providerChain": { diff --git a/messages/ja/errors.json b/messages/ja/errors.json index 6ea618bcf..d6bc74d16 100644 --- a/messages/ja/errors.json +++ b/messages/ja/errors.json @@ -30,6 +30,8 @@ "PERMISSION_DENIED": "アクセス権限がありません", "TOKEN_REQUIRED": "認証トークンが必要です", "INVALID_TOKEN": "無効な認証トークン", + "SESSION_REQUEST_SOURCE_MISMATCH": "リクエスト元はこの Session に属していません", + "SESSION_REQUEST_SELECTOR_INCOMPLETE": "プレフィックス Session のリクエストでは、物理ソースとリクエスト番号の両方を指定する必要があります", "PROXY_INVALID_API_KEY": "API キーが無効です。指定されたキーは存在しないか、削除されています。", "PROXY_API_KEY_DISABLED": "この API キーは無効化されています。管理者に再有効化を依頼するか、別のキーをご使用ください。", "PROXY_API_KEY_EXPIRED": "この API キーは期限切れです。管理者に更新を依頼するか、新しいキーへ切り替えてください。", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 454fe0803..ff0db2cbd 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -88,6 +88,12 @@ "minRetryCount": "Количество ретраев ≥", "minRetryCountPlaceholder": "Введите минимум (0 — без ограничения)", "enabled": "Вкл.", + "replay": { + "label": "Replay", + "all": "Все запросы", + "only": "Только Replay", + "exclude": "Исключить Replay" + }, "apply": "Применить фильтр", "reset": "Сброс", "last7days": "7д", @@ -164,6 +170,7 @@ "prevPage": "Предыдущая", "nextPage": "Следующая", "blocked": "Заблокировано", + "replay": "Replay", "nonBilling": "Не тарифицируется", "skipped": "Пропущено", "specialSettings": "Особые", @@ -349,6 +356,7 @@ "title": "Производительность", "ttfb": "TTFB", "tfft": "TFFT", + "tfftShort": "TFFT", "duration": "Общее время", "outputRate": "Скорость вывода", "outputTokens": "Токены вывода" @@ -554,7 +562,8 @@ "replayServe": { "title": "Обслужено из Replay-кэша", "desc": "Запрос обслужен из Replay-кэша (идентичный запрос уже выполняется или завершён). Обращение к провайдеру не выполнялось, затрат нет.", - "replayId": "Replay ID" + "replayId": "Replay ID", + "sourceRequestId": "Исходный запрос" } }, "providerChain": { diff --git a/messages/ru/errors.json b/messages/ru/errors.json index 51c0b0888..29b2f58ae 100644 --- a/messages/ru/errors.json +++ b/messages/ru/errors.json @@ -30,6 +30,8 @@ "PERMISSION_DENIED": "Доступ запрещен", "TOKEN_REQUIRED": "Требуется токен аутентификации", "INVALID_TOKEN": "Недействительный токен аутентификации", + "SESSION_REQUEST_SOURCE_MISMATCH": "Источник запроса не относится к этой сессии", + "SESSION_REQUEST_SELECTOR_INCOMPLETE": "Для запроса префиксной Session необходимо указать физический источник и порядковый номер запроса", "PROXY_INVALID_API_KEY": "Неверный API-ключ. Указанный ключ не существует или был удалён.", "PROXY_API_KEY_DISABLED": "Этот API-ключ отключён. Обратитесь к администратору, чтобы повторно включить его, или используйте другой ключ.", "PROXY_API_KEY_EXPIRED": "Срок действия этого API-ключа истёк. Обратитесь к администратору, чтобы продлить срок, или замените ключ.", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index f314c58ec..1c0cc3e9a 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -88,6 +88,12 @@ "minRetryCount": "重试次数≥", "minRetryCountPlaceholder": "输入次数(0 表示不限)", "enabled": "已开启", + "replay": { + "label": "Replay", + "all": "全部请求", + "only": "仅 Replay 请求", + "exclude": "排除 Replay 请求" + }, "apply": "应用筛选", "reset": "重置", "last7days": "近7天", @@ -164,6 +170,7 @@ "prevPage": "上一页", "nextPage": "下一页", "blocked": "被拦截", + "replay": "Replay", "nonBilling": "非计费", "skipped": "已跳过", "specialSettings": "特殊设置", @@ -349,6 +356,7 @@ "title": "性能数据", "ttfb": "首字节时间(TTFB)", "tfft": "首 Token 时间(TFFT)", + "tfftShort": "TFFT", "duration": "总耗时", "outputRate": "输出速率", "outputTokens": "输出 Tokens" @@ -554,7 +562,8 @@ "replayServe": { "title": "由 Replay 缓存服务", "desc": "该请求由 Replay 缓存直接服务(相同请求正在进行或已完成),未发起上游供应商调用,不产生费用。", - "replayId": "Replay ID" + "replayId": "Replay ID", + "sourceRequestId": "源请求" } }, "providerChain": { diff --git a/messages/zh-CN/errors.json b/messages/zh-CN/errors.json index 7846d95c8..1ed2e29cc 100644 --- a/messages/zh-CN/errors.json +++ b/messages/zh-CN/errors.json @@ -30,6 +30,8 @@ "PERMISSION_DENIED": "权限不足", "TOKEN_REQUIRED": "需要提供认证令牌", "INVALID_TOKEN": "无效的认证令牌", + "SESSION_REQUEST_SOURCE_MISMATCH": "请求来源不属于该 Session", + "SESSION_REQUEST_SELECTOR_INCOMPLETE": "前缀 Session 请求必须同时指定物理来源和请求序号", "PROXY_INVALID_API_KEY": "API 密钥无效。提供的密钥不存在或已被删除。", "PROXY_API_KEY_DISABLED": "API 密钥已被禁用。请联系管理员重新启用,或使用其他可用密钥。", "PROXY_API_KEY_EXPIRED": "API 密钥已过期。请联系管理员续期或更换密钥。", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index e8e5fee6f..ebb995e21 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -88,6 +88,12 @@ "minRetryCount": "重試次數≥", "minRetryCountPlaceholder": "輸入次數(0 表示不限)", "enabled": "已開啟", + "replay": { + "label": "Replay", + "all": "全部請求", + "only": "僅 Replay 請求", + "exclude": "排除 Replay 請求" + }, "apply": "套用篩選", "reset": "重設", "last7days": "近 7 天", @@ -164,6 +170,7 @@ "prevPage": "上一頁", "nextPage": "下一頁", "blocked": "已攔截", + "replay": "Replay", "nonBilling": "非計費", "skipped": "已跳過", "specialSettings": "特殊設定", @@ -349,6 +356,7 @@ "title": "效能資料", "ttfb": "首字節時間(TTFB)", "tfft": "首 Token 時間(TFFT)", + "tfftShort": "TFFT", "duration": "總耗時", "outputRate": "輸出速率", "outputTokens": "輸出 Tokens" @@ -554,7 +562,8 @@ "replayServe": { "title": "由 Replay 快取服務", "desc": "該請求由 Replay 快取直接服務(相同請求正在進行或已完成),未發起上游供應商呼叫,不產生費用。", - "replayId": "Replay ID" + "replayId": "Replay ID", + "sourceRequestId": "來源請求" } }, "providerChain": { diff --git a/messages/zh-TW/errors.json b/messages/zh-TW/errors.json index 1ad09a848..ba8dbe595 100644 --- a/messages/zh-TW/errors.json +++ b/messages/zh-TW/errors.json @@ -30,6 +30,8 @@ "PERMISSION_DENIED": "權限不足", "TOKEN_REQUIRED": "需要提供認證令牌", "INVALID_TOKEN": "無效的認證令牌", + "SESSION_REQUEST_SOURCE_MISMATCH": "請求來源不屬於此 Session", + "SESSION_REQUEST_SELECTOR_INCOMPLETE": "前綴 Session 請求必須同時指定實體來源與請求序號", "PROXY_INVALID_API_KEY": "API 金鑰無效。提供的金鑰不存在或已被刪除。", "PROXY_API_KEY_DISABLED": "API 金鑰已被停用。請聯絡管理員重新啟用,或使用其他可用金鑰。", "PROXY_API_KEY_EXPIRED": "API 金鑰已過期。請聯絡管理員續期或更換金鑰。", diff --git a/src/actions/active-sessions.ts b/src/actions/active-sessions.ts index db9868f54..f6ff986ff 100644 --- a/src/actions/active-sessions.ts +++ b/src/actions/active-sessions.ts @@ -9,7 +9,7 @@ import { } from "@/lib/cache/session-cache"; import { logger } from "@/lib/logger"; import { extractAfterRequestMessages, isSessionMessages } from "@/lib/session-detail-snapshots"; -import { normalizeRequestSequence } from "@/lib/utils/request-sequence"; +import { resolveSessionRequestLocator } from "@/lib/session-request-locator"; import { buildUnifiedSpecialSettings } from "@/lib/utils/special-settings"; import { type ActiveSessionInfo, @@ -22,6 +22,16 @@ import type { SpecialSetting } from "@/types/special-settings"; import { summarizeTerminateSessionsBatch } from "./active-sessions-utils"; import type { ActionResult } from "./types"; +function isPrefixAffinityIdentity(identity: string): boolean { + return identity.startsWith("pfx:"); +} + +function getSessionFingerprint(identity: string): string | null { + if (!isPrefixAffinityIdentity(identity)) return null; + const fingerprint = identity.split(":").at(-1); + return fingerprint || null; +} + function normalizeRequestSnapshot( snapshot: Awaited< ReturnType @@ -177,7 +187,8 @@ export async function getActiveSessions(): Promise s.sessionId); - const concurrentCounts = await SessionTracker.getConcurrentCountBatch(cachedSessionIds); + const concurrentCounts = + await SessionTracker.getObservedConcurrentCountBatch(cachedSessionIds); return { ok: true, @@ -185,6 +196,10 @@ export async function getActiveSessions(): Promise s.sessionId); - const concurrentCounts = await SessionTracker.getConcurrentCountBatch(allSessionIds); + const concurrentCounts = await SessionTracker.getObservedConcurrentCountBatch(allSessionIds); // 4. 写入缓存 setActiveSessionsCache(sessionsData); @@ -242,6 +257,10 @@ export async function getActiveSessions(): Promise !isPrefixAffinityIdentity(id)), + ]) + ); if (allSessionIds.length === 0) { return { @@ -444,6 +477,10 @@ export async function getAllSessions( const lastRequestTime = s.lastRequestAt ? new Date(s.lastRequestAt).getTime() : 0; const sessionInfo: ActiveSessionInfo = { sessionId: s.sessionId, + sessionIdentityKind: isPrefixAffinityIdentity(s.sessionId) + ? "prefix_affinity" + : "session_id", + sessionFingerprint: getSessionFingerprint(s.sessionId), userName: s.userName, userId: s.userId, keyId: s.keyId, @@ -516,7 +553,11 @@ export async function getAllSessions( * * 安全修复:添加用户权限检查 */ -export async function getSessionMessages(sessionId: string): Promise> { +export async function getSessionMessages( + sessionId: string, + requestSequence?: number, + requestedSourceSessionId?: string +): Promise> { try { // 0. 验证用户权限 const authSession = await getSession(); @@ -553,8 +594,18 @@ export async function getSessionMessages(sessionId: string): Promise> { try { // 验证用户权限 @@ -622,11 +674,22 @@ export async function hasSessionMessages( }; } + const locatorResult = await resolveSessionRequestLocator( + sessionId, + requestSequence, + requestedSourceSessionId + ); + if (!locatorResult.ok) return locatorResult; + const { SessionManager } = await import("@/lib/session-manager"); + const sourceSessionId = locatorResult.locator.sourceSessionId; // 如果指定了序号,检查特定请求 if (requestSequence !== undefined) { - const messages = await SessionManager.getSessionMessages(sessionId, requestSequence); + const messages = await SessionManager.getSessionMessages( + sourceSessionId, + locatorResult.locator.requestSequence + ); return { ok: true, data: messages !== null, @@ -634,7 +697,7 @@ export async function hasSessionMessages( } // 否则检查 Session 是否有任意请求的 messages - const hasAny = await SessionManager.hasAnySessionMessages(sessionId); + const hasAny = await SessionManager.hasAnySessionMessages(sourceSessionId); return { ok: true, data: hasAny, @@ -656,12 +719,14 @@ export async function hasSessionMessages( * * @param sessionId - Session ID * @param requestSequence - 请求序号(可选,用于获取 Session 内特定请求的消息) + * @param requestedSourceSessionId - 聚合 identity 下的物理 Session ID * * 安全修复:添加用户权限检查 */ export async function getSessionDetails( sessionId: string, - requestSequence?: number + requestSequence?: number, + requestedSourceSessionId?: string ): Promise< ActionResult<{ requestBody: unknown | null; @@ -676,6 +741,7 @@ export async function getSessionDetails( sessionStats: Awaited< ReturnType > | null; + currentSourceSessionId: string; currentSequence: number | null; prevSequence: number | null; nextSequence: number | null; @@ -735,18 +801,25 @@ export async function getSessionDetails( }; } - // 5. 解析 requestSequence:未指定时默认取当前最新请求序号 + const locatorResult = await resolveSessionRequestLocator( + sessionId, + requestSequence, + requestedSourceSessionId + ); + if (!locatorResult.ok) return locatorResult; + + const sourceSessionId = locatorResult.locator.sourceSessionId; + const effectiveSequence = locatorResult.locator.requestSequence; + + // 5. 请求 locator 已同时验证 identity、物理 Session 和序号,后续所有读取必须复用它。 const { SessionManager } = await import("@/lib/session-manager"); - const requestCount = await SessionManager.getSessionRequestCount(sessionId); - const normalizedSequence = normalizeRequestSequence(requestSequence); - const effectiveSequence = normalizedSequence ?? (requestCount > 0 ? requestCount : undefined); const { findAdjacentRequestSequences, findMessageRequestAuditBySessionIdAndSequence } = await import("@/repository/message"); const adjacent = effectiveSequence == null ? { prevSequence: null, nextSequence: null } - : await findAdjacentRequestSequences(sessionId, effectiveSequence); + : await findAdjacentRequestSequences(sourceSessionId, effectiveSequence); const parseJsonStringOrNull = (value: unknown): unknown => { if (typeof value !== "string") return value; @@ -787,22 +860,22 @@ export async function getSessionDetails( responseSnapshotBefore, responseSnapshotAfter, ] = await Promise.all([ - SessionManager.getSessionRequestBody(sessionId, effectiveSequence), - SessionManager.getSessionMessages(sessionId, effectiveSequence), - SessionManager.getSessionResponse(sessionId, effectiveSequence), - SessionManager.getSessionRequestHeaders(sessionId, effectiveSequence), - SessionManager.getSessionResponseHeaders(sessionId, effectiveSequence), - SessionManager.getSessionClientRequestMeta(sessionId, effectiveSequence), - SessionManager.getSessionUpstreamRequestMeta(sessionId, effectiveSequence), - SessionManager.getSessionUpstreamResponseMeta(sessionId, effectiveSequence), - SessionManager.getSessionSpecialSettings(sessionId, effectiveSequence), + SessionManager.getSessionRequestBody(sourceSessionId, effectiveSequence), + SessionManager.getSessionMessages(sourceSessionId, effectiveSequence), + SessionManager.getSessionResponse(sourceSessionId, effectiveSequence), + SessionManager.getSessionRequestHeaders(sourceSessionId, effectiveSequence), + SessionManager.getSessionResponseHeaders(sourceSessionId, effectiveSequence), + SessionManager.getSessionClientRequestMeta(sourceSessionId, effectiveSequence), + SessionManager.getSessionUpstreamRequestMeta(sourceSessionId, effectiveSequence), + SessionManager.getSessionUpstreamResponseMeta(sourceSessionId, effectiveSequence), + SessionManager.getSessionSpecialSettings(sourceSessionId, effectiveSequence), effectiveSequence - ? findMessageRequestAuditBySessionIdAndSequence(sessionId, effectiveSequence) + ? findMessageRequestAuditBySessionIdAndSequence(sourceSessionId, effectiveSequence) : Promise.resolve(null), - SessionManager.getSessionRequestPhaseSnapshot(sessionId, "before", effectiveSequence), - SessionManager.getSessionRequestPhaseSnapshot(sessionId, "after", effectiveSequence), - SessionManager.getSessionResponsePhaseSnapshot(sessionId, "before", effectiveSequence), - SessionManager.getSessionResponsePhaseSnapshot(sessionId, "after", effectiveSequence), + SessionManager.getSessionRequestPhaseSnapshot(sourceSessionId, "before", effectiveSequence), + SessionManager.getSessionRequestPhaseSnapshot(sourceSessionId, "after", effectiveSequence), + SessionManager.getSessionResponsePhaseSnapshot(sourceSessionId, "before", effectiveSequence), + SessionManager.getSessionResponsePhaseSnapshot(sourceSessionId, "after", effectiveSequence), ]); // 兼容:历史/异常数据可能是 JSON 字符串(前端需要根级对象/数组) @@ -896,6 +969,7 @@ export async function getSessionDetails( snapshots: effectiveSnapshots, specialSettings: unifiedSpecialSettings, sessionStats, + currentSourceSessionId: sourceSessionId, currentSequence: effectiveSequence ?? null, prevSequence: adjacent.prevSequence, nextSequence: adjacent.nextSequence, @@ -930,6 +1004,7 @@ export async function getSessionRequests( ActionResult<{ requests: Array<{ id: number; + sourceSessionId: string; sequence: number; model: string | null; statusCode: number | null; @@ -978,9 +1053,9 @@ export async function getSessionRequests( } // 2. 查询请求列表 - const { findRequestsBySessionId } = await import("@/repository/message"); + const { findRequestsBySessionIdentity } = await import("@/repository/message"); const offset = (page - 1) * pageSize; - const { requests, total } = await findRequestsBySessionId(sessionId, { + const { requests, total } = await findRequestsBySessionIdentity(sessionId, { limit: pageSize, offset, order, @@ -1026,7 +1101,7 @@ export async function terminateActiveSession(sessionId: string): Promise> { try { - const count = await getActiveConcurrentSessions(); + const count = await SessionTracker.getObservedGlobalSessionCount(); return { ok: true, data: count, diff --git a/src/actions/session-origin-chain.ts b/src/actions/session-origin-chain.ts index 904b7b23d..b95c1d301 100644 --- a/src/actions/session-origin-chain.ts +++ b/src/actions/session-origin-chain.ts @@ -1,17 +1,16 @@ "use server"; -import { and, eq, inArray, isNull, or } from "drizzle-orm"; -import { db } from "@/drizzle/db"; -import { messageRequest } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; import { logger } from "@/lib/logger"; -import { findKeyList } from "@/repository/key"; -import { findSessionOriginChain } from "@/repository/message"; +import { resolveSessionRequestLocator } from "@/lib/session-request-locator"; +import { aggregateSessionStats, findSessionOriginChain } from "@/repository/message"; import type { ProviderChainItem } from "@/types/message"; import type { ActionResult } from "./types"; export async function getSessionOriginChain( - sessionId: string + sessionId: string, + requestSequence?: number, + requestedSourceSessionId?: string ): Promise> { try { const session = await getSession(); @@ -19,36 +18,23 @@ export async function getSessionOriginChain( return { ok: false, error: "未登录" }; } - if (session.user.role !== "admin") { - const userKeys = await findKeyList(session.user.id); - const userKeyValues = userKeys.map((key) => key.key); - - const ownershipCondition = - userKeyValues.length > 0 - ? or( - eq(messageRequest.userId, session.user.id), - inArray(messageRequest.key, userKeyValues) - ) - : eq(messageRequest.userId, session.user.id); - - const [ownedSession] = await db - .select({ id: messageRequest.id }) - .from(messageRequest) - .where( - and( - eq(messageRequest.sessionId, sessionId), - isNull(messageRequest.deletedAt), - ownershipCondition - ) - ) - .limit(1); + const sessionStats = await aggregateSessionStats(sessionId); + if (!sessionStats) { + return { ok: false, error: "Session 不存在" }; + } - if (!ownedSession) { - return { ok: false, error: "无权访问该 Session" }; - } + if (session.user.role !== "admin" && sessionStats.userId !== session.user.id) { + return { ok: false, error: "无权访问该 Session" }; } - const chain = await findSessionOriginChain(sessionId); + const locatorResult = await resolveSessionRequestLocator( + sessionId, + requestSequence, + requestedSourceSessionId + ); + if (!locatorResult.ok) return locatorResult; + + const chain = await findSessionOriginChain(locatorResult.locator.sourceSessionId); return { ok: true, data: chain ?? null }; } catch (error) { logger.error("获取会话来源链失败:", error); diff --git a/src/actions/session-response.ts b/src/actions/session-response.ts index 99196d8da..e00de0e8c 100644 --- a/src/actions/session-response.ts +++ b/src/actions/session-response.ts @@ -1,8 +1,10 @@ "use server"; +import type { ActionResult } from "@/actions/types"; import { getSession } from "@/lib/auth"; import { logger } from "@/lib/logger"; import { SessionManager } from "@/lib/session-manager"; +import { resolveSessionRequestLocator } from "@/lib/session-request-locator"; /** * 获取 session 响应体内容 @@ -13,8 +15,10 @@ import { SessionManager } from "@/lib/session-manager"; * 安全修复:添加用户权限检查 */ export async function getSessionResponse( - sessionId: string -): Promise<{ ok: true; data: string } | { ok: false; error: string }> { + sessionId: string, + requestSequence?: number, + requestedSourceSessionId?: string +): Promise> { try { // 0. 验证用户权限 const authSession = await getSession(); @@ -50,10 +54,20 @@ export async function getSessionResponse( }; } - // 3. 获取响应体 - const response = await SessionManager.getSessionResponse(sessionId); + const locatorResult = await resolveSessionRequestLocator( + sessionId, + requestSequence, + requestedSourceSessionId + ); + if (!locatorResult.ok) return locatorResult; - if (!response) { + // 3. 只读取 locator 已授权的物理请求响应体 + const response = await SessionManager.getSessionResponse( + locatorResult.locator.sourceSessionId, + locatorResult.locator.requestSequence + ); + + if (response === null) { return { ok: false, error: "响应体已过期(5分钟 TTL)或尚未记录", diff --git a/src/app/[locale]/dashboard/_components/bento/live-sessions-panel.tsx b/src/app/[locale]/dashboard/_components/bento/live-sessions-panel.tsx index a309623c5..2b31cffea 100644 --- a/src/app/[locale]/dashboard/_components/bento/live-sessions-panel.tsx +++ b/src/app/[locale]/dashboard/_components/bento/live-sessions-panel.tsx @@ -30,7 +30,8 @@ function SessionItem({ session }: { session: ActiveSessionInfo }) { status: session.status, }); - const shortId = session.sessionId.slice(-6); + const displayIdentity = session.sessionFingerprint ?? session.sessionId; + const shortId = displayIdentity.slice(-6); const userName = session.userName || t("unknownUser"); // Determine ping animation color based on status diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx index 66f1e9ff6..130c6e197 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx @@ -237,6 +237,13 @@ const messages = { warmup: "Warmup", desc: "Warmup skipped", }, + replayServe: { + ...dashboardMessages.logs.details.replayServe, + title: "Replay", + desc: "Served from Replay without a new upstream charge.", + replayId: "Replay ID", + sourceRequestId: "Source request", + }, blocked: { title: "Blocked", sensitiveWord: "Sensitive word", @@ -422,6 +429,26 @@ function click(element: Element | null) { } describe("error-details-dialog layout", () => { + test("marks Replay requests and shows their source request", () => { + const html = renderWithIntl( + + ); + + expect(html).toContain("Replay"); + expect(html).toContain("Source request"); + expect(html).toContain("7"); + expect(html).not.toContain(">Blocked<"); + }); + test("renders fake-200 forwarded notice when errorMessage is a FAKE_200_* code", () => { const html = renderWithIntl( )} - {/* F2 Replay Serve Info (cache hit served without upstream call) */} - {blockedBy === "replay_serve" && ( + {/* Replay audit info (served without a new upstream charge) */} + {isReplay && (
@@ -271,11 +273,21 @@ export function LogicTraceTab({
)} + {replaySourceRequestId != null && ( +
+ + {t("replayServe.sourceRequestId")}: + + + {replaySourceRequestId} + +
+ )}
)} {/* Block Info */} - {isBlocked && blockedBy && blockedBy !== "replay_serve" && ( + {isBlocked && blockedBy && (
diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx index d690debf6..f2e3e3917 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx @@ -25,6 +25,8 @@ interface ErrorDetailsDialogProps { requestSequence?: number | null; blockedBy?: string | null; blockedReason?: string | null; + isReplay?: boolean; + replaySourceRequestId?: number | null; originalModel?: string | null; currentModel?: string | null; actualResponseModel?: string | null; @@ -71,6 +73,8 @@ export function ErrorDetailsDialog({ requestSequence, blockedBy, blockedReason, + isReplay = false, + replaySourceRequestId, originalModel, currentModel, actualResponseModel, @@ -222,6 +226,8 @@ export function ErrorDetailsDialog({ requestSequence, blockedBy, blockedReason, + isReplay, + replaySourceRequestId, originalModel, currentModel, actualResponseModel, @@ -264,7 +270,14 @@ export function ErrorDetailsDialog({ className="w-[95vw] sm:w-[480px] md:w-[540px] lg:w-[600px] xl:w-[640px] sm:max-w-none overflow-y-auto px-4 sm:px-6" > - {t("title")} + + {t("title")} + {isReplay && ( + + {t("replayServe.title")} + + )} +
diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts index a5b95850e..0cedffcce 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts @@ -24,6 +24,10 @@ export interface TabSharedProps { blockedBy?: string | null; /** Block reason (JSON string) */ blockedReason?: string | null; + /** Whether this audit row was served by Request Replay */ + isReplay?: boolean; + /** Source terminal request copied into this Replay audit row */ + replaySourceRequestId?: number | null; /** Original model before redirect */ originalModel?: string | null; /** Current model after redirect */ diff --git a/src/app/[locale]/dashboard/logs/_components/filters/active-filters-display.tsx b/src/app/[locale]/dashboard/logs/_components/filters/active-filters-display.tsx index 5d9eb5b9d..e158676c6 100644 --- a/src/app/[locale]/dashboard/logs/_components/filters/active-filters-display.tsx +++ b/src/app/[locale]/dashboard/logs/_components/filters/active-filters-display.tsx @@ -147,6 +147,14 @@ export function ActiveFiltersDisplay({ }); } + if (filters.replayFilter && filters.replayFilter !== "all") { + result.push({ + key: "replayFilter", + label: t("replay.label"), + value: t(filters.replayFilter === "replay" ? "replay.only" : "replay.exclude"), + }); + } + return result; }, [filters, displayNames, isAdmin, serverTimeZone, t]); diff --git a/src/app/[locale]/dashboard/logs/_components/filters/status-filters.test.tsx b/src/app/[locale]/dashboard/logs/_components/filters/status-filters.test.tsx new file mode 100644 index 000000000..8405f44bc --- /dev/null +++ b/src/app/[locale]/dashboard/logs/_components/filters/status-filters.test.tsx @@ -0,0 +1,80 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { UsageLogFilters } from "./types"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("../../_hooks/use-lazy-filter-options", () => ({ + useLazyStatusCodes: () => ({ + data: [], + isLoading: false, + onOpenChange: vi.fn(), + }), +})); + +vi.mock("@/components/ui/select", () => ({ + Select: ({ + value, + onValueChange, + children, + }: { + value: string; + onValueChange: (value: string) => void; + children: React.ReactNode; + }) => ( + + ), + SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => ( + + ), + SelectTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectValue: () => null, +})); + +import { StatusFilters } from "./status-filters"; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("StatusFilters Replay filter", () => { + test("preserves existing filters when selecting non-Replay requests", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const onFiltersChange = vi.fn<(filters: UsageLogFilters) => void>(); + + act(() => { + root.render( + + ); + }); + + const replaySelect = Array.from(container.querySelectorAll("select")).find((select) => + Array.from(select.options).some((option) => option.value === "non-replay") + ); + expect(replaySelect).toBeDefined(); + + act(() => { + if (!replaySelect) return; + replaySelect.value = "non-replay"; + replaySelect.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(onFiltersChange).toHaveBeenCalledWith({ + statusCode: 200, + replayFilter: "non-replay", + }); + + act(() => root.unmount()); + }); +}); diff --git a/src/app/[locale]/dashboard/logs/_components/filters/status-filters.tsx b/src/app/[locale]/dashboard/logs/_components/filters/status-filters.tsx index 786f899b4..414ecc8df 100644 --- a/src/app/[locale]/dashboard/logs/_components/filters/status-filters.tsx +++ b/src/app/[locale]/dashboard/logs/_components/filters/status-filters.tsx @@ -53,6 +53,13 @@ export function StatusFilters({ filters, onFiltersChange }: StatusFiltersProps) }); }; + const handleReplayFilterChange = (value: string) => { + onFiltersChange({ + ...filters, + replayFilter: value as NonNullable, + }); + }; + return (
{/* Status code selector */} @@ -102,6 +109,21 @@ export function StatusFilters({ filters, onFiltersChange }: StatusFiltersProps) onChange={handleMinRetryCountChange} />
+ + {/* Replay audit selector */} +
+ + +
); } diff --git a/src/app/[locale]/dashboard/logs/_components/filters/types.ts b/src/app/[locale]/dashboard/logs/_components/filters/types.ts index ffde30de0..f7b48f564 100644 --- a/src/app/[locale]/dashboard/logs/_components/filters/types.ts +++ b/src/app/[locale]/dashboard/logs/_components/filters/types.ts @@ -19,6 +19,7 @@ export interface UsageLogFilters { actualResponseModelMismatch?: boolean; endpoint?: string; minRetryCount?: number; + replayFilter?: "all" | "replay" | "non-replay"; } /** diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx index 1563e388c..92e687368 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx @@ -45,6 +45,7 @@ const VALID_FILTER_KEYS: (keyof UsageLogFilters)[] = [ "actualResponseModelMismatch", "endpoint", "minRetryCount", + "replayFilter", ]; function sanitizeFilters(filters: UsageLogFilters): UsageLogFilters { @@ -149,8 +150,14 @@ export function UsageLogsFilters({ let count = 0; if (localFilters.statusCode !== undefined || localFilters.excludeStatusCode200) count++; if (localFilters.minRetryCount !== undefined && localFilters.minRetryCount > 0) count++; + if (localFilters.replayFilter && localFilters.replayFilter !== "all") count++; return count; - }, [localFilters.statusCode, localFilters.excludeStatusCode200, localFilters.minRetryCount]); + }, [ + localFilters.statusCode, + localFilters.excludeStatusCode200, + localFilters.minRetryCount, + localFilters.replayFilter, + ]); useEffect(() => { setLocalFilters(filters); diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx index df9a39782..4049d39ad 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx @@ -23,6 +23,7 @@ interface UsageLogsStatsPanelProps { model?: string; endpoint?: string; minRetryCount?: number; + replayFilter?: "all" | "replay" | "non-replay"; }; currencyCode?: CurrencyCode; /** diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx index 301ed256a..c843297d5 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx @@ -85,6 +85,8 @@ function makeLog(overrides: Partial): UsageLogRow { providerChain: null, blockedBy: null, blockedReason: null, + isReplay: false, + replaySourceRequestId: null, userAgent: null, clientIp: null, messagesCount: null, diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.test.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.test.tsx index ce3e68cbb..619ce6b83 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.test.tsx @@ -14,6 +14,12 @@ const searchParamMocks = vi.hoisted(() => ({ value: new URLSearchParams(), })); +const filterPropMocks = vi.hoisted(() => ({ + panel: undefined as UsageLogFilters | undefined, + table: undefined as UsageLogFilters | undefined, + controls: undefined as UsageLogFilters | undefined, +})); + vi.mock("next-intl", () => ({ useLocale: () => "zh-CN", useTranslations: () => (key: string) => key, @@ -77,57 +83,69 @@ vi.mock("./column-visibility-dropdown", () => ({ })); vi.mock("./usage-logs-stats-panel", () => ({ - UsageLogsStatsPanel: () =>
, + UsageLogsStatsPanel: ({ filters }: { filters: UsageLogFilters }) => { + filterPropMocks.panel = filters; + return
; + }, })); vi.mock("./virtualized-logs-table", () => ({ - VirtualizedLogsTable: () =>
, + VirtualizedLogsTable: ({ filters }: { filters: UsageLogFilters }) => { + filterPropMocks.table = filters; + return
; + }, })); vi.mock("./usage-logs-filters", () => ({ UsageLogsFilters: ({ + filters, onChange, onReset, }: { + filters: UsageLogFilters; onChange: (filters: UsageLogFilters) => void; onReset: () => void; - }) => ( -
- - - -
- ), + }) => { + filterPropMocks.controls = filters; + return ( +
+ + + +
+ ); + }, })); import { UsageLogsViewVirtualized } from "./usage-logs-view-virtualized"; @@ -177,6 +195,9 @@ describe("UsageLogsViewVirtualized filter navigation", () => { routerMocks.pushedHref = ""; routerMocks.push.mockClear(); searchParamMocks.value = new URLSearchParams(); + filterPropMocks.panel = undefined; + filterPropMocks.table = undefined; + filterPropMocks.controls = undefined; document.body.innerHTML = ""; }); @@ -186,12 +207,24 @@ describe("UsageLogsViewVirtualized filter navigation", () => { clickButton(container, "apply all filters"); expect(routerMocks.pushedHref).toBe( - "/zh-CN/dashboard/logs?userId=2&keyId=3&providerId=4&sessionId=session-abc&startTime=1000&endTime=2000&statusCode=500&model=claude-sonnet&actualResponseModelMismatch=true&endpoint=%2Fv1%2Fmessages&minRetry=1" + "/zh-CN/dashboard/logs?userId=2&keyId=3&providerId=4&sessionId=session-abc&startTime=1000&endTime=2000&statusCode=500&model=claude-sonnet&actualResponseModelMismatch=true&endpoint=%2Fv1%2Fmessages&minRetry=1&replayFilter=replay" ); unmount(); }); + it("propagates the Replay URL filter to controls, stats, and table", () => { + searchParamMocks.value = new URLSearchParams("replayFilter=non-replay"); + + const { unmount } = renderUsageLogsView(); + + expect(filterPropMocks.controls?.replayFilter).toBe("non-replay"); + expect(filterPropMocks.panel?.replayFilter).toBe("non-replay"); + expect(filterPropMocks.table?.replayFilter).toBe("non-replay"); + + unmount(); + }); + it("applies exclude-200 status filter through the locale-aware dashboard route", () => { const { container, unmount } = renderUsageLogsView(); diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx index 918ca5d33..89a63b56a 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx @@ -169,6 +169,7 @@ function UsageLogsViewContent({ actualResponseModelMismatch: _params.get("actualResponseModelMismatch") ?? undefined, endpoint: _params.get("endpoint") ?? undefined, minRetry: _params.get("minRetry") ?? undefined, + replayFilter: _params.get("replayFilter") ?? undefined, page: _params.get("page") ?? undefined, }); @@ -269,6 +270,7 @@ function UsageLogsViewContent({ if (statsFilters.actualResponseModelMismatch) count++; if (statsFilters.endpoint) count++; if (statsFilters.minRetryCount !== undefined && statsFilters.minRetryCount > 0) count++; + if (statsFilters.replayFilter && statsFilters.replayFilter !== "all") count++; return count; }, [statsFilters]); const [isFilterOpen, setIsFilterOpen] = useState(activeFilterCount > 0); diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx index e7f57f5b2..8da38c8d2 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx @@ -149,6 +149,8 @@ function makeLog(overrides: Partial): UsageLogRow { providerChain: null, blockedBy: null, blockedReason: null, + isReplay: false, + replaySourceRequestId: null, userAgent: null, clientIp: null, messagesCount: null, @@ -196,6 +198,27 @@ function renderCostTooltipWithLog(overrides: Partial) { return tooltip; } +function renderPerformanceWithLog(overrides: Partial) { + const html = renderTableWithLog(overrides); + const container = document.createElement("div"); + container.innerHTML = html; + + const tooltip = [...container.querySelectorAll('[data-slot="tooltip-content"]')].find((node) => + node.textContent?.includes("logs.details.performance.duration") + ); + + if (!(tooltip instanceof HTMLDivElement) || !(tooltip.parentElement instanceof HTMLDivElement)) { + throw new Error("Performance tooltip content not found"); + } + + const trigger = tooltip.parentElement.firstElementChild; + if (!(trigger instanceof HTMLDivElement)) { + throw new Error("Performance tooltip trigger not found"); + } + + return { tooltip, trigger }; +} + describe("virtualized-logs-table thinking effort", () => { test("在计费模型右侧显示思考强度列", () => { const html = renderTableWithLog({ @@ -441,6 +464,22 @@ describe("virtualized-logs-table multiplier badge", () => { expect(html).toContain("animate-spin"); }); + test("renders Replay badge without treating the request as blocked", () => { + mockIsLoading = false; + mockIsError = false; + mockError = null; + mockHasNextPage = false; + mockIsFetchingNextPage = false; + + mockLogs = [makeLog({ id: 1, isReplay: true, replaySourceRequestId: 7 })]; + const html = renderToStaticMarkup( + + ); + + expect(html).toContain("logs.table.replay"); + expect(html).not.toContain("logs.table.blocked"); + }); + test("hides provider column when hiddenColumns includes provider", () => { mockIsLoading = false; mockIsError = false; @@ -530,6 +569,27 @@ describe("virtualized-logs-table multiplier badge", () => { expect(html).toContain("logs.details.performance.tfft"); }); + test("性能列使用 TFFT 缩写", () => { + const { trigger } = renderPerformanceWithLog({ + durationMs: 1000, + tfftMs: 500, + firstByteMs: 250, + }); + + expect(trigger.textContent).toContain("logs.details.performance.tfftShort"); + }); + + test("性能 Tooltip 保留 TFFT 和 TTFB 完整术语", () => { + const { tooltip } = renderPerformanceWithLog({ + durationMs: 1000, + tfftMs: 500, + firstByteMs: 250, + }); + + expect(tooltip.textContent).toContain("logs.details.performance.tfft"); + expect(tooltip.textContent).toContain("logs.details.performance.ttfb"); + }); + test("renders swap indicator on cacheTtl badge when swapCacheTtlApplied is true", () => { mockIsLoading = false; mockIsError = false; diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index e0f150534..e29e6cfc9 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -70,6 +70,7 @@ export interface VirtualizedLogsTableFilters { actualResponseModelMismatch?: boolean; endpoint?: string; minRetryCount?: number; + replayFilter?: "all" | "replay" | "non-replay"; } const STATUS_BADGE_FALLBACK = @@ -872,7 +873,12 @@ export function VirtualizedLogsTable({ {/* Provider */} {hideProviderColumn ? null : (
- {log.blockedBy ? ( + {log.isReplay ? ( + + + {t("logs.table.replay")} + + ) : log.blockedBy ? ( {t("logs.table.blocked")} @@ -1166,7 +1172,7 @@ export function VirtualizedLogsTable({ ); const tfftLine = log.tfftMs != null && log.tfftMs > 0 - ? `${t("logs.details.performance.tfft")} ${formatDuration(log.tfftMs)}` + ? `${t("logs.details.performance.tfftShort")} ${formatDuration(log.tfftMs)}` : null; const rateLine = rate !== null && !hideRate ? `${rate.toFixed(0)} tok/s` : null; @@ -1234,6 +1240,8 @@ export function VirtualizedLogsTable({ requestSequence={log.requestSequence} blockedBy={log.blockedBy} blockedReason={log.blockedReason} + isReplay={log.isReplay} + replaySourceRequestId={log.replaySourceRequestId} originalModel={log.originalModel} currentModel={log.model} actualResponseModel={log.actualResponseModel} diff --git a/src/app/[locale]/dashboard/logs/_utils/logs-query.test.ts b/src/app/[locale]/dashboard/logs/_utils/logs-query.test.ts index dca1e9749..5b3c6714c 100644 --- a/src/app/[locale]/dashboard/logs/_utils/logs-query.test.ts +++ b/src/app/[locale]/dashboard/logs/_utils/logs-query.test.ts @@ -12,13 +12,15 @@ describe("logs-query", () => { endTime: 2000, statusCode: 500, model: "claude-sonnet", + actualResponseModelMismatch: undefined, endpoint: "/v1/messages", minRetryCount: 1, + replayFilter: "replay", page: 3, }); expect(query.toString()).toBe( - "userId=2&keyId=3&providerId=4&sessionId=session-abc&startTime=1000&endTime=2000&statusCode=500&model=claude-sonnet&endpoint=%2Fv1%2Fmessages&minRetry=1&page=3" + "userId=2&keyId=3&providerId=4&sessionId=session-abc&startTime=1000&endTime=2000&statusCode=500&model=claude-sonnet&endpoint=%2Fv1%2Fmessages&minRetry=1&replayFilter=replay&page=3" ); }); @@ -44,6 +46,7 @@ describe("logs-query", () => { model: "claude-sonnet", endpoint: "/v1/messages", minRetry: "1", + replayFilter: "non-replay", page: "3", }) ).toEqual({ @@ -56,9 +59,15 @@ describe("logs-query", () => { statusCode: undefined, excludeStatusCode200: true, model: "claude-sonnet", + actualResponseModelMismatch: undefined, endpoint: "/v1/messages", minRetryCount: 1, + replayFilter: "non-replay", page: 3, }); }); + + it("ignores invalid Replay filter values", () => { + expect(parseLogsUrlFilters({ replayFilter: "invalid" }).replayFilter).toBeUndefined(); + }); }); diff --git a/src/app/[locale]/dashboard/logs/_utils/logs-query.ts b/src/app/[locale]/dashboard/logs/_utils/logs-query.ts index 8e068e995..9fc0ac6ec 100644 --- a/src/app/[locale]/dashboard/logs/_utils/logs-query.ts +++ b/src/app/[locale]/dashboard/logs/_utils/logs-query.ts @@ -11,6 +11,7 @@ export interface LogsUrlFilters { actualResponseModelMismatch?: boolean; endpoint?: string; minRetryCount?: number; + replayFilter?: "all" | "replay" | "non-replay"; page?: number; } @@ -46,6 +47,13 @@ export function parseLogsUrlFilters(searchParams: { const actualResponseModelMismatch = parseStringParam(searchParams.actualResponseModelMismatch) === "true" ? true : undefined; + const replayFilterParam = parseStringParam(searchParams.replayFilter); + const replayFilter = + replayFilterParam === "all" || + replayFilterParam === "replay" || + replayFilterParam === "non-replay" + ? replayFilterParam + : undefined; return { userId: parseIntParam(searchParams.userId), @@ -60,6 +68,7 @@ export function parseLogsUrlFilters(searchParams: { actualResponseModelMismatch, endpoint: parseStringParam(searchParams.endpoint), minRetryCount: parseIntParam(searchParams.minRetry), + replayFilter, page, }; } @@ -91,6 +100,10 @@ export function buildLogsUrlQuery(filters: LogsUrlFilters): URLSearchParams { query.set("minRetry", filters.minRetryCount.toString()); } + if (filters.replayFilter && filters.replayFilter !== "all") { + query.set("replayFilter", filters.replayFilter); + } + if (filters.page !== undefined && filters.page > 1) { query.set("page", filters.page.toString()); } diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.tsx index 3eddefc61..99cfb9bc5 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/request-list-sidebar.tsx @@ -20,6 +20,7 @@ import { cn } from "@/lib/utils"; interface RequestItem { id: number; + sourceSessionId: string; sequence: number; model: string | null; statusCode: number | null; @@ -33,7 +34,8 @@ interface RequestItem { interface RequestListSidebarProps { sessionId: string; selectedSeq: number | null; - onSelect: (seq: number) => void; + selectedSourceSessionId: string | null; + onSelect: (sourceSessionId: string, seq: number) => void; collapsed?: boolean; className?: string; } @@ -41,6 +43,7 @@ interface RequestListSidebarProps { export function RequestListSidebar({ sessionId, selectedSeq, + selectedSourceSessionId, onSelect, collapsed = false, className, @@ -117,10 +120,11 @@ export function RequestListSidebar({ +
+ ), }; }); @@ -168,6 +180,7 @@ function buildDetailsData( overrides: Partial<{ snapshots: SessionDetailSnapshots | null; sessionStats: unknown | null; + currentSourceSessionId: string | null; currentSequence: number | null; prevSequence: number | null; nextSequence: number | null; @@ -188,6 +201,7 @@ function buildDetailsData( snapshots: createSnapshots(), specialSettings: null, sessionStats: null, + currentSourceSessionId: "physical-current", currentSequence: 7, prevSequence: null, nextSequence: null, @@ -351,16 +365,36 @@ describe("SessionMessagesClient (request export actions)", () => { click(nextBtn as HTMLButtonElement); expect(routerReplaceMock).toHaveBeenCalledWith( - "/dashboard/sessions/0123456789abcdef/messages?seq=6" + "/dashboard/sessions/0123456789abcdef/messages?seq=6&sourceSessionId=physical-current" ); expect(routerReplaceMock).toHaveBeenCalledWith( - "/dashboard/sessions/0123456789abcdef/messages?seq=8" + "/dashboard/sessions/0123456789abcdef/messages?seq=8&sourceSessionId=physical-current" ); expect(container.querySelector("[data-testid='mock-view-mode']")?.textContent).toBe("before"); unmount(); }); + test("stores the physical source Session together with the selected sequence", async () => { + getSessionDetailsMock.mockResolvedValue({ + ok: true, + data: buildDetailsData({ currentSequence: 1 }), + }); + + const { container, unmount } = renderClient(); + await flushEffects(); + + click( + container.querySelector("[data-testid='mock-select-physical-request']") as HTMLButtonElement + ); + + expect(routerReplaceMock).toHaveBeenCalledWith( + "/dashboard/sessions/0123456789abcdef/messages?seq=1&sourceSessionId=physical-selected" + ); + + unmount(); + }); + test("copy and download request payloads from the active view", async () => { const snapshots = createSnapshots(); getSessionDetailsMock.mockResolvedValue({ @@ -525,13 +559,15 @@ describe("SessionMessagesClient (request export actions)", () => { test("shows error when getSessionDetails returns ok:false", async () => { getSessionDetailsMock.mockResolvedValue({ ok: false, - error: "ERR_FETCH", + error: "legacy fallback", + errorCode: "SESSION_REQUEST_SOURCE_MISMATCH", }); const { container, unmount } = renderClient(); await flushEffects(); - expect(container.textContent).toContain("ERR_FETCH"); + expect(container.textContent).toContain("SESSION_REQUEST_SOURCE_MISMATCH"); + expect(container.textContent).not.toContain("legacy fallback"); unmount(); }); diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx index c1b5058c5..7f4c05d46 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx @@ -42,6 +42,7 @@ import { terminateActiveSession, } from "@/lib/api-client/v1/actions/active-sessions"; import { getSystemSettings } from "@/lib/api-client/v1/actions/system-config"; +import { getErrorMessage } from "@/lib/utils/error-messages"; import { DEFAULT_SESSION_DETAIL_VIEW_MODE, type SessionDetailSnapshots, @@ -54,6 +55,7 @@ import { SessionStats } from "./session-stats"; export function SessionMessagesClient() { const t = useTranslations("dashboard.sessions"); + const tErrors = useTranslations("errors"); const params = useParams(); const searchParams = useSearchParams(); @@ -63,6 +65,7 @@ export function SessionMessagesClient() { // URL state const seqParam = searchParams.get("seq"); + const selectedSourceSessionId = searchParams.get("sourceSessionId"); const selectedSeq = (() => { if (!seqParam) return null; const parsed = Number.parseInt(seqParam, 10); @@ -83,6 +86,7 @@ export function SessionMessagesClient() { useState< Extract>, { ok: true }>["data"]["sessionStats"] >(null); + const [currentSourceSessionId, setCurrentSourceSessionId] = useState(null); const [currentSequence, setCurrentSequence] = useState(null); const [prevSequence, setPrevSequence] = useState(null); const [nextSequence, setNextSequence] = useState(null); @@ -103,6 +107,7 @@ export function SessionMessagesClient() { setSnapshots(null); setSpecialSettings(null); setSessionStats(null); + setCurrentSourceSessionId(null); setCurrentSequence(null); setPrevSequence(null); setNextSequence(null); @@ -116,9 +121,14 @@ export function SessionMessagesClient() { const currencyCode = systemSettings?.currencyDisplay || "USD"; const handleSelectRequest = useCallback( - (seq: number) => { + (sourceSessionId: string | null, seq: number) => { const params = new URLSearchParams(window.location.search); params.set("seq", seq.toString()); + if (sourceSessionId) { + params.set("sourceSessionId", sourceSessionId); + } else { + params.delete("sourceSessionId"); + } router.replace(`${pathname}?${params.toString()}`); setIsMobileMenuOpen(false); }, @@ -133,19 +143,28 @@ export function SessionMessagesClient() { setError(null); try { - const result = await getSessionDetails(sessionId, selectedSeq ?? undefined); + const result = await getSessionDetails( + sessionId, + selectedSeq ?? undefined, + selectedSourceSessionId ?? undefined + ); if (cancelled) return; if (result.ok) { setSnapshots(result.data.snapshots); setSpecialSettings(result.data.specialSettings); setSessionStats(result.data.sessionStats); + setCurrentSourceSessionId(result.data.currentSourceSessionId); setCurrentSequence(result.data.currentSequence); setPrevSequence(result.data.prevSequence); setNextSequence(result.data.nextSequence); } else { resetDetailsState(); - setError(result.error || t("status.fetchFailed")); + setError( + result.errorCode + ? getErrorMessage(tErrors, result.errorCode, result.errorParams) + : result.error || t("status.fetchFailed") + ); } } catch (err) { if (cancelled) return; @@ -163,7 +182,7 @@ export function SessionMessagesClient() { return () => { cancelled = true; }; - }, [resetDetailsState, sessionId, selectedSeq, t]); + }, [resetDetailsState, selectedSeq, selectedSourceSessionId, sessionId, t, tErrors]); const currentRequestSnapshot = snapshots?.request[viewMode] ?? null; const currentResponseSnapshot = snapshots?.response[viewMode] ?? null; @@ -264,6 +283,7 @@ export function SessionMessagesClient() { @@ -289,6 +309,7 @@ export function SessionMessagesClient() { prevSequence && handleSelectRequest(prevSequence)} + onClick={() => + prevSequence && + handleSelectRequest( + selectedSourceSessionId ?? currentSourceSessionId, + prevSequence + ) + } > {t("details.prevRequest")} @@ -477,7 +504,13 @@ export function SessionMessagesClient() { variant="outline" size="sm" disabled={!nextSequence} - onClick={() => nextSequence && handleSelectRequest(nextSequence)} + onClick={() => + nextSequence && + handleSelectRequest( + selectedSourceSessionId ?? currentSourceSessionId, + nextSequence + ) + } className="flex-row-reverse" > diff --git a/src/app/[locale]/dashboard/sessions/_components/active-sessions-table.tsx b/src/app/[locale]/dashboard/sessions/_components/active-sessions-table.tsx index fd375c51b..0a8c9a362 100644 --- a/src/app/[locale]/dashboard/sessions/_components/active-sessions-table.tsx +++ b/src/app/[locale]/dashboard/sessions/_components/active-sessions-table.tsx @@ -381,7 +381,7 @@ export function ActiveSessionsTable({ )} - {session.sessionId.substring(0, 16)}... + {(session.sessionFingerprint ?? session.sessionId).substring(0, 16)}... diff --git a/src/app/[locale]/dashboard/sessions/_components/session-messages-dialog.tsx b/src/app/[locale]/dashboard/sessions/_components/session-messages-dialog.tsx index 12916192c..2c96c2467 100644 --- a/src/app/[locale]/dashboard/sessions/_components/session-messages-dialog.tsx +++ b/src/app/[locale]/dashboard/sessions/_components/session-messages-dialog.tsx @@ -13,6 +13,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { getSessionMessages } from "@/lib/api-client/v1/actions/active-sessions"; +import { getErrorMessage } from "@/lib/utils/error-messages"; interface SessionMessagesDialogProps { sessionId: string; @@ -20,6 +21,7 @@ interface SessionMessagesDialogProps { export function SessionMessagesDialog({ sessionId }: SessionMessagesDialogProps) { const t = useTranslations("dashboard.sessions"); + const tErrors = useTranslations("errors"); const [isOpen, setIsOpen] = useState(false); const [messages, setMessages] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -35,7 +37,11 @@ export function SessionMessagesDialog({ sessionId }: SessionMessagesDialogProps) if (result.ok) { setMessages(result.data); } else { - setError(result.error || t("status.fetchFailed")); + setError( + result.errorCode + ? getErrorMessage(tErrors, result.errorCode, result.errorParams) + : result.error || t("status.fetchFailed") + ); } } catch (err) { setError(err instanceof Error ? err.message : t("status.unknownError")); diff --git a/src/app/api/v1/resources/sessions/handlers.ts b/src/app/api/v1/resources/sessions/handlers.ts index 07e2e4a1f..fe1d4a3fb 100644 --- a/src/app/api/v1/resources/sessions/handlers.ts +++ b/src/app/api/v1/resources/sessions/handlers.ts @@ -44,6 +44,7 @@ export async function getSessionDetail(c: Context): Promise { if (params instanceof Response) return params; const query = SessionSequenceQuerySchema.safeParse({ requestSequence: c.req.query("requestSequence"), + sourceSessionId: c.req.query("sourceSessionId"), }); if (!query.success) return fromZodError(query.error, new URL(c.req.url).pathname); @@ -53,7 +54,7 @@ export async function getSessionDetail(c: Context): Promise { await callAction( c, actions.getSessionDetails, - [params.sessionId, query.data.requestSequence] as never[], + [params.sessionId, query.data.requestSequence, query.data.sourceSessionId] as never[], c.get("auth") ) ); @@ -64,6 +65,7 @@ export async function getSessionMessages(c: Context): Promise { if (params instanceof Response) return params; const query = SessionSequenceQuerySchema.safeParse({ requestSequence: c.req.query("requestSequence"), + sourceSessionId: c.req.query("sourceSessionId"), }); if (!query.success) return fromZodError(query.error, new URL(c.req.url).pathname); @@ -73,7 +75,7 @@ export async function getSessionMessages(c: Context): Promise { await callAction( c, actions.getSessionMessages, - [params.sessionId, query.data.requestSequence] as never[], + [params.sessionId, query.data.requestSequence, query.data.sourceSessionId] as never[], c.get("auth") ) ); @@ -84,6 +86,7 @@ export async function hasSessionMessages(c: Context): Promise { if (params instanceof Response) return params; const query = SessionSequenceQuerySchema.safeParse({ requestSequence: c.req.query("requestSequence"), + sourceSessionId: c.req.query("sourceSessionId"), }); if (!query.success) return fromZodError(query.error, new URL(c.req.url).pathname); @@ -91,7 +94,7 @@ export async function hasSessionMessages(c: Context): Promise { const result = await callAction( c, actions.hasSessionMessages, - [params.sessionId, query.data.requestSequence] as never[], + [params.sessionId, query.data.requestSequence, query.data.sourceSessionId] as never[], c.get("auth") ); if (!result.ok) return actionError(c, result); @@ -123,21 +126,36 @@ export async function getSessionRequests(c: Context): Promise { export async function getSessionOriginChain(c: Context): Promise { const params = parseSessionParams(c); if (params instanceof Response) return params; + const query = SessionSequenceQuerySchema.safeParse({ + requestSequence: c.req.query("requestSequence"), + sourceSessionId: c.req.query("sourceSessionId"), + }); + if (!query.success) return fromZodError(query.error, new URL(c.req.url).pathname); const actions = await import("@/actions/session-origin-chain"); return actionJson( c, - await callAction(c, actions.getSessionOriginChain, [params.sessionId] as never[], c.get("auth")) + await callAction( + c, + actions.getSessionOriginChain, + [params.sessionId, query.data.requestSequence, query.data.sourceSessionId] as never[], + c.get("auth") + ) ); } export async function getSessionResponseBody(c: Context): Promise { const params = parseSessionParams(c); if (params instanceof Response) return params; + const query = SessionSequenceQuerySchema.safeParse({ + requestSequence: c.req.query("requestSequence"), + sourceSessionId: c.req.query("sourceSessionId"), + }); + if (!query.success) return fromZodError(query.error, new URL(c.req.url).pathname); const actions = await import("@/actions/session-response"); const result = await callAction( c, actions.getSessionResponse, - [params.sessionId] as never[], + [params.sessionId, query.data.requestSequence, query.data.sourceSessionId] as never[], c.get("auth") ); if (!result.ok) return actionError(c, result); diff --git a/src/app/api/v1/resources/sessions/router.ts b/src/app/api/v1/resources/sessions/router.ts index 2cc411334..6911038b6 100644 --- a/src/app/api/v1/resources/sessions/router.ts +++ b/src/app/api/v1/resources/sessions/router.ts @@ -205,7 +205,7 @@ sessionsRouter.openapi( description: "Returns provider origin chain information for a session.", "x-required-access": "read", security, - request: { params: SessionIdParamSchema }, + request: { params: SessionIdParamSchema, query: SessionSequenceQuerySchema }, responses: { 200: { description: "Session origin chain.", @@ -227,7 +227,7 @@ sessionsRouter.openapi( description: "Returns the stored response body for a session.", "x-required-access": "read", security, - request: { params: SessionIdParamSchema }, + request: { params: SessionIdParamSchema, query: SessionSequenceQuerySchema }, responses: { 200: { description: "Session response body.", diff --git a/src/app/api/v1/resources/usage-logs/handlers.ts b/src/app/api/v1/resources/usage-logs/handlers.ts index 0526ea3f1..3b1fc95bd 100644 --- a/src/app/api/v1/resources/usage-logs/handlers.ts +++ b/src/app/api/v1/resources/usage-logs/handlers.ts @@ -168,6 +168,7 @@ function parseUsageLogsQuery(c: Context): UsageLogsActionQueryInput | Response { excludeStatusCode200: c.req.query("excludeStatusCode200"), endpoint: c.req.query("endpoint"), minRetryCount: c.req.query("minRetryCount"), + replayFilter: c.req.query("replayFilter"), startTime: c.req.query("startTime"), endTime: c.req.query("endTime"), }); diff --git a/src/app/v1/_lib/proxy-handler.ts b/src/app/v1/_lib/proxy-handler.ts index ad5fd55c2..e5417a0bb 100644 --- a/src/app/v1/_lib/proxy-handler.ts +++ b/src/app/v1/_lib/proxy-handler.ts @@ -3,6 +3,7 @@ import { findSafeDatabaseError } from "@/drizzle/admitted-client"; import { getCachedSystemSettings } from "@/lib/config"; import { logger } from "@/lib/logger"; import { ProxyStatusTracker } from "@/lib/proxy-status-tracker"; +import { SessionManager } from "@/lib/session-manager"; import { SessionTracker } from "@/lib/session-tracker"; import { ProxyErrorHandler } from "./proxy/error-handler"; import { attachSessionIdToErrorResponse } from "./proxy/error-session-id"; @@ -20,6 +21,27 @@ export async function handleProxyRequest(c: Context): Promise { let session: ProxySession | null = null; let cachedSystemSettings: Awaited> | null = null; let acquiredConcurrencySessionId: string | null = null; + let acquiredObservedSessionIdentity: string | null = null; + + const trackObservedSession = async (resolvedSession: ProxySession): Promise => { + if (!resolvedSession.shouldTrackSessionObservability()) return null; + const identity = resolvedSession.getSessionIdentityMetadata(); + if (!identity.identity) return null; + + void SessionTracker.trackObservedSession(identity.identity); + const authState = resolvedSession.authState; + if (authState?.user && authState.key) { + void SessionManager.storeSessionInfo(identity.identity, { + userName: authState.user.name, + userId: authState.user.id, + keyId: authState.key.id, + keyName: authState.key.name, + model: resolvedSession.request.model, + apiType: resolvedSession.originalFormat === "openai" ? "codex" : "chat", + }); + } + return identity.identity; + }; try { session = await ProxySession.fromContext(c); try { @@ -88,14 +110,25 @@ export async function handleProxyRequest(c: Context): Promise { // Run guard chain; may return early Response const early = await pipeline.run(session); if (early) { + const isReplayServe = early.headers.has("x-cch-replay"); + const isHandledWarmup = early.status === 200 && session.isWarmupRequest(); + if (!isReplayServe && !isHandledWarmup) { + await trackObservedSession(session); + } return await attachSessionIdToErrorResponse(session.sessionId, early); } + const observedSessionIdentity = await trackObservedSession(session); + // 9. 增加并发计数(在所有检查通过后,请求开始前)- 跳过 count_tokens if (session.sessionId && session.getEndpointPolicy().trackConcurrentRequests) { await SessionTracker.incrementConcurrentCount(session.sessionId); acquiredConcurrencySessionId = session.sessionId; } + if (observedSessionIdentity && session.getEndpointPolicy().trackConcurrentRequests) { + await SessionTracker.incrementObservedConcurrentCount(observedSessionIdentity); + acquiredObservedSessionIdentity = observedSessionIdentity; + } // 10. 记录请求开始 if (session.messageContext && session.provider) { @@ -161,5 +194,8 @@ export async function handleProxyRequest(c: Context): Promise { if (acquiredConcurrencySessionId) { await SessionTracker.decrementConcurrentCount(acquiredConcurrencySessionId); } + if (acquiredObservedSessionIdentity) { + await SessionTracker.decrementObservedConcurrentCount(acquiredObservedSessionIdentity); + } } } diff --git a/src/app/v1/_lib/proxy/affinity/affinity-recorder.ts b/src/app/v1/_lib/proxy/affinity/affinity-recorder.ts index 8eca681c8..5346912d2 100644 --- a/src/app/v1/_lib/proxy/affinity/affinity-recorder.ts +++ b/src/app/v1/_lib/proxy/affinity/affinity-recorder.ts @@ -31,7 +31,8 @@ export async function recordAffinityWinner( affinity.scopeTag, tip.fp, providerId, - getEnvConfig().PREFIX_AFFINITY_TTL_SECONDS + getEnvConfig().PREFIX_AFFINITY_TTL_SECONDS, + affinity.generation ); } catch (error) { logger.debug("[AffinityRecorder] winner writeback failed", { @@ -59,7 +60,12 @@ export async function tombstoneAffinityOnFailure( } try { if (!(await isAffinityRoutingEnabled())) return; - await getAffinityStore().tombstone(affinity.scopeTag, affinity.matchedFp, "failover"); + await getAffinityStore().tombstone( + affinity.scopeTag, + affinity.matchedFp, + "failover", + affinity.generation + ); } catch (error) { logger.debug("[AffinityRecorder] tombstone failed", { error: error instanceof Error ? error.message : String(error), diff --git a/src/app/v1/_lib/proxy/affinity/affinity-store.ts b/src/app/v1/_lib/proxy/affinity/affinity-store.ts index 08306fc8f..fe2671825 100644 --- a/src/app/v1/_lib/proxy/affinity/affinity-store.ts +++ b/src/app/v1/_lib/proxy/affinity/affinity-store.ts @@ -12,28 +12,62 @@ import { getRedisClient } from "@/lib/redis/client"; * 多键 Lua 在集群下无 CROSSSLOT;单机 Redis 下花括号只是键名的一部分,无副作用。) * * 值格式(管道串,避免 JSON 编解码开销): - * 活跃绑定 "1|" - * 墓碑 "0|"(failover 后短 TTL 防羊群,查找时跳过继续向浅—— + * 活跃绑定 "1||" + * 墓碑 "0||"(failover 后短 TTL 防羊群,查找时跳过继续向浅—— * 修复 CCHP 已知缺陷:最深命中为 disabled 时直接判 miss) * * 查找:单次 Lua 往返,KEYS 按最深->最浅传入,首个活跃值即最长前缀命中, * 命中时 EXPIRE 滑动续期(对齐 prompt cache 的「读即续」语义)。 * - * 一切 Redis 失败 fail-open:lookup 返回 null(回落加权随机),写操作静默放弃。 + * 路由路径上的 Redis 失败 fail-open:lookup 返回 null(回落加权随机),写操作静默放弃。 + * 管理终止使用 invalidate 的 boolean 结果区分命令成功与 Redis 故障。 */ const LOOKUP_LONGEST_PREFIX_LUA = ` +-- affinity_lookup_v2 local ttl = tonumber(ARGV[1]) -for i = 1, #KEYS do +local generationKey = KEYS[#KEYS] +local generation = redis.call('GET', generationKey) +if not generation then + redis.call('SET', generationKey, '0', 'NX') + generation = redis.call('GET', generationKey) +end +for i = 1, #KEYS - 1 do local v = redis.call('GET', KEYS[i]) if v and string.sub(v, 1, 2) == '1|' then - if ttl and ttl > 0 then - redis.call('EXPIRE', KEYS[i], ttl) + local bindingGeneration = string.match(v, '^1|[^|]+|([^|]+)$') or '0' + if bindingGeneration == generation then + if ttl and ttl > 0 then + redis.call('EXPIRE', KEYS[i], ttl) + end + return {i, v, generation} end - return {i, v} end end -return nil +return {0, '', generation} +`; + +const CAS_WRITE_LUA = ` +-- affinity_cas_write_v1 +local generation = redis.call('GET', KEYS[1]) +if not generation or generation ~= ARGV[1] then + return 0 +end +redis.call('SET', KEYS[2], ARGV[2], 'EX', tonumber(ARGV[3])) +return 1 +`; + +const INVALIDATE_LUA = ` +-- affinity_invalidate_v1 +local generation = redis.call('INCR', KEYS[1]) +if #KEYS > 1 then + local bindings = {} + for i = 2, #KEYS do + bindings[#bindings + 1] = KEYS[i] + end + redis.call('DEL', unpack(bindings)) +end +return generation `; const TOMBSTONE_TTL_SECONDS = 60; @@ -45,6 +79,11 @@ export interface AffinityHint { matchedIndex: number; } +export interface AffinityLookupResult { + hint: AffinityHint | null; + generation: string; +} + type RedisLuaClient = Pick & { eval(...args: [script: string, numkeys: number, ...rest: (string | number)[]]): Promise; }; @@ -73,6 +112,10 @@ export class AffinityStore { return `cch:pfx:{${scopeTag}}:fp:${fp}`; } + private buildGenerationKey(scopeTag: string): string { + return `cch:pfx:{${scopeTag}}:generation`; + } + /** * 最长前缀查找。fpsDeepestFirst 为最深->最浅的会话消息边界指纹序列 * (不含 F_sys:仅系统提示词相同不构成前缀命中)。 @@ -82,7 +125,7 @@ export class AffinityStore { scopeTag: string, fpsDeepestFirst: string[], slidingTtlSeconds: number - ): Promise { + ): Promise { if (!scopeTag || fpsDeepestFirst.length === 0) return null; const redis = this.getReadyRedis(); if (!redis) return null; @@ -95,21 +138,31 @@ export class AffinityStore { try { const result = (await redis.eval( LOOKUP_LONGEST_PREFIX_LUA, - keys.length, + keys.length + 1, ...keys, + this.buildGenerationKey(scopeTag), String(Math.max(0, Math.floor(slidingTtlSeconds))) - )) as [number, string] | null; + )) as [number, string, string] | null; - if (!result || !Array.isArray(result) || result.length < 2) return null; - const [index, value] = result; + if (!result || !Array.isArray(result) || result.length < 3) return null; + const [index, value, generation] = result; + if (!generation) return null; + const matchedIndex = Number(index) - 1; + if (matchedIndex < 0) { + return { hint: null, generation: String(generation) }; + } const providerId = Number.parseInt(String(value).slice(2), 10); - if (!Number.isFinite(providerId) || providerId <= 0) return null; + if (!Number.isFinite(providerId) || providerId <= 0) { + return { hint: null, generation: String(generation) }; + } - const matchedIndex = Number(index) - 1; return { - providerId, - matchedIndex, - matchedFp: fpsDeepestFirst[matchedIndex] ?? "", + generation: String(generation), + hint: { + providerId, + matchedIndex, + matchedFp: fpsDeepestFirst[matchedIndex] ?? "", + }, }; } catch (error) { logger.warn("[AffinityStore] lookup failed, falling back to no-affinity", { @@ -129,41 +182,90 @@ export class AffinityStore { scopeTag: string, tipFp: string, providerId: number, - ttlSeconds: number - ): Promise { - if (!scopeTag || !tipFp || providerId <= 0 || ttlSeconds <= 0) return; + ttlSeconds: number, + expectedGeneration: string | null | undefined + ): Promise { + if (!scopeTag || !tipFp || providerId <= 0 || ttlSeconds <= 0 || !expectedGeneration) { + return false; + } const redis = this.getReadyRedis(); - if (!redis) return; + if (!redis) return false; - const value = `1|${providerId}`; + const value = `1|${providerId}|${expectedGeneration}`; try { - await redis.set(this.buildKey(scopeTag, tipFp), value, "EX", ttlSeconds); + const result = await redis.eval( + CAS_WRITE_LUA, + 2, + this.buildGenerationKey(scopeTag), + this.buildKey(scopeTag, tipFp), + expectedGeneration, + value, + ttlSeconds + ); + return Number(result) === 1; } catch (error) { logger.warn("[AffinityStore] put failed", { error: error instanceof Error ? error.message : String(error), scopeTag, providerId, }); + return false; } } /** failover 墓碑:短 TTL 覆盖,阻止旧绑定立即复活,同时允许查找向浅回落。 */ - async tombstone(scopeTag: string, fp: string, reason: string): Promise { - if (!scopeTag || !fp) return; + async tombstone( + scopeTag: string, + fp: string, + reason: string, + expectedGeneration: string | null | undefined + ): Promise { + if (!scopeTag || !fp || !expectedGeneration) return false; const redis = this.getReadyRedis(); - if (!redis) return; + if (!redis) return false; try { - await redis.set( + const result = await redis.eval( + CAS_WRITE_LUA, + 2, + this.buildGenerationKey(scopeTag), this.buildKey(scopeTag, fp), - `0|${reason.slice(0, 32)}`, - "EX", + expectedGeneration, + `0|${reason.slice(0, 32)}|${expectedGeneration}`, TOMBSTONE_TTL_SECONDS ); + return Number(result) === 1; } catch (error) { logger.warn("[AffinityStore] tombstone failed", { error: error instanceof Error ? error.message : String(error), scopeTag, }); + return false; + } + } + + /** + * 管理员终止前缀 Session 时原子递增 scope generation,再删除目标及已知祖先。 + * 未知 descendant 与在途旧请求仍携带旧 generation,后续 lookup/CAS write 均会忽略。 + */ + async invalidate(scopeTag: string, fingerprints: string[]): Promise { + if (!scopeTag || fingerprints.length === 0) return false; + const redis = this.getReadyRedis(); + if (!redis) return false; + + const keys = [...new Set(fingerprints.filter(Boolean))].map((fp) => + this.buildKey(scopeTag, fp) + ); + if (keys.length === 0) return false; + + try { + await redis.eval(INVALIDATE_LUA, keys.length + 1, this.buildGenerationKey(scopeTag), ...keys); + return true; + } catch (error) { + logger.warn("[AffinityStore] invalidate failed", { + error: error instanceof Error ? error.message : String(error), + scopeTag, + }); + return false; } } } diff --git a/src/app/v1/_lib/proxy/message-service.test.ts b/src/app/v1/_lib/proxy/message-service.test.ts index 9efe9fefe..1ed77d4b0 100644 --- a/src/app/v1/_lib/proxy/message-service.test.ts +++ b/src/app/v1/_lib/proxy/message-service.test.ts @@ -36,6 +36,13 @@ function createSession(providerType: string, message: Record) { getRequestSequence: () => 1, getGroupCostMultiplier: () => "1", getMessagesLength: () => 1, + getSessionIdentityMetadata: () => ({ + identity: "pfx:scope123:fp-deep", + kind: "prefix_affinity", + scopeTag: "scope123", + fingerprint: "fp-deep", + fingerprints: ["fp-deep", "fp-mid"], + }), setMessageContext, } as unknown as ProxySession; @@ -102,4 +109,21 @@ describe("ProxyMessageService Codex reasoning effort audit", () => { expect(specialSettings).toHaveLength(1); }); + + test("前缀亲和 Session identity 与原始 sessionId 一起写入请求记录", async () => { + const { session } = createSession("codex", { reasoning: { effort: "high" } }); + + await ProxyMessageService.ensureContext(session); + + expect(createMessageRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + session_id: "session-1", + session_identity: "pfx:scope123:fp-deep", + session_identity_kind: "prefix_affinity", + affinity_scope_tag: "scope123", + affinity_fingerprint: "fp-deep", + affinity_fingerprint_chain: ["fp-deep", "fp-mid"], + }) + ); + }); }); diff --git a/src/app/v1/_lib/proxy/message-service.ts b/src/app/v1/_lib/proxy/message-service.ts index 3aec7e2a9..d5ea02c57 100644 --- a/src/app/v1/_lib/proxy/message-service.ts +++ b/src/app/v1/_lib/proxy/message-service.ts @@ -28,6 +28,7 @@ export class ProxyMessageService { // Extract endpoint from URL pathname (nullable) const endpoint = session.getEndpoint() ?? undefined; + const sessionIdentity = session.getSessionIdentityMetadata(); // 修复模型重定向记录问题: // 由于 ensureContext 在模型重定向之前被调用(guard-pipeline 阶段), @@ -80,6 +81,11 @@ export class ProxyMessageService { key: authState.apiKey, model: session.request.model ?? undefined, session_id: session.sessionId ?? undefined, // 传入 session_id + session_identity: sessionIdentity.identity || session.sessionId || undefined, + session_identity_kind: sessionIdentity.kind, + affinity_scope_tag: sessionIdentity.scopeTag, + affinity_fingerprint: sessionIdentity.fingerprint, + affinity_fingerprint_chain: sessionIdentity.fingerprints, request_sequence: session.getRequestSequence(), // 传入请求序号(Session 内) cost_multiplier: provider.costMultiplier, // 传入 cost_multiplier group_cost_multiplier: session.getGroupCostMultiplier(), // 传入分组倍率 diff --git a/src/app/v1/_lib/proxy/provider-selector.ts b/src/app/v1/_lib/proxy/provider-selector.ts index 464b5205e..795397e00 100644 --- a/src/app/v1/_lib/proxy/provider-selector.ts +++ b/src/app/v1/_lib/proxy/provider-selector.ts @@ -4,7 +4,7 @@ import { getEnvConfig } from "@/lib/config/env.schema"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { logger } from "@/lib/logger"; import { RateLimitService } from "@/lib/rate-limit"; -import { buildScopeTag } from "@/lib/request-identity"; +import { buildPublicSessionIdentity, buildScopeTag } from "@/lib/request-identity"; import { SessionManager } from "@/lib/session-manager"; import { getProxyRuntimeSettings, @@ -24,7 +24,11 @@ import type { ProviderChainItem } from "@/types/message"; import type { Provider } from "@/types/provider"; import { getAffinityStore } from "./affinity/affinity-store"; import { isAffinityRoutingEnabledWith } from "./affinity/config"; -import { computeFingerprintChain, fingerprintsDeepestFirst } from "./affinity/fingerprint"; +import { + computeFingerprintChain, + fingerprintsDeepestFirst, + fingerprintTip, +} from "./affinity/fingerprint"; import { isClientAllowedDetailed } from "./client-detector"; import type { ClientFormat } from "./format-mapper"; import { getVerboseProviderErrorCached } from "./provider-selector-settings-cache"; @@ -268,6 +272,34 @@ export class ProxyProviderResolver { session.setLastSelectionContext(context); // 保存用于后续记录 } + const affinityIdentityEnabled = + skipSessionBinding && session.affinity !== null && session.sessionId !== null; + if (affinityIdentityEnabled && session.affinity) { + const fingerprint = session.affinity.matchedFp ?? fingerprintTip(session.affinity.chain).fp; + const fingerprints = fingerprintsDeepestFirst(session.affinity.chain); + const matchedIndex = session.affinity.matchedFp + ? fingerprints.indexOf(session.affinity.matchedFp) + : 0; + session.setSessionIdentityMetadata({ + identity: `pfx:${session.affinity.scopeTag}:${fingerprint}`, + kind: "prefix_affinity", + scopeTag: session.affinity.scopeTag, + fingerprint, + fingerprints: matchedIndex >= 0 ? fingerprints.slice(matchedIndex) : [fingerprint], + }); + } else if (session.sessionId) { + session.setSessionIdentityMetadata({ + identity: buildPublicSessionIdentity( + session.sessionId, + session.authState?.key?.id ?? "unbound" + ), + kind: "session_id", + scopeTag: null, + fingerprint: null, + fingerprints: [], + }); + } + // === 故障转移循环 === let attemptCount = 0; while (true) { @@ -560,6 +592,7 @@ export class ProxyProviderResolver { chain, nominatedProviderId: null, matchedFp: null, + generation: null, }; return true; } @@ -577,11 +610,15 @@ export class ProxyProviderResolver { const affinity = session.affinity; if (!affinity) return; - const hint = await getAffinityStore().lookup( + const lookup = await getAffinityStore().lookup( affinity.scopeTag, fingerprintsDeepestFirst(affinity.chain), getEnvConfig().PREFIX_AFFINITY_TTL_SECONDS ); + if (!lookup) return; + + affinity.generation = lookup.generation; + const hint = lookup.hint; if (!hint) return; affinity.matchedFp = hint.matchedFp; diff --git a/src/app/v1/_lib/proxy/replay/replay-guard.ts b/src/app/v1/_lib/proxy/replay/replay-guard.ts index e31a994d4..4a5db8c69 100644 --- a/src/app/v1/_lib/proxy/replay/replay-guard.ts +++ b/src/app/v1/_lib/proxy/replay/replay-guard.ts @@ -4,6 +4,7 @@ import { messageRequest } from "@/drizzle/schema"; import { getEnvConfig } from "@/lib/config/env.schema"; import { logger } from "@/lib/logger"; import { getProxyRuntimeSettings } from "@/lib/system-settings/proxy-runtime"; +import { materializeReplayAuditFromSource } from "@/repository/message"; import type { ProxySession } from "../session"; import { restoreReplayResponseHeaders } from "./replay-headers"; import { deriveReplayIdentity, REPLAY_BYPASS_HEADER, type ReplayIdentity } from "./replay-identity"; @@ -102,7 +103,8 @@ export class ProxyReplayGuard { session, identity, meta.statusCode, - "redis_completed" + "redis_completed", + meta.messageRequestId ); return ProxyReplayGuard.buildStaticResponse(meta, chunks.join("")); } @@ -110,8 +112,7 @@ export class ProxyReplayGuard { } else if (meta.status === "owning") { const heartbeatFresh = Date.now() - meta.heartbeatAt < ATTACH_STALL_MS; if (meta.delivery !== "buffered" && env.REPLAY_LIVE_DEDUP_ENABLED && heartbeatFresh) { - await ProxyReplayGuard.writeAuditRow(session, identity, meta.statusCode, "attached_live"); - return ProxyReplayGuard.buildLiveAttachResponse(identity, meta, store); + return ProxyReplayGuard.buildLiveAttachResponse(session, identity, meta, store); } // 心跳过期(owner 崩溃/停机):不 attach 半截死流;owner 租约到期后可被重新 claim return null; @@ -124,7 +125,13 @@ export class ProxyReplayGuard { // Redis miss:查 PG 完成持久层(跨小时/跨副本/跨滚动发布) const persisted = await store.findCompleted(identity.replayId); if (persisted && persisted.verifier === identity.verifier && persisted.payload.length > 0) { - await ProxyReplayGuard.writeAuditRow(session, identity, persisted.statusCode, "pg_completed"); + await ProxyReplayGuard.writeAuditRow( + session, + identity, + persisted.statusCode, + "pg_completed", + persisted.sourceMessageRequestId + ); return ProxyReplayGuard.buildStaticResponse( { statusCode: persisted.statusCode, @@ -157,6 +164,7 @@ export class ProxyReplayGuard { * 订阅者断开只影响自身(cancel 时停止轮询),对 owner 零影响。 */ private static buildLiveAttachResponse( + session: ProxySession, identity: ReplayIdentity, initialMeta: ReplayMeta, store: ReplayStore @@ -169,6 +177,13 @@ export class ProxyReplayGuard { const startedAt = Date.now(); let lastProgressAt = Date.now(); + void ProxyReplayGuard.observeLiveAuditCompletion(session, identity, store).catch((error) => { + logger.warn("[ReplayGuard] live audit observer failed", { + replayId: identity.replayId.slice(0, 12), + error: error instanceof Error ? error.message : String(error), + }); + }); + const body = new ReadableStream({ async pull(controller) { while (!cancelled) { @@ -223,6 +238,39 @@ export class ProxyReplayGuard { return new Response(body, { status: initialMeta.statusCode || 200, headers }); } + /** + * live attach 的审计生命周期独立于客户端 reader。只有 source 已完成且携带 + * durable messageRequestId 时才创建 Replay 审计;失败、超时或取消不留下伪成功行。 + */ + private static async observeLiveAuditCompletion( + session: ProxySession, + identity: ReplayIdentity, + store: ReplayStore + ): Promise { + const startedAt = Date.now(); + let pollDelay = ATTACH_POLL_INITIAL_MS; + + while (Date.now() - startedAt <= ATTACH_MAX_WAIT_MS) { + const meta = await store.getMeta(identity.replayId); + if (!meta || meta.verifier !== identity.verifier || meta.status === "aborted") return; + if (meta.status === "completed") { + if (meta.messageRequestId) { + await ProxyReplayGuard.writeAuditRow( + session, + identity, + meta.statusCode, + "attached_live", + meta.messageRequestId + ); + } + return; + } + if (Date.now() - meta.heartbeatAt > ATTACH_STALL_MS) return; + await sleep(pollDelay); + pollDelay = Math.min(pollDelay * 2, ATTACH_POLL_MAX_MS); + } + } + private static buildServeHeaders( stored: Record, mode: "completed" | "live" @@ -239,36 +287,65 @@ export class ProxyReplayGuard { return headers; } - /** 审计行:costUsd 0、blockedBy replay_serve;不写 usageLedger、不绑 session/亲和。 */ + /** 审计行:保留 Replay provenance 与 usage 投影,costUsd 恒为 0。 */ private static async writeAuditRow( session: ProxySession, identity: ReplayIdentity, statusCode: number, - source: string - ): Promise { + source: string, + sourceRequestId?: number | null + ): Promise { try { - if (!session.authState?.user || !session.authState.apiKey) return; - await db.insert(messageRequest).values({ - providerId: 0, - userId: session.authState.user.id, - key: session.authState.apiKey, - model: session.request.model ?? undefined, - sessionId: session.sessionId ?? undefined, - statusCode: statusCode || 200, - costUsd: "0", - blockedBy: "replay_serve", - blockedReason: JSON.stringify({ - source, - replayId: identity.replayId.slice(0, 12), - }), - endpoint: identity.endpoint, - messagesCount: session.getMessagesLength(), - userAgent: session.userAgent ?? undefined, - }); + if (!session.authState?.user || !session.authState.apiKey) return null; + const [auditRow] = await db + .insert(messageRequest) + .values({ + providerId: 0, + userId: session.authState.user.id, + key: session.authState.apiKey, + model: session.request.model ?? undefined, + sessionId: session.sessionId ?? undefined, + requestSequence: session.requestSequence, + statusCode: statusCode || 200, + costUsd: "0", + blockedBy: null, + isReplay: true, + replaySourceRequestId: sourceRequestId, + blockedReason: JSON.stringify({ + source, + sourceRequestId: sourceRequestId ?? null, + replayId: identity.replayId.slice(0, 12), + }), + endpoint: identity.endpoint, + messagesCount: session.getMessagesLength(), + userAgent: session.userAgent ?? undefined, + }) + .returning({ id: messageRequest.id }); + + if (auditRow && sourceRequestId) { + await ProxyReplayGuard.tryMaterializeAudit(auditRow.id, sourceRequestId); + } + return auditRow?.id ?? null; } catch (error) { logger.warn("[ReplayGuard] audit row insert failed", { error: error instanceof Error ? error.message : String(error), }); + return null; + } + } + + private static async tryMaterializeAudit( + replayRequestId: number, + sourceRequestId: number + ): Promise { + try { + await materializeReplayAuditFromSource(replayRequestId, sourceRequestId); + } catch (error) { + logger.warn("[ReplayGuard] audit materialization failed", { + replayRequestId, + sourceRequestId, + error: error instanceof Error ? error.message : String(error), + }); } } } diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index d143eaf82..520fba795 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -6512,6 +6512,13 @@ async function trackCostToRedis( } ); } + if (session.shouldTrackSessionObservability()) { + void SessionTracker.refreshObservedSession( + session.getSessionIdentityMetadata().identity + ).catch((error) => { + logger.error("[ResponseHandler] Failed to refresh observed session tracker:", error); + }); + } } catch (error) { logger.error("[ResponseHandler] Failed to track cost to Redis, skipping", { error: error instanceof Error ? error.message : String(error), diff --git a/src/app/v1/_lib/proxy/session-guard.ts b/src/app/v1/_lib/proxy/session-guard.ts index 9bc559a01..714a3c09a 100644 --- a/src/app/v1/_lib/proxy/session-guard.ts +++ b/src/app/v1/_lib/proxy/session-guard.ts @@ -1,10 +1,13 @@ import { injectClaudeMetadataUserIdWithContext } from "@/lib/claude-code/metadata-user-id"; import { getCachedSystemSettings } from "@/lib/config"; +import { getEnvConfig } from "@/lib/config/env.schema"; import { logger } from "@/lib/logger"; import { resolveKeyUserConcurrentSessionLimits } from "@/lib/rate-limit/concurrent-session-limit"; +import { buildScopeTag } from "@/lib/request-identity"; import { headersToSanitizedObject, SessionManager } from "@/lib/session-manager"; import { SessionTracker } from "@/lib/session-tracker"; import { completeCodexSessionIdentifiers } from "../codex/session-completer"; +import { computeFingerprintChain, fingerprintTip } from "./affinity/fingerprint"; import type { ProxySession } from "./session"; const CLIENT_HEADER_SNAPSHOT_BLOCKLIST = [ @@ -144,6 +147,38 @@ export class ProxySessionGuard { // 4. 设置到 session 对象 session.setSessionId(sessionId); + if ( + systemSettings.affinityIgnoreClientSessionId && + session.getEndpointPolicy().kind === "default" + ) { + const chain = computeFingerprintChain( + session.request.message as Record, + session.originalFormat, + getEnvConfig().PREFIX_AFFINITY_WINDOW + ); + if (chain) { + session.affinity = { + scopeTag: buildScopeTag( + keyId, + session.originalFormat, + session.getOriginalModel() ?? session.request.model + ), + chain, + nominatedProviderId: null, + matchedFp: null, + generation: null, + }; + const fingerprint = fingerprintTip(chain).fp; + session.setSessionIdentityMetadata({ + identity: `pfx:${session.affinity.scopeTag}:${fingerprint}`, + kind: "prefix_affinity", + scopeTag: session.affinity.scopeTag, + fingerprint, + fingerprints: chain.tail.map((boundary) => boundary.fp).reverse(), + }); + } + } + if ( !allowRawSessionContext && claudeMetadataCompletionEnabled && diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 46093ca29..243f44193 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -28,6 +28,7 @@ import { type RoutingTraceSummaryV1, type RoutingTraceV1, } from "@/types/routing-trace"; +import type { SessionIdentityMetadata } from "@/types/session"; import type { SpecialSetting } from "@/types/special-settings"; import type { BillingModelSource, CodexPriorityBillingSource } from "@/types/system-config"; import type { User } from "@/types/user"; @@ -63,6 +64,8 @@ export interface SessionAffinityState { nominatedProviderId: number | null; /** 查找命中的边界指纹(未命中为 null) */ matchedFp: string | null; + /** lookup 捕获的 scope generation;终态写回必须以此做 CAS。 */ + generation: string | null; } /** @@ -176,6 +179,7 @@ export class ProxySession { // 最长前缀亲和状态(F3a 计算一次,供提名/写回/缓存效果指标复用) affinity: SessionAffinityState | null = null; + private sessionIdentityMetadata: SessionIdentityMetadata | null = null; // Replay 角色状态(F2 guard 阶段 claim owner 成功后填充,spool 由 handleStream 建立) replayState: SessionReplayState | null = null; @@ -613,6 +617,22 @@ export class ProxySession { this.sessionId = sessionId; } + setSessionIdentityMetadata(metadata: SessionIdentityMetadata): void { + this.sessionIdentityMetadata = metadata; + } + + getSessionIdentityMetadata(): SessionIdentityMetadata { + return ( + this.sessionIdentityMetadata ?? { + identity: this.sessionId ?? "", + kind: "session_id", + scopeTag: null, + fingerprint: null, + fingerprints: [], + } + ); + } + /** * 设置请求序号(Session 内) */ diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 67310fdc4..964ca4905 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -552,6 +552,17 @@ export const messageRequest = pgTable('message_request', { // Session ID(用于会话粘性和日志追踪) sessionId: varchar('session_id', { length: 64 }), + // 活跃 Session 聚合 identity;session_id 仍保留物理请求/快照归属 + sessionIdentity: varchar('session_identity', { length: 64 }), + sessionIdentityKind: varchar('session_identity_kind', { length: 20 }).$type<'session_id' | 'prefix_affinity'>(), + affinityScopeTag: varchar('affinity_scope_tag', { length: 16 }), + affinityFingerprint: varchar('affinity_fingerprint', { length: 64 }), + affinityFingerprintChain: jsonb('affinity_fingerprint_chain').$type(), + + // Replay 审计标记与原始请求 provenance;Replay 永远保持零成本 + isReplay: boolean('is_replay').notNull().default(false), + replaySourceRequestId: integer('replay_source_request_id'), + // Request Sequence(Session 内请求序号,用于区分同一 Session 的不同请求) requestSequence: integer('request_sequence').default(1), @@ -656,6 +667,11 @@ export const messageRequest = pgTable('message_request', { messageRequestSessionIdPrefixIdx: index('idx_message_request_session_id_prefix').on(sql`${table.sessionId} varchar_pattern_ops`).where(sql`${table.deletedAt} IS NULL AND (${table.blockedBy} IS NULL OR ${table.blockedBy} <> 'warmup')`), // Session + Sequence 复合索引(用于 Session 内请求列表查询) messageRequestSessionSeqIdx: index('idx_message_request_session_seq').on(table.sessionId, table.requestSequence).where(sql`${table.deletedAt} IS NULL`), + messageRequestSessionIdentityCreatedAtIdx: index( + 'idx_message_request_session_identity_created_at' + ) + .on(sql`COALESCE(${table.sessionIdentity}, ${table.sessionId})`, table.createdAt.desc()) + .where(sql`${table.deletedAt} IS NULL`), // Endpoint 过滤查询索引(仅针对未删除数据) messageRequestEndpointIdx: index('idx_message_request_endpoint').on(table.endpoint).where(sql`${table.deletedAt} IS NULL`), // blocked_by 过滤查询索引(用于排除 warmup/sensitive 等拦截请求) @@ -1159,6 +1175,13 @@ export const usageLedger = pgTable('usage_ledger', { endpoint: varchar('endpoint', { length: 256 }), apiType: varchar('api_type', { length: 20 }), sessionId: varchar('session_id', { length: 64 }), + sessionIdentity: varchar('session_identity', { length: 64 }), + sessionIdentityKind: varchar('session_identity_kind', { length: 20 }).$type<'session_id' | 'prefix_affinity'>(), + affinityScopeTag: varchar('affinity_scope_tag', { length: 16 }), + affinityFingerprint: varchar('affinity_fingerprint', { length: 64 }), + affinityFingerprintChain: jsonb('affinity_fingerprint_chain').$type(), + isReplay: boolean('is_replay').notNull().default(false), + replaySourceRequestId: integer('replay_source_request_id'), statusCode: integer('status_code'), isSuccess: boolean('is_success').notNull().default(false), successRateOutcome: varchar('success_rate_outcome', { length: 16 }), @@ -1187,13 +1210,13 @@ export const usageLedger = pgTable('usage_ledger', { usageLedgerRequestIdIdx: uniqueIndex('idx_usage_ledger_request_id').on(table.requestId), usageLedgerUserCreatedAtIdx: index('idx_usage_ledger_user_created_at') .on(table.userId, table.createdAt) - .where(sql`${table.blockedBy} IS NULL`), + .where(sql`${table.blockedBy} IS NULL AND ${table.isReplay} = false`), usageLedgerKeyCreatedAtIdx: index('idx_usage_ledger_key_created_at') .on(table.key, table.createdAt) - .where(sql`${table.blockedBy} IS NULL`), + .where(sql`${table.blockedBy} IS NULL AND ${table.isReplay} = false`), usageLedgerProviderCreatedAtIdx: index('idx_usage_ledger_provider_created_at') .on(table.finalProviderId, table.createdAt) - .where(sql`${table.blockedBy} IS NULL`), + .where(sql`${table.blockedBy} IS NULL AND ${table.isReplay} = false`), // Expression index on minute truncation - AT TIME ZONE 'UTC' makes date_trunc IMMUTABLE on timestamptz usageLedgerCreatedAtMinuteIdx: index('idx_usage_ledger_created_at_minute') .on(sql`date_trunc('minute', ${table.createdAt} AT TIME ZONE 'UTC')`), @@ -1202,6 +1225,11 @@ export const usageLedger = pgTable('usage_ledger', { usageLedgerSessionIdIdx: index('idx_usage_ledger_session_id') .on(table.sessionId) .where(sql`${table.sessionId} IS NOT NULL`), + usageLedgerSessionIdentityCreatedAtIdx: index( + 'idx_usage_ledger_session_identity_created_at' + ) + .on(sql`COALESCE(${table.sessionIdentity}, ${table.sessionId})`, table.createdAt.desc()) + .where(sql`${table.blockedBy} IS NULL AND ${table.isReplay} = false`), usageLedgerModelIdx: index('idx_usage_ledger_model') .on(table.model) .where(sql`${table.model} IS NOT NULL`), @@ -1209,23 +1237,23 @@ export const usageLedger = pgTable('usage_ledger', { // endpoint trailing column keeps LEDGER_BILLING_CONDITION's non-billing-endpoint filter index-only (Drizzle lacks INCLUDE support) usageLedgerKeyCostIdx: index('idx_usage_ledger_key_cost') .on(table.key, table.createdAt, table.costUsd, table.endpoint) - .where(sql`${table.blockedBy} IS NULL`), + .where(sql`${table.blockedBy} IS NULL AND ${table.isReplay} = false`), // #slow-query: covering index for SUM(cost_usd) per user (Quotas page + rate-limit total) // Keys: user_id (equality), created_at (range filter), cost_usd (aggregation, index-only scan) // endpoint trailing column keeps LEDGER_BILLING_CONDITION's non-billing-endpoint filter index-only (Drizzle lacks INCLUDE support) usageLedgerUserCostCoverIdx: index('idx_usage_ledger_user_cost_cover') .on(table.userId, table.createdAt, table.costUsd, table.endpoint) - .where(sql`${table.blockedBy} IS NULL`), + .where(sql`${table.blockedBy} IS NULL AND ${table.isReplay} = false`), // #slow-query: covering index for SUM(cost_usd) per provider (rate-limit total) // endpoint trailing column keeps LEDGER_BILLING_CONDITION's non-billing-endpoint filter index-only (Drizzle lacks INCLUDE support) usageLedgerProviderCostCoverIdx: index('idx_usage_ledger_provider_cost_cover') .on(table.finalProviderId, table.createdAt, table.costUsd, table.endpoint) - .where(sql`${table.blockedBy} IS NULL`), + .where(sql`${table.blockedBy} IS NULL AND ${table.isReplay} = false`), // #slow-query: covering index for LATERAL last-usage per key (getUsers) // finalProviderId as trailing key column for index-only scan (Drizzle lacks INCLUDE support) usageLedgerKeyCreatedAtDescCoverIdx: index('idx_usage_ledger_key_created_at_desc_cover') .on(table.key, sql`${table.createdAt} DESC NULLS LAST`, table.finalProviderId) - .where(sql`${table.blockedBy} IS NULL`), + .where(sql`${table.blockedBy} IS NULL AND ${table.isReplay} = false`), })); // Audit Log table - 面板登录和后台操作审计日志 diff --git a/src/lib/api-client/v1/actions/active-sessions.ts b/src/lib/api-client/v1/actions/active-sessions.ts index d082c20b2..9629b2520 100644 --- a/src/lib/api-client/v1/actions/active-sessions.ts +++ b/src/lib/api-client/v1/actions/active-sessions.ts @@ -28,29 +28,48 @@ export function getAllSessions(activePage?: number, inactivePage?: number, pageS ); } -export function getSessionMessages(sessionId: string, requestSequence?: number) { +export function getSessionMessages( + sessionId: string, + requestSequence?: number, + sourceSessionId?: string +) { return toActionResult( apiGet( `/api/v1/sessions/${encodeURIComponent(sessionId)}/messages${searchParams({ requestSequence, + sourceSessionId, })}` ) ); } -export function hasSessionMessages(sessionId: string, requestSequence?: number) { +export function hasSessionMessages( + sessionId: string, + requestSequence?: number, + sourceSessionId?: string +) { return toActionResult( apiGet<{ exists: boolean }>( `/api/v1/sessions/${encodeURIComponent(sessionId)}/messages/exists${searchParams({ requestSequence, + sourceSessionId, })}` ).then((body) => body.exists) ); } -export function getSessionDetails(sessionId: string, requestSequence?: number) { +export function getSessionDetails( + sessionId: string, + requestSequence?: number, + sourceSessionId?: string +) { return toActionResult( - apiGet(`/api/v1/sessions/${encodeURIComponent(sessionId)}${searchParams({ requestSequence })}`) + apiGet( + `/api/v1/sessions/${encodeURIComponent(sessionId)}${searchParams({ + requestSequence, + sourceSessionId, + })}` + ) ); } diff --git a/src/lib/api-client/v1/actions/session-origin-chain.ts b/src/lib/api-client/v1/actions/session-origin-chain.ts index aed89ffe0..22e57c402 100644 --- a/src/lib/api-client/v1/actions/session-origin-chain.ts +++ b/src/lib/api-client/v1/actions/session-origin-chain.ts @@ -1,5 +1,16 @@ -import { apiGet, toActionResult } from "./_compat"; +import { apiGet, searchParams, toActionResult } from "./_compat"; -export function getSessionOriginChain(sessionId: string) { - return toActionResult(apiGet(`/api/v1/sessions/${encodeURIComponent(sessionId)}/origin-chain`)); +export function getSessionOriginChain( + sessionId: string, + requestSequence?: number, + sourceSessionId?: string +) { + return toActionResult( + apiGet( + `/api/v1/sessions/${encodeURIComponent(sessionId)}/origin-chain${searchParams({ + requestSequence, + sourceSessionId, + })}` + ) + ); } diff --git a/src/lib/api-client/v1/actions/session-response.ts b/src/lib/api-client/v1/actions/session-response.ts index c26f48e9d..44a6c565e 100644 --- a/src/lib/api-client/v1/actions/session-response.ts +++ b/src/lib/api-client/v1/actions/session-response.ts @@ -1,9 +1,16 @@ -import { apiGet, toActionResult } from "./_compat"; +import { apiGet, searchParams, toActionResult } from "./_compat"; -export function getSessionResponse(sessionId: string) { +export function getSessionResponse( + sessionId: string, + requestSequence?: number, + sourceSessionId?: string +) { return toActionResult( apiGet<{ response: string | null }>( - `/api/v1/sessions/${encodeURIComponent(sessionId)}/response` + `/api/v1/sessions/${encodeURIComponent(sessionId)}/response${searchParams({ + requestSequence, + sourceSessionId, + })}` ).then((body) => body.response) ); } diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index 4de6b384e..2725db983 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -23094,6 +23094,8 @@ export interface operations { query?: { /** @description Request sequence. */ requestSequence?: number; + /** @description Physical source session id. */ + sourceSessionId?: string; }; header?: never; path: { @@ -23442,6 +23444,8 @@ export interface operations { query?: { /** @description Request sequence. */ requestSequence?: number; + /** @description Physical source session id. */ + sourceSessionId?: string; }; header?: never; path: { @@ -23616,6 +23620,8 @@ export interface operations { query?: { /** @description Request sequence. */ requestSequence?: number; + /** @description Physical source session id. */ + sourceSessionId?: string; }; header?: never; path: { @@ -23970,7 +23976,12 @@ export interface operations { }; getSessionsBySessionidOriginChain: { parameters: { - query?: never; + query?: { + /** @description Request sequence. */ + requestSequence?: number; + /** @description Physical source session id. */ + sourceSessionId?: string; + }; header?: never; path: { /** @description Session id. */ @@ -24141,7 +24152,12 @@ export interface operations { }; getSessionsBySessionidResponse: { parameters: { - query?: never; + query?: { + /** @description Request sequence. */ + requestSequence?: number; + /** @description Physical source session id. */ + sourceSessionId?: string; + }; header?: never; path: { /** @description Session id. */ @@ -35659,6 +35675,8 @@ export interface operations { endpoint?: string; /** @description Minimum retry count. */ minRetryCount?: number | null; + /** @description Replay request filter. */ + replayFilter?: "all" | "replay" | "non-replay"; /** @description Start timestamp in milliseconds. */ startTime?: number | null; /** @description End timestamp in milliseconds. */ @@ -35864,6 +35882,8 @@ export interface operations { endpoint?: string; /** @description Minimum retry count. */ minRetryCount?: number | null; + /** @description Replay request filter. */ + replayFilter?: "all" | "replay" | "non-replay"; /** @description Start timestamp in milliseconds. */ startTime?: number | null; /** @description End timestamp in milliseconds. */ @@ -36934,6 +36954,11 @@ export interface operations { endpoint?: string; /** @description Minimum retry count. */ minRetryCount?: number | null; + /** + * @description Replay request filter. + * @enum {string} + */ + replayFilter?: "all" | "replay" | "non-replay"; /** @description Start timestamp in milliseconds. */ startTime?: number | null; /** @description End timestamp in milliseconds. */ diff --git a/src/lib/api/v1/schemas/sessions.ts b/src/lib/api/v1/schemas/sessions.ts index d11668d27..f5345cb85 100644 --- a/src/lib/api/v1/schemas/sessions.ts +++ b/src/lib/api/v1/schemas/sessions.ts @@ -13,6 +13,7 @@ export const SessionsListQuerySchema = z.object({ export const SessionSequenceQuerySchema = z.object({ requestSequence: z.coerce.number().int().positive().optional().describe("Request sequence."), + sourceSessionId: z.string().min(1).optional().describe("Physical source session id."), }); export const SessionRequestsQuerySchema = z.object({ diff --git a/src/lib/api/v1/schemas/usage-logs.ts b/src/lib/api/v1/schemas/usage-logs.ts index a54303089..253bec9c6 100644 --- a/src/lib/api/v1/schemas/usage-logs.ts +++ b/src/lib/api/v1/schemas/usage-logs.ts @@ -29,6 +29,10 @@ export const UsageLogsQuerySchema = z.object({ excludeStatusCode200: BooleanQuerySchema.describe("Exclude successful responses."), endpoint: z.string().optional().describe("Endpoint filter."), minRetryCount: z.coerce.number().int().min(0).optional().describe("Minimum retry count."), + replayFilter: z + .enum(["all", "replay", "non-replay"]) + .optional() + .describe("Replay request filter."), startTime: NumberQuerySchema.describe("Start timestamp in milliseconds."), endTime: NumberQuerySchema.describe("End timestamp in milliseconds."), }); diff --git a/src/lib/availability/availability-service.ts b/src/lib/availability/availability-service.ts index ce48cca28..d465cb31d 100644 --- a/src/lib/availability/availability-service.ts +++ b/src/lib/availability/availability-service.ts @@ -130,6 +130,7 @@ function buildAvailabilityRequestConditions(input: { inArray(messageRequest.providerId, input.providerIds), buildTimestampLowerBound(messageRequest.createdAt, input.startDate, "startTime"), isNull(messageRequest.deletedAt), + eq(messageRequest.isReplay, false), buildAvailabilityFinalizedCondition(), ]; @@ -595,6 +596,7 @@ export async function getCurrentProviderStatus(): Promise< buildRelativeNowLowerBound(messageRequest.createdAt, CURRENT_PROVIDER_STATUS_WINDOW_MINUTES), buildNowUpperBound(messageRequest.createdAt), isNull(messageRequest.deletedAt), + eq(messageRequest.isReplay, false), buildAvailabilityFinalizedCondition() ); diff --git a/src/lib/config/env.schema.ts b/src/lib/config/env.schema.ts index c974a0a51..e604cec2e 100644 --- a/src/lib/config/env.schema.ts +++ b/src/lib/config/env.schema.ts @@ -185,7 +185,7 @@ export const EnvSchema = z.object({ // ===== CCHP 网关移植功能开关 ===== // 流式内容门控:off=关闭;shadow=旁路分类只记录分歧;enforce=首个有效内容帧前缓冲+failover - STREAM_GATE_MODE: z.enum(["off", "shadow", "enforce"]).default("off"), + STREAM_GATE_MODE: z.enum(["off", "shadow", "enforce"]).default("enforce"), // 门控 precommit 缓冲上限:超限即视为该供应商流异常,failover 释放内存 // (字节计数排除请求回显帧,见 stream-gate/frame-classifier.ts isRequestEchoFrame) STREAM_GATE_PREBUFFER_EVENT_CAP: z.coerce.number().int().min(1).max(4096).default(64), diff --git a/src/lib/config/system-settings-cache.ts b/src/lib/config/system-settings-cache.ts index 63b2d376e..ad59957ec 100644 --- a/src/lib/config/system-settings-cache.ts +++ b/src/lib/config/system-settings-cache.ts @@ -16,6 +16,7 @@ import { logger } from "@/lib/logger"; import { DEFAULT_SITE_TITLE } from "@/lib/site-title"; import { getSystemSettings } from "@/repository/system-config"; import type { SystemSettings } from "@/types/system-config"; +import { getEnvConfig } from "./env.schema"; /** Cache TTL in milliseconds (1 minute) */ const CACHE_TTL_MS = 60 * 1000; @@ -53,6 +54,14 @@ function getOpenaiResponsesWebsocketEnvOverride(): boolean | undefined { } } +function getFallbackStreamGateMode(): "off" | "shadow" | "enforce" { + try { + return getEnvConfig().STREAM_GATE_MODE; + } catch { + return "off"; + } +} + /** * Read the current in-memory settings cache only. * Never triggers a DB refresh. @@ -214,7 +223,7 @@ export async function getCachedSystemSettings(): Promise { publicStatusWindowHours: DEFAULT_SETTINGS.publicStatusWindowHours, publicStatusAggregationIntervalMinutes: DEFAULT_SETTINGS.publicStatusAggregationIntervalMinutes, - streamGateMode: DEFAULT_SETTINGS.streamGateMode, + streamGateMode: getFallbackStreamGateMode(), affinityIgnoreClientSessionId: DEFAULT_SETTINGS.affinityIgnoreClientSessionId, replayEnabled: null, cacheEffectivenessEnabled: null, diff --git a/src/lib/ledger-backfill/service.ts b/src/lib/ledger-backfill/service.ts index e4a720cb1..e3052c16b 100644 --- a/src/lib/ledger-backfill/service.ts +++ b/src/lib/ledger-backfill/service.ts @@ -69,6 +69,13 @@ export async function backfillUsageLedger( mr.endpoint, mr.api_type, mr.session_id, + mr.session_identity, + mr.session_identity_kind, + mr.affinity_scope_tag, + mr.affinity_fingerprint, + mr.affinity_fingerprint_chain, + mr.is_replay, + mr.replay_source_request_id, mr.status_code, fn_compute_message_request_success_rate_outcome( mr.blocked_by, @@ -76,10 +83,12 @@ export async function backfillUsageLedger( mr.error_message, mr.provider_chain ) AS success_rate_outcome, - (mr.error_message IS NULL OR mr.error_message = '') AS is_success, + (mr.error_message IS NULL OR mr.error_message = '') + AND (mr.status_code IS NULL OR mr.status_code < 400) AS is_success, mr.blocked_by, - mr.cost_usd, + CASE WHEN mr.is_replay THEN 0 ELSE mr.cost_usd END AS cost_usd, mr.cost_multiplier, + mr.group_cost_multiplier, mr.input_tokens, mr.output_tokens, mr.cache_creation_input_tokens, @@ -92,6 +101,7 @@ export async function backfillUsageLedger( mr.duration_ms, mr.ttfb_ms, mr.first_byte_ms, + mr.client_ip, mr.created_at, ul.request_id AS existing_request_id FROM message_request mr @@ -108,6 +118,16 @@ export async function backfillUsageLedger( AND ( ul.request_id IS NULL OR ul.success_rate_outcome IS NULL + OR ul.session_identity IS DISTINCT FROM mr.session_identity + OR ul.session_identity_kind IS DISTINCT FROM mr.session_identity_kind + OR ul.affinity_scope_tag IS DISTINCT FROM mr.affinity_scope_tag + OR ul.affinity_fingerprint IS DISTINCT FROM mr.affinity_fingerprint + OR ul.affinity_fingerprint_chain IS DISTINCT FROM mr.affinity_fingerprint_chain + OR ul.is_replay IS DISTINCT FROM mr.is_replay + OR ul.replay_source_request_id IS DISTINCT FROM mr.replay_source_request_id + OR (mr.is_replay AND ul.cost_usd IS DISTINCT FROM 0) + OR ul.group_cost_multiplier IS DISTINCT FROM mr.group_cost_multiplier + OR ul.client_ip IS DISTINCT FROM mr.client_ip ) ORDER BY mr.id ASC LIMIT 10000 @@ -116,13 +136,15 @@ export async function backfillUsageLedger( INSERT INTO usage_ledger ( request_id, user_id, key, provider_id, final_provider_id, model, original_model, actual_response_model, endpoint, api_type, session_id, + session_identity, session_identity_kind, affinity_scope_tag, + affinity_fingerprint, affinity_fingerprint_chain, is_replay, replay_source_request_id, status_code, is_success, success_rate_outcome, blocked_by, - cost_usd, cost_multiplier, + cost_usd, cost_multiplier, group_cost_multiplier, input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, cache_creation_5m_input_tokens, cache_creation_1h_input_tokens, cache_ttl_applied, context_1m_applied, swap_cache_ttl_applied, - duration_ms, ttfb_ms, first_byte_ms, created_at + duration_ms, ttfb_ms, first_byte_ms, client_ip, created_at ) SELECT batch.id, @@ -136,12 +158,20 @@ export async function backfillUsageLedger( batch.endpoint, batch.api_type, batch.session_id, + batch.session_identity, + batch.session_identity_kind, + batch.affinity_scope_tag, + batch.affinity_fingerprint, + batch.affinity_fingerprint_chain, + batch.is_replay, + batch.replay_source_request_id, batch.status_code, batch.is_success, batch.success_rate_outcome, batch.blocked_by, batch.cost_usd, batch.cost_multiplier, + batch.group_cost_multiplier, batch.input_tokens, batch.output_tokens, batch.cache_creation_input_tokens, @@ -154,10 +184,22 @@ export async function backfillUsageLedger( batch.duration_ms, batch.ttfb_ms, batch.first_byte_ms, + batch.client_ip, batch.created_at FROM batch ON CONFLICT (request_id) DO UPDATE SET - success_rate_outcome = EXCLUDED.success_rate_outcome + success_rate_outcome = EXCLUDED.success_rate_outcome, + session_identity = EXCLUDED.session_identity, + session_identity_kind = EXCLUDED.session_identity_kind, + affinity_scope_tag = EXCLUDED.affinity_scope_tag, + affinity_fingerprint = EXCLUDED.affinity_fingerprint, + affinity_fingerprint_chain = EXCLUDED.affinity_fingerprint_chain, + is_replay = EXCLUDED.is_replay, + replay_source_request_id = EXCLUDED.replay_source_request_id, + cost_usd = EXCLUDED.cost_usd, + group_cost_multiplier = EXCLUDED.group_cost_multiplier, + client_ip = EXCLUDED.client_ip, + is_success = EXCLUDED.is_success RETURNING request_id ) SELECT diff --git a/src/lib/ledger-backfill/trigger.sql b/src/lib/ledger-backfill/trigger.sql index 7d474aaad..bbe051782 100644 --- a/src/lib/ledger-backfill/trigger.sql +++ b/src/lib/ledger-backfill/trigger.sql @@ -196,6 +196,8 @@ BEGIN INSERT INTO usage_ledger ( request_id, user_id, key, provider_id, final_provider_id, model, original_model, actual_response_model, endpoint, api_type, session_id, + session_identity, session_identity_kind, affinity_scope_tag, + affinity_fingerprint, affinity_fingerprint_chain, is_replay, replay_source_request_id, status_code, is_success, success_rate_outcome, blocked_by, cost_usd, cost_multiplier, group_cost_multiplier, input_tokens, output_tokens, @@ -206,8 +208,11 @@ BEGIN ) VALUES ( NEW.id, NEW.user_id, NEW.key, NEW.provider_id, v_final_provider_id, NEW.model, NEW.original_model, NEW.actual_response_model, NEW.endpoint, NEW.api_type, NEW.session_id, + NEW.session_identity, NEW.session_identity_kind, NEW.affinity_scope_tag, + NEW.affinity_fingerprint, NEW.affinity_fingerprint_chain, NEW.is_replay, NEW.replay_source_request_id, NEW.status_code, v_is_success, v_success_rate_outcome, NEW.blocked_by, - NEW.cost_usd, NEW.cost_multiplier, NEW.group_cost_multiplier, + CASE WHEN NEW.is_replay THEN 0 ELSE NEW.cost_usd END, + NEW.cost_multiplier, NEW.group_cost_multiplier, NEW.input_tokens, NEW.output_tokens, NEW.cache_creation_input_tokens, NEW.cache_read_input_tokens, NEW.cache_creation_5m_input_tokens, NEW.cache_creation_1h_input_tokens, @@ -225,6 +230,13 @@ BEGIN endpoint = EXCLUDED.endpoint, api_type = EXCLUDED.api_type, session_id = EXCLUDED.session_id, + session_identity = EXCLUDED.session_identity, + session_identity_kind = EXCLUDED.session_identity_kind, + affinity_scope_tag = EXCLUDED.affinity_scope_tag, + affinity_fingerprint = EXCLUDED.affinity_fingerprint, + affinity_fingerprint_chain = EXCLUDED.affinity_fingerprint_chain, + is_replay = EXCLUDED.is_replay, + replay_source_request_id = EXCLUDED.replay_source_request_id, status_code = EXCLUDED.status_code, is_success = EXCLUDED.is_success, success_rate_outcome = EXCLUDED.success_rate_outcome, @@ -272,6 +284,13 @@ AFTER INSERT OR UPDATE OF original_model, api_type, session_id, + session_identity, + session_identity_kind, + affinity_scope_tag, + affinity_fingerprint, + affinity_fingerprint_chain, + is_replay, + replay_source_request_id, cost_usd, cost_multiplier, group_cost_multiplier, diff --git a/src/lib/migrate.ts b/src/lib/migrate.ts index ac25b3eee..0829587d3 100644 --- a/src/lib/migrate.ts +++ b/src/lib/migrate.ts @@ -6,6 +6,10 @@ import { drizzle } from "drizzle-orm/postgres-js"; import { migrate } from "drizzle-orm/postgres-js/migrator"; import postgres from "postgres"; import { logger } from "@/lib/logger"; +import { + type MigrationIndexPreflightExecutor, + runPendingSessionReplayIndexPreflight, +} from "@/lib/migrations/session-replay-index-preflight"; const MIGRATION_ADVISORY_LOCK_NAME = "claude-code-hub:migrations"; @@ -140,6 +144,46 @@ async function repairDrizzleMigrationsCreatedAt(input: { }); } +function createMigrationIndexPreflightExecutor( + client: ReturnType +): MigrationIndexPreflightExecutor { + return { + execute: async (sql) => { + await client.unsafe(sql); + }, + inspectIndex: async (name) => { + const qualifiedName = `public."${name}"`; + const [row] = await client` + SELECT + c.oid IS NOT NULL AS exists, + COALESCE(i.indisvalid, false) AS valid, + obj_description(c.oid, 'pg_class') AS marker + FROM (SELECT to_regclass(${qualifiedName}) AS oid) resolved + LEFT JOIN pg_class c ON c.oid = resolved.oid + LEFT JOIN pg_index i ON i.indexrelid = c.oid + `; + return { + exists: row?.exists === true, + valid: row?.valid === true, + marker: typeof row?.marker === "string" ? row.marker : null, + }; + }, + }; +} + +async function getLatestDrizzleMigrationCreatedAt( + client: ReturnType +): Promise { + const [row] = await client` + SELECT MAX(created_at) AS latest_created_at + FROM "drizzle"."__drizzle_migrations" + `; + const value = row?.latest_created_at; + const parsed = + typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN; + return Number.isFinite(parsed) ? parsed : null; +} + /** * 自动执行数据库迁移 * 在生产环境启动时自动运行 @@ -165,6 +209,10 @@ export async function runMigrations() { await ensureDrizzleMigrationsTableExists(migrationClient); await repairDrizzleMigrationsCreatedAt({ client: migrationClient, migrationsFolder }); + await runPendingSessionReplayIndexPreflight( + createMigrationIndexPreflightExecutor(migrationClient), + await getLatestDrizzleMigrationCreatedAt(migrationClient) + ); // 执行迁移 await migrate(db, { migrationsFolder }); diff --git a/src/lib/migrations/session-replay-index-preflight.ts b/src/lib/migrations/session-replay-index-preflight.ts new file mode 100644 index 000000000..10eb93326 --- /dev/null +++ b/src/lib/migrations/session-replay-index-preflight.ts @@ -0,0 +1,154 @@ +export const SESSION_REPLAY_MIGRATION_CREATED_AT = 1785563419224; +export const SESSION_REPLAY_INDEX_MARKER = "cch:migration:0116:session-replay-index:v1"; + +export type MigrationIndexState = { + exists: boolean; + valid: boolean; + marker: string | null; +}; + +export type MigrationIndexPreflightExecutor = { + execute(sql: string): Promise; + inspectIndex(name: string): Promise; +}; + +export type SessionReplayIndexSpec = { + canonicalName: string; + temporaryName: string; + definition: string; +}; + +export const SESSION_REPLAY_INDEX_SPECS: readonly SessionReplayIndexSpec[] = [ + { + canonicalName: "idx_message_request_session_identity_created_at", + temporaryName: "cch_0116_tmp_01", + definition: + 'ON "message_request" USING btree (COALESCE("session_identity", "session_id"),"created_at" DESC NULLS LAST) WHERE "message_request"."deleted_at" IS NULL', + }, + { + canonicalName: "idx_usage_ledger_session_identity_created_at", + temporaryName: "cch_0116_tmp_02", + definition: + 'ON "usage_ledger" USING btree (COALESCE("session_identity", "session_id"),"created_at" DESC NULLS LAST) WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false', + }, + { + canonicalName: "idx_usage_ledger_user_created_at", + temporaryName: "cch_0116_tmp_03", + definition: + 'ON "usage_ledger" USING btree ("user_id","created_at") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false', + }, + { + canonicalName: "idx_usage_ledger_key_created_at", + temporaryName: "cch_0116_tmp_04", + definition: + 'ON "usage_ledger" USING btree ("key","created_at") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false', + }, + { + canonicalName: "idx_usage_ledger_provider_created_at", + temporaryName: "cch_0116_tmp_05", + definition: + 'ON "usage_ledger" USING btree ("final_provider_id","created_at") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false', + }, + { + canonicalName: "idx_usage_ledger_key_cost", + temporaryName: "cch_0116_tmp_06", + definition: + 'ON "usage_ledger" USING btree ("key","created_at","cost_usd","endpoint") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false', + }, + { + canonicalName: "idx_usage_ledger_user_cost_cover", + temporaryName: "cch_0116_tmp_07", + definition: + 'ON "usage_ledger" USING btree ("user_id","created_at","cost_usd","endpoint") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false', + }, + { + canonicalName: "idx_usage_ledger_provider_cost_cover", + temporaryName: "cch_0116_tmp_08", + definition: + 'ON "usage_ledger" USING btree ("final_provider_id","created_at","cost_usd","endpoint") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false', + }, + { + canonicalName: "idx_usage_ledger_key_created_at_desc_cover", + temporaryName: "cch_0116_tmp_09", + definition: + 'ON "usage_ledger" USING btree ("key","created_at" DESC NULLS LAST,"final_provider_id") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false', + }, +]; + +function isValidated0116Index(state: MigrationIndexState): boolean { + return state.exists && state.valid && state.marker === SESSION_REPLAY_INDEX_MARKER; +} + +async function ensurePreflightColumns(executor: MigrationIndexPreflightExecutor): Promise { + await executor.execute("SET lock_timeout = '5s'"); + try { + await executor.execute(`ALTER TABLE "message_request" + ADD COLUMN IF NOT EXISTS "session_identity" varchar(64); +ALTER TABLE "usage_ledger" + ADD COLUMN IF NOT EXISTS "session_identity" varchar(64); +ALTER TABLE "usage_ledger" + ADD COLUMN IF NOT EXISTS "is_replay" boolean DEFAULT false NOT NULL`); + } finally { + await executor.execute("RESET lock_timeout"); + } +} + +export async function runSessionReplayIndexPreflight( + executor: MigrationIndexPreflightExecutor, + specs: readonly SessionReplayIndexSpec[] = SESSION_REPLAY_INDEX_SPECS +): Promise { + await ensurePreflightColumns(executor); + + for (const spec of specs) { + const canonical = await executor.inspectIndex(spec.canonicalName); + if (isValidated0116Index(canonical)) { + const staleTemp = await executor.inspectIndex(spec.temporaryName); + if (staleTemp.exists) { + await executor.execute(`DROP INDEX CONCURRENTLY IF EXISTS "${spec.temporaryName}"`); + } + continue; + } + + let temporary = await executor.inspectIndex(spec.temporaryName); + if (!isValidated0116Index(temporary)) { + if (temporary.exists) { + await executor.execute(`DROP INDEX CONCURRENTLY IF EXISTS "${spec.temporaryName}"`); + } + await executor.execute( + `CREATE INDEX CONCURRENTLY "${spec.temporaryName}" ${spec.definition}` + ); + await executor.execute( + `COMMENT ON INDEX "${spec.temporaryName}" IS '${SESSION_REPLAY_INDEX_MARKER}'` + ); + temporary = await executor.inspectIndex(spec.temporaryName); + if (!isValidated0116Index(temporary)) { + throw new Error(`0116 preflight produced an invalid index: ${spec.temporaryName}`); + } + } + + if (canonical.exists) { + await executor.execute(`DROP INDEX CONCURRENTLY IF EXISTS "${spec.canonicalName}"`); + } + await executor.execute(`ALTER INDEX "${spec.temporaryName}" RENAME TO "${spec.canonicalName}"`); + + const replaced = await executor.inspectIndex(spec.canonicalName); + if (!isValidated0116Index(replaced)) { + throw new Error(`0116 preflight failed to install index: ${spec.canonicalName}`); + } + } +} + +export async function runPendingSessionReplayIndexPreflight( + executor: MigrationIndexPreflightExecutor, + latestMigrationCreatedAt: number | null +): Promise { + if ( + latestMigrationCreatedAt != null && + Number.isFinite(latestMigrationCreatedAt) && + latestMigrationCreatedAt >= SESSION_REPLAY_MIGRATION_CREATED_AT + ) { + return; + } + + await runSessionReplayIndexPreflight(executor); +} diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index f449ccf1c..478a1a8cd 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -164,6 +164,7 @@ export class ProxyStatusTracker { and( isNull(messageRequest.deletedAt), isNull(messageRequest.durationMs), + eq(messageRequest.isReplay, false), isNull(providers.deletedAt) ) ); @@ -186,6 +187,7 @@ export class ProxyStatusTracker { JOIN providers p ON mr.provider_id = p.id AND p.deleted_at IS NULL LEFT JOIN keys k ON k.key = mr.key AND k.deleted_at IS NULL WHERE mr.deleted_at IS NULL + AND mr.is_replay = false AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') ORDER BY mr.user_id, mr.updated_at DESC `; diff --git a/src/lib/redis/active-session-keys.ts b/src/lib/redis/active-session-keys.ts index 99f7223a0..8eb824176 100644 --- a/src/lib/redis/active-session-keys.ts +++ b/src/lib/redis/active-session-keys.ts @@ -6,6 +6,7 @@ * - 目前仅对 global/key/user 三类 active_sessions key 统一加 hash tag;provider 维度不需要。 */ const ACTIVE_SESSIONS_HASH_TAG = "{active_sessions}"; +const OBSERVED_SESSIONS_HASH_TAG = "{observed_sessions}"; /** * 全局活跃 Session ZSET(仅用于观测 / Sessions 页面)。 @@ -14,6 +15,11 @@ export function getGlobalActiveSessionsKey(): string { return `${ACTIVE_SESSIONS_HASH_TAG}:global:active_sessions`; } +/** 统一后的有效 Session identity 观测集合。 */ +export function getObservedGlobalActiveSessionsKey(): string { + return `${OBSERVED_SESSIONS_HASH_TAG}:global:active_sessions`; +} + /** * Key 维度活跃 Session ZSET(用于 Key 并发上限判断)。 */ diff --git a/src/lib/request-identity.ts b/src/lib/request-identity.ts index c21d9f407..936232a34 100644 --- a/src/lib/request-identity.ts +++ b/src/lib/request-identity.ts @@ -39,6 +39,22 @@ export function buildScopeTag( return sha256Hex(`${keyId}|${format}|${model ?? ""}`).slice(0, 16); } +/** + * 构造用户可见的物理 Session identity。 + * + * `pfx:` 是前缀亲和 identity namespace,`sid:` 是物理 Session 的转义 namespace。 + * 对客户端可控的保留前缀做 key-bound 定长编码,避免 namespace alias 与 varchar(64) + * 溢出;普通 Session ID 保持原样,保留现有展示和查询语义。 + */ +export function buildPublicSessionIdentity(sessionId: string, keyId: number | string): string { + if (!sessionId.startsWith("pfx:") && !sessionId.startsWith("sid:")) { + return sessionId; + } + + const digest = sha256Hex(`session-identity:v1\0${keyId}\0${sessionId}`).slice(0, 32); + return `sid:${digest}`; +} + /** * 键序稳定的 JSON 序列化(对象键按字典序排序,数组保序)。 * 用于无原始 buffer 时从解析后 message 派生确定性字节。 diff --git a/src/lib/session-request-locator.ts b/src/lib/session-request-locator.ts new file mode 100644 index 000000000..eb3e01937 --- /dev/null +++ b/src/lib/session-request-locator.ts @@ -0,0 +1,56 @@ +import { BUSINESS_ERRORS } from "@/lib/utils/error-messages"; +import { normalizeRequestSequence } from "@/lib/utils/request-sequence"; +import { findSessionRequestLocator } from "@/repository/message"; + +type SessionRequestLocator = NonNullable>>; +type SessionRequestLocatorErrorCode = + | typeof BUSINESS_ERRORS.SESSION_REQUEST_SOURCE_MISMATCH + | typeof BUSINESS_ERRORS.SESSION_REQUEST_SELECTOR_INCOMPLETE; +type SessionRequestLocatorResult = + | { ok: true; locator: SessionRequestLocator } + | { ok: false; error: string; errorCode: SessionRequestLocatorErrorCode }; + +export async function resolveSessionRequestLocator( + identity: string, + requestSequence?: number, + sourceSessionId?: string +): Promise { + const normalizedSequence = normalizeRequestSequence(requestSequence); + const identityLocator = await findSessionRequestLocator(identity); + + if (!identityLocator) { + return { + ok: false, + error: "Request source does not belong to this session.", + errorCode: BUSINESS_ERRORS.SESSION_REQUEST_SOURCE_MISMATCH, + }; + } + + if ( + identityLocator.identityKind === "prefix_affinity" && + ((normalizedSequence !== null && !sourceSessionId) || + (normalizedSequence === null && sourceSessionId)) + ) { + return { + ok: false, + error: "Prefix Session requests must specify both the physical source and request sequence.", + errorCode: BUSINESS_ERRORS.SESSION_REQUEST_SELECTOR_INCOMPLETE, + }; + } + + const locator = + normalizedSequence !== null || sourceSessionId + ? await findSessionRequestLocator(identity, { + requestSequence: normalizedSequence ?? undefined, + sourceSessionId, + }) + : identityLocator; + + return locator + ? { ok: true, locator } + : { + ok: false, + error: "Request source does not belong to this session.", + errorCode: BUSINESS_ERRORS.SESSION_REQUEST_SOURCE_MISMATCH, + }; +} diff --git a/src/lib/session-tracker.ts b/src/lib/session-tracker.ts index dab2e9395..bdef1b38d 100644 --- a/src/lib/session-tracker.ts +++ b/src/lib/session-tracker.ts @@ -2,6 +2,7 @@ import { logger } from "@/lib/logger"; import { getGlobalActiveSessionsKey, getKeyActiveSessionsKey, + getObservedGlobalActiveSessionsKey, getUserActiveSessionsKey, } from "@/lib/redis/active-session-keys"; import { getRedisClient } from "./redis"; @@ -131,6 +132,68 @@ export class SessionTracker { } } + /** 记录用于 Dashboard/Sessions 页统一统计的有效 Session identity。 */ + static async trackObservedSession(sessionIdentity: string): Promise { + const redis = getRedisClient(); + if (redis?.status !== "ready" || !sessionIdentity) return; + + try { + const key = getObservedGlobalActiveSessionsKey(); + const pipeline = redis.pipeline(); + pipeline.zadd(key, Date.now(), sessionIdentity); + pipeline.expire(key, 3600); + await pipeline.exec(); + } catch (error) { + logger.error("SessionTracker: Failed to track observed session", { + error, + sessionIdentity, + }); + } + } + + /** 响应完成后刷新有效 Session identity 的滑动窗口。 */ + static async refreshObservedSession(sessionIdentity: string): Promise { + const redis = getRedisClient(); + if (redis?.status !== "ready" || !sessionIdentity) return; + + try { + const key = getObservedGlobalActiveSessionsKey(); + const pipeline = redis.pipeline(); + pipeline.zadd(key, Date.now(), sessionIdentity); + pipeline.expire(key, Math.max(3600, SessionTracker.SESSION_TTL_SECONDS)); + pipeline.expire(`session:${sessionIdentity}:info`, SessionTracker.SESSION_TTL_SECONDS); + await pipeline.exec(); + } catch (error) { + logger.error("SessionTracker: Failed to refresh observed session", { + error, + sessionIdentity, + }); + } + } + + /** 终止 Dashboard/Sessions 页使用的 observed Session identity。 */ + static async terminateObservedSession(sessionIdentity: string): Promise { + const redis = getRedisClient(); + if (redis?.status !== "ready" || !sessionIdentity) return false; + + try { + const pipeline = redis.pipeline(); + pipeline.zrem(getObservedGlobalActiveSessionsKey(), sessionIdentity); + pipeline.del(`observed_session:${sessionIdentity}:concurrent_count`); + pipeline.del(`session:${sessionIdentity}:info`); + const results = await pipeline.exec(); + if (!results) return false; + + return results.some(([error, deleted]) => error === null && Number(deleted) > 0); + } catch (error) { + logger.error("SessionTracker: Failed to terminate observed session", { + error, + sessionIdentity, + }); + return false; + } + } + /** * 更新 session 的 provider 信息(同时刷新时间戳) * @@ -320,6 +383,24 @@ export class SessionTracker { } } + static async getObservedGlobalSessionCount(): Promise { + const redis = getRedisClient(); + if (redis?.status !== "ready") return 0; + + try { + const key = getObservedGlobalActiveSessionsKey(); + if ((await redis.exists(key)) !== 1) return 0; + if ((await redis.type(key)) !== "zset") { + await redis.del(key); + return 0; + } + return await SessionTracker.countFromZSet(key); + } catch (error) { + logger.error("SessionTracker: Failed to count observed sessions", { error }); + return 0; + } + } + /** * 获取 Key 级活跃 session 计数 * @@ -594,6 +675,39 @@ export class SessionTracker { } } + static async getObservedActiveSessions(): Promise { + const redis = getRedisClient(); + if (redis?.status !== "ready") return []; + + try { + const key = getObservedGlobalActiveSessionsKey(); + if ((await redis.exists(key)) !== 1) return []; + if ((await redis.type(key)) !== "zset") { + await redis.del(key); + return []; + } + const cutoffMs = Date.now() - SessionTracker.SESSION_TTL_MS; + await redis.zremrangebyscore(key, "-inf", cutoffMs); + const sessionIdentities = await redis.zrange(key, 0, -1); + if (sessionIdentities.length === 0) return []; + + const pipeline = redis.pipeline(); + for (const sessionIdentity of sessionIdentities) { + pipeline.exists(`session:${sessionIdentity}:info`); + } + const results = await pipeline.exec(); + if (!results) return []; + + return sessionIdentities.filter((_, index) => { + const result = results[index]; + return result?.[0] === null && result[1] === 1; + }); + } catch (error) { + logger.error("SessionTracker: Failed to get observed sessions", { error }); + return []; + } + } + /** * 从 ZSET 计数(新格式) * @@ -678,6 +792,22 @@ export class SessionTracker { } } + static async incrementObservedConcurrentCount(sessionIdentity: string): Promise { + const redis = getRedisClient(); + if (redis?.status !== "ready" || !sessionIdentity) return; + + try { + const key = `observed_session:${sessionIdentity}:concurrent_count`; + await redis.incr(key); + await redis.expire(key, 600); + } catch (error) { + logger.error("SessionTracker: Failed to increment observed concurrent count", { + error, + sessionIdentity, + }); + } + } + /** * 减少 session 并发计数 * @@ -704,6 +834,22 @@ export class SessionTracker { } } + static async decrementObservedConcurrentCount(sessionIdentity: string): Promise { + const redis = getRedisClient(); + if (redis?.status !== "ready" || !sessionIdentity) return; + + try { + const key = `observed_session:${sessionIdentity}:concurrent_count`; + const newCount = await redis.decr(key); + if (newCount <= 0) await redis.del(key); + } catch (error) { + logger.error("SessionTracker: Failed to decrement observed concurrent count", { + error, + sessionIdentity, + }); + } + } + /** * 批量获取多个 session 的并发计数 * 用于 dashboard 显示优化,避免 N+1 查询 @@ -760,6 +906,34 @@ export class SessionTracker { } } + static async getObservedConcurrentCountBatch( + sessionIdentities: string[] + ): Promise> { + const result = new Map(); + if (sessionIdentities.length === 0) return result; + + const redis = getRedisClient(); + if (redis?.status !== "ready") return result; + + try { + const pipeline = redis.pipeline(); + for (const identity of sessionIdentities) { + pipeline.get(`observed_session:${identity}:concurrent_count`); + } + const values = await pipeline.exec(); + if (!values) return result; + values.forEach((entry, index) => { + const raw = entry?.[0] ? null : entry?.[1]; + const count = typeof raw === "string" ? Number.parseInt(raw, 10) : Number(raw ?? 0); + result.set(sessionIdentities[index], Number.isFinite(count) ? Math.max(0, count) : 0); + }); + return result; + } catch (error) { + logger.error("SessionTracker: Failed to get observed concurrent counts", { error }); + return result; + } + } + /** * 获取 session 当前并发计数 * diff --git a/src/lib/utils/error-messages.ts b/src/lib/utils/error-messages.ts index 70d5425e3..eb71f53d5 100644 --- a/src/lib/utils/error-messages.ts +++ b/src/lib/utils/error-messages.ts @@ -95,6 +95,8 @@ export const BUSINESS_ERRORS = { RESOURCE_BUSY: "RESOURCE_BUSY", INVALID_STATE: "INVALID_STATE", CONFLICT: "CONFLICT", + SESSION_REQUEST_SOURCE_MISMATCH: "SESSION_REQUEST_SOURCE_MISMATCH", + SESSION_REQUEST_SELECTOR_INCOMPLETE: "SESSION_REQUEST_SELECTOR_INCOMPLETE", USER_5H_FIXED_RESET_CLEANUP_FAILED: "USER_5H_FIXED_RESET_CLEANUP_FAILED", USER_LIMITS_RESET_PARTIAL_FAILURE: "USER_LIMITS_RESET_PARTIAL_FAILURE", USER_STATS_RESET_PARTIAL_FAILURE: "USER_STATS_RESET_PARTIAL_FAILURE", diff --git a/src/repository/_shared/ledger-conditions.ts b/src/repository/_shared/ledger-conditions.ts index 7918950e7..f03b86f1f 100644 --- a/src/repository/_shared/ledger-conditions.ts +++ b/src/repository/_shared/ledger-conditions.ts @@ -17,7 +17,7 @@ const NON_BILLING_LEDGER_ENDPOINT_CONDITION = sql`( * Warmup 行在触发器层面已过滤,不会进入 usage_ledger, * 此外 count_tokens / compact 虽写 message_request,但不得进入 billable ledger。 */ -export const LEDGER_BILLING_CONDITION = sql`(${usageLedger.blockedBy} IS NULL AND ${NON_BILLING_LEDGER_ENDPOINT_CONDITION})`; +export const LEDGER_BILLING_CONDITION = sql`(${usageLedger.blockedBy} IS NULL AND ${usageLedger.isReplay} = false AND ${NON_BILLING_LEDGER_ENDPOINT_CONDITION})`; /** * 非计费查询中排除被阻断请求的别名条件(语义更清晰)。 diff --git a/src/repository/_shared/usage-log-filters.ts b/src/repository/_shared/usage-log-filters.ts index 12358f20a..399c7c6e3 100644 --- a/src/repository/_shared/usage-log-filters.ts +++ b/src/repository/_shared/usage-log-filters.ts @@ -3,6 +3,8 @@ import { eq, gte, lt, sql } from "drizzle-orm"; import { messageRequest } from "@/drizzle/schema"; import { NON_BILLING_ENDPOINTS } from "@/lib/utils/performance-formatter"; +export type UsageLogReplayFilter = "all" | "replay" | "non-replay"; + export interface UsageLogFilterParams { sessionId?: string; startTime?: number; @@ -13,6 +15,7 @@ export interface UsageLogFilterParams { actualResponseModelMismatch?: boolean; endpoint?: string; minRetryCount?: number; + replayFilter?: UsageLogReplayFilter; } export const DEFAULT_HIDDEN_USAGE_LOG_ENDPOINTS = [...NON_BILLING_ENDPOINTS]; @@ -173,6 +176,12 @@ export function buildUsageLogConditions(filters: UsageLogFilterParams): SQL[] { ); } + if (filters.replayFilter === "replay") { + conditions.push(eq(messageRequest.isReplay, true)); + } else if (filters.replayFilter === "non-replay") { + conditions.push(eq(messageRequest.isReplay, false)); + } + const hiddenEndpointCondition = buildDefaultHiddenUsageLogEndpointCondition( messageRequest.endpoint, filters.endpoint diff --git a/src/repository/cache-hit-rate-alert.ts b/src/repository/cache-hit-rate-alert.ts index 230c9735f..8c896eb06 100644 --- a/src/repository/cache-hit-rate-alert.ts +++ b/src/repository/cache-hit-rate-alert.ts @@ -289,6 +289,7 @@ export async function findProviderModelCacheHitRateMetricsForAlert( const whereConditionsRaw = [ isNull(messageRequest.deletedAt), + eq(messageRequest.isReplay, false), EXCLUDE_WARMUP_CONDITION, gte(messageRequest.createdAt, timeRange.start), lt(messageRequest.createdAt, timeRange.end), @@ -332,6 +333,7 @@ export async function findProviderModelCacheHitRateMetricsForAlert( eq(prev.sessionId, messageRequest.sessionId), eq(prev.requestSequence, sql`(${messageRequest.requestSequence} - 1)`), isNull(prev.deletedAt), + eq(prev.isReplay, false), prevExcludeWarmupCondition ) ) diff --git a/src/repository/key.ts b/src/repository/key.ts index 029573145..d94749a43 100644 --- a/src/repository/key.ts +++ b/src/repository/key.ts @@ -998,6 +998,7 @@ async function _findKeysStatisticsBatchInternal( FROM usage_ledger ul WHERE ul.key = k.key_val AND ul.blocked_by IS NULL + AND ul.is_replay = false ORDER BY ul.created_at DESC LIMIT 1 ) lr ON true diff --git a/src/repository/message.ts b/src/repository/message.ts index fa0627b2a..a135c381a 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -1,6 +1,6 @@ "use server"; -import { and, asc, desc, eq, gt, inArray, isNull, lt, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, lt, sql } from "drizzle-orm"; import { db, getMessageWriterDb } from "@/drizzle/db"; import { keys as keysTable, messageRequest, providers, usageLedger, users } from "@/drizzle/schema"; import { getEnvConfig } from "@/lib/config/env.schema"; @@ -32,6 +32,8 @@ import { } from "./routing-trace-outbox"; const POST_TERMINAL_ROUTING_TRACE_ACK_TIMEOUT_MS = 3_000; +const ledgerSessionIdentity = sql`COALESCE(${usageLedger.sessionIdentity}, ${usageLedger.sessionId})`; +const messageSessionIdentity = sql`COALESCE(${messageRequest.sessionIdentity}, ${messageRequest.sessionId})`; type PublicStatusRequestSeed = { createdAt: Date; @@ -248,6 +250,13 @@ export async function createMessageRequest( costMultiplier: data.cost_multiplier?.toString() ?? undefined, // 供应商倍率(转为字符串) groupCostMultiplier: data.group_cost_multiplier?.toString() ?? undefined, // 分组倍率(转为字符串) sessionId: data.session_id, // Session ID + sessionIdentity: data.session_identity, + sessionIdentityKind: data.session_identity_kind, + affinityScopeTag: data.affinity_scope_tag, + affinityFingerprint: data.affinity_fingerprint, + affinityFingerprintChain: data.affinity_fingerprint_chain, + isReplay: data.is_replay, + replaySourceRequestId: data.replay_source_request_id, requestSequence: data.request_sequence, // Request Sequence(Session 内请求序号) routingTrace: data.routing_trace === undefined ? undefined : normalizeRoutingTrace(data.routing_trace), @@ -274,6 +283,13 @@ export async function createMessageRequest( costUsd: messageRequest.costUsd, costMultiplier: messageRequest.costMultiplier, // 新增 sessionId: messageRequest.sessionId, // 新增 + sessionIdentity: messageRequest.sessionIdentity, + sessionIdentityKind: messageRequest.sessionIdentityKind, + affinityScopeTag: messageRequest.affinityScopeTag, + affinityFingerprint: messageRequest.affinityFingerprint, + affinityFingerprintChain: messageRequest.affinityFingerprintChain, + isReplay: messageRequest.isReplay, + replaySourceRequestId: messageRequest.replaySourceRequestId, requestSequence: messageRequest.requestSequence, // Request Sequence routingTrace: messageRequest.routingTrace, userAgent: messageRequest.userAgent, // 新增 @@ -301,6 +317,54 @@ export async function createMessageRequest( return toMessageRequest(result); } +export async function materializeReplayAuditFromSource( + replayRequestId: number, + sourceRequestId: number +): Promise { + const rows = await db.execute(sql` + UPDATE message_request AS replay + SET + provider_id = source.provider_id, + model = source.model, + original_model = source.original_model, + actual_response_model = source.actual_response_model, + status_code = source.status_code, + input_tokens = source.input_tokens, + output_tokens = source.output_tokens, + cache_creation_input_tokens = source.cache_creation_input_tokens, + cache_read_input_tokens = source.cache_read_input_tokens, + cache_creation_5m_input_tokens = source.cache_creation_5m_input_tokens, + cache_creation_1h_input_tokens = source.cache_creation_1h_input_tokens, + cache_ttl_applied = source.cache_ttl_applied, + cost_multiplier = source.cost_multiplier, + group_cost_multiplier = source.group_cost_multiplier, + context_1m_applied = source.context_1m_applied, + swap_cache_ttl_applied = source.swap_cache_ttl_applied, + special_settings = source.special_settings, + session_identity = source.session_identity, + session_identity_kind = source.session_identity_kind, + affinity_scope_tag = source.affinity_scope_tag, + affinity_fingerprint = source.affinity_fingerprint, + affinity_fingerprint_chain = source.affinity_fingerprint_chain, + replay_source_request_id = source.id, + is_replay = TRUE, + cost_usd = 0, + cost_breakdown = NULL, + blocked_by = NULL, + updated_at = NOW() + FROM message_request AS source + WHERE replay.id = ${replayRequestId} + AND replay.is_replay = TRUE + AND source.id = ${sourceRequestId} + AND source.status_code >= 200 + AND source.status_code < 400 + AND COALESCE(source.error_message, '') = '' + RETURNING replay.id + `); + + return rows.length > 0; +} + /** * 更新消息请求的耗时 */ @@ -1200,7 +1264,7 @@ export async function aggregateSessionStats(sessionId: string): Promise<{ lastRequestAt: sql`max(${usageLedger.createdAt})`, }) .from(usageLedger) - .where(and(eq(usageLedger.sessionId, sessionId), LEDGER_BILLING_CONDITION)); + .where(and(eq(ledgerSessionIdentity, sessionId), LEDGER_BILLING_CONDITION)); if (!stats || stats.requestCount === 0) { return null; @@ -1216,7 +1280,7 @@ export async function aggregateSessionStats(sessionId: string): Promise<{ .leftJoin(providers, eq(usageLedger.finalProviderId, providers.id)) .where( and( - eq(usageLedger.sessionId, sessionId), + eq(ledgerSessionIdentity, sessionId), LEDGER_BILLING_CONDITION, sql`${usageLedger.finalProviderId} IS NOT NULL` ) @@ -1228,7 +1292,7 @@ export async function aggregateSessionStats(sessionId: string): Promise<{ .from(usageLedger) .where( and( - eq(usageLedger.sessionId, sessionId), + eq(ledgerSessionIdentity, sessionId), LEDGER_BILLING_CONDITION, sql`${usageLedger.model} IS NOT NULL` ) @@ -1240,7 +1304,7 @@ export async function aggregateSessionStats(sessionId: string): Promise<{ .from(usageLedger) .where( and( - eq(usageLedger.sessionId, sessionId), + eq(ledgerSessionIdentity, sessionId), LEDGER_BILLING_CONDITION, sql`${usageLedger.cacheTtlApplied} IS NOT NULL` ) @@ -1268,7 +1332,7 @@ export async function aggregateSessionStats(sessionId: string): Promise<{ .from(messageRequest) .innerJoin(users, eq(messageRequest.userId, users.id)) .innerJoin(keysTable, eq(messageRequest.key, keysTable.key)) - .where(and(eq(messageRequest.sessionId, sessionId), isNull(messageRequest.deletedAt))) + .where(and(eq(messageSessionIdentity, sessionId), isNull(messageRequest.deletedAt))) .orderBy(messageRequest.createdAt) .limit(1); @@ -1302,6 +1366,118 @@ export async function aggregateSessionStats(sessionId: string): Promise<{ }; } +/** 解析活跃 Session identity 到可查看的物理 Session/前缀绑定信息。 */ +export async function resolveSessionIdentity(identity: string): Promise<{ + sourceSessionId: string | null; + identityKind: "session_id" | "prefix_affinity" | null; + scopeTag: string | null; + fingerprint: string | null; + fingerprints: string[]; +} | null> { + const rows = await db + .select({ + sessionId: messageRequest.sessionId, + identityKind: messageRequest.sessionIdentityKind, + scopeTag: messageRequest.affinityScopeTag, + fingerprint: messageRequest.affinityFingerprint, + fingerprintChain: messageRequest.affinityFingerprintChain, + }) + .from(messageRequest) + .where(and(eq(messageSessionIdentity, identity), isNull(messageRequest.deletedAt))) + .orderBy(desc(messageRequest.createdAt)); + + if (rows.length === 0) return null; + + const fingerprints = new Set(); + for (const row of rows) { + if (row.fingerprint) fingerprints.add(row.fingerprint); + if (Array.isArray(row.fingerprintChain)) { + for (const fingerprint of row.fingerprintChain) { + if (typeof fingerprint === "string" && fingerprint) fingerprints.add(fingerprint); + } + } + } + + const identityKinds = new Set( + rows.map((row) => (row.identityKind === "prefix_affinity" ? "prefix_affinity" : "session_id")) + ); + + return { + sourceSessionId: rows.find((row) => row.sessionId)?.sessionId ?? null, + identityKind: identityKinds.size === 1 ? ([...identityKinds][0] ?? null) : null, + scopeTag: rows.find((row) => row.scopeTag)?.scopeTag ?? null, + fingerprint: rows.find((row) => row.fingerprint)?.fingerprint ?? null, + fingerprints: [...fingerprints], + }; +} + +/** 验证物理 Session 是否属于指定的聚合 identity。 */ +export async function isSessionSourceForIdentity( + identity: string, + sourceSessionId: string +): Promise { + const [row] = await db + .select({ id: messageRequest.id }) + .from(messageRequest) + .where( + and( + eq(messageSessionIdentity, identity), + eq(messageRequest.sessionId, sourceSessionId), + isNull(messageRequest.deletedAt) + ) + ) + .limit(1); + + return Boolean(row); +} + +export async function findSessionRequestLocator( + identity: string, + selector: { sourceSessionId?: string; requestSequence?: number } = {} +): Promise<{ + sourceSessionId: string; + requestSequence: number; + identityKind: "session_id" | "prefix_affinity"; + scopeTag: string | null; + fingerprint: string | null; +} | null> { + const [row] = await db + .select({ + sourceSessionId: messageRequest.sessionId, + requestSequence: messageRequest.requestSequence, + identityKind: messageRequest.sessionIdentityKind, + scopeTag: messageRequest.affinityScopeTag, + fingerprint: messageRequest.affinityFingerprint, + }) + .from(messageRequest) + .where( + and( + eq(messageSessionIdentity, identity), + isNotNull(messageRequest.sessionId), + isNotNull(messageRequest.requestSequence), + selector.sourceSessionId + ? eq(messageRequest.sessionId, selector.sourceSessionId) + : undefined, + selector.requestSequence !== undefined + ? eq(messageRequest.requestSequence, selector.requestSequence) + : undefined, + isNull(messageRequest.deletedAt) + ) + ) + .orderBy(desc(messageRequest.createdAt), desc(messageRequest.id)) + .limit(1); + + if (!row?.sourceSessionId || row.requestSequence == null) return null; + + return { + sourceSessionId: row.sourceSessionId, + requestSequence: row.requestSequence, + identityKind: row.identityKind === "prefix_affinity" ? "prefix_affinity" : "session_id", + scopeTag: row.scopeTag, + fingerprint: row.fingerprint, + }; +} + /** * 批量聚合多个 session 的统计数据(性能优化版本) * @@ -1340,7 +1516,7 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi // 1. 批量聚合统计(从 usageLedger,单次查询) const statsResults = await db .select({ - sessionId: usageLedger.sessionId, + sessionId: ledgerSessionIdentity, requestCount: sql`count(*)::double precision`, totalCostUsd: sql`COALESCE(sum(${usageLedger.costUsd}), 0)`, totalInputTokens: sql`COALESCE(sum(${usageLedger.inputTokens})::double precision, 0::double precision)`, @@ -1352,8 +1528,8 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi lastRequestAt: sql`max(${usageLedger.createdAt})`, }) .from(usageLedger) - .where(and(inArray(usageLedger.sessionId, sessionIds), LEDGER_BILLING_CONDITION)) - .groupBy(usageLedger.sessionId); + .where(and(inArray(ledgerSessionIdentity, sessionIds), LEDGER_BILLING_CONDITION)) + .groupBy(ledgerSessionIdentity); // 创建 sessionId → stats 的 Map const statsMap = new Map(statsResults.map((s) => [s.sessionId, s])); @@ -1361,7 +1537,7 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi // 2. 批量查询供应商列表(按 session 分组) const providerResults = await db .selectDistinct({ - sessionId: usageLedger.sessionId, + sessionId: ledgerSessionIdentity, providerId: usageLedger.finalProviderId, providerName: providers.name, }) @@ -1369,7 +1545,7 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi .leftJoin(providers, eq(usageLedger.finalProviderId, providers.id)) .where( and( - inArray(usageLedger.sessionId, sessionIds), + inArray(ledgerSessionIdentity, sessionIds), LEDGER_BILLING_CONDITION, sql`${usageLedger.finalProviderId} IS NOT NULL` ) @@ -1393,13 +1569,13 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi // 3. 批量查询模型列表(按 session 分组) const modelResults = await db .selectDistinct({ - sessionId: usageLedger.sessionId, + sessionId: ledgerSessionIdentity, model: usageLedger.model, }) .from(usageLedger) .where( and( - inArray(usageLedger.sessionId, sessionIds), + inArray(ledgerSessionIdentity, sessionIds), LEDGER_BILLING_CONDITION, sql`${usageLedger.model} IS NOT NULL` ) @@ -1420,13 +1596,13 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi // 3.1 批量查询 Cache TTL 列表(按 session 分组) const cacheTtlResults = await db .selectDistinct({ - sessionId: usageLedger.sessionId, + sessionId: ledgerSessionIdentity, cacheTtl: usageLedger.cacheTtlApplied, }) .from(usageLedger) .where( and( - inArray(usageLedger.sessionId, sessionIds), + inArray(ledgerSessionIdentity, sessionIds), LEDGER_BILLING_CONDITION, sql`${usageLedger.cacheTtlApplied} IS NOT NULL` ) @@ -1464,7 +1640,7 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi CROSS JOIN LATERAL ( SELECT user_id, key, user_agent, api_type FROM message_request - WHERE session_id = sid AND deleted_at IS NULL + WHERE COALESCE(session_identity, session_id) = sid AND deleted_at IS NULL ORDER BY created_at LIMIT 1 ) mr @@ -1723,6 +1899,7 @@ export async function findRequestsBySessionId( ): Promise<{ requests: Array<{ id: number; + sourceSessionId: string; sequence: number; model: string | null; statusCode: number | null; @@ -1748,6 +1925,7 @@ export async function findRequestsBySessionId( const results = await db .select({ id: messageRequest.id, + sessionId: messageRequest.sessionId, sequence: messageRequest.requestSequence, model: messageRequest.model, statusCode: messageRequest.statusCode, @@ -1768,6 +1946,7 @@ export async function findRequestsBySessionId( return { requests: results.map((r) => ({ id: r.id, + sourceSessionId: r.sessionId ?? sessionId, sequence: r.sequence ?? 1, model: r.model, statusCode: r.statusCode, @@ -1781,6 +1960,65 @@ export async function findRequestsBySessionId( }; } +export async function findRequestsBySessionIdentity( + identity: string, + options?: { limit?: number; offset?: number; order?: "asc" | "desc" } +): Promise>> { + const { limit = 20, offset = 0, order = "asc" } = options || {}; + const where = and( + eq(messageSessionIdentity, identity), + isNotNull(messageRequest.sessionId), + isNull(messageRequest.deletedAt) + ); + const [countResult] = await db + .select({ count: sql`count(*)::int` }) + .from(messageRequest) + .where(where); + const results = await db + .select({ + id: messageRequest.id, + sessionId: messageRequest.sessionId, + sequence: messageRequest.requestSequence, + model: messageRequest.model, + statusCode: messageRequest.statusCode, + costUsd: messageRequest.costUsd, + createdAt: messageRequest.createdAt, + inputTokens: messageRequest.inputTokens, + outputTokens: messageRequest.outputTokens, + errorMessage: messageRequest.errorMessage, + }) + .from(messageRequest) + .where(where) + .orderBy( + order === "asc" ? asc(messageRequest.createdAt) : desc(messageRequest.createdAt), + order === "asc" ? asc(messageRequest.id) : desc(messageRequest.id) + ) + .limit(limit) + .offset(offset); + + return { + requests: results.flatMap((row) => + row.sessionId + ? [ + { + id: row.id, + sourceSessionId: row.sessionId, + sequence: row.sequence ?? 1, + model: row.model, + statusCode: row.statusCode, + costUsd: row.costUsd, + createdAt: row.createdAt, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + errorMessage: row.errorMessage, + }, + ] + : [] + ), + total: countResult?.count ?? 0, + }; +} + export async function findAdjacentRequestSequences( sessionId: string, sequence: number diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 2901e372f..48ea8695a 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -2335,6 +2335,7 @@ export async function getProviderStatistics(): Promise COUNT(*)::integer AS today_calls FROM usage_ledger WHERE blocked_by IS NULL + AND is_replay = false AND created_at >= (SELECT today_start FROM bounds) AND created_at < (SELECT tomorrow_start FROM bounds) GROUP BY final_provider_id @@ -2346,6 +2347,7 @@ export async function getProviderStatistics(): Promise model AS last_call_model FROM usage_ledger WHERE blocked_by IS NULL + AND is_replay = false AND created_at >= (SELECT last7_start FROM bounds) -- 性能优化:添加 7 天时间范围限制(避免扫描历史数据) ORDER BY final_provider_id, created_at DESC, id DESC diff --git a/src/repository/usage-logs.ts b/src/repository/usage-logs.ts index 9a20754b8..220883a19 100644 --- a/src/repository/usage-logs.ts +++ b/src/repository/usage-logs.ts @@ -12,7 +12,6 @@ import type { HedgeLoserBilling, StoredCostBreakdown } from "@/types/cost-breakd import type { ProviderChainItem } from "@/types/message"; import { normalizeRoutingTrace, type RoutingTraceV1 } from "@/types/routing-trace"; import type { SpecialSetting } from "@/types/special-settings"; -import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; import { escapeLike } from "./_shared/like"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; import { @@ -21,6 +20,7 @@ import { buildUsageLogConditions, buildUsageLogEndpointMatchCondition, RETRY_COUNT_EXPR, + type UsageLogReplayFilter, } from "./_shared/usage-log-filters"; export interface UsageLogFilters { @@ -42,10 +42,23 @@ export interface UsageLogFilters { endpoint?: string; /** 最低重试次数(按 provider_chain 中“实际请求”数量 - 1 计算;<= 0 视为不筛选) */ minRetryCount?: number; + replayFilter?: UsageLogReplayFilter; page?: number; pageSize?: number; } +function buildLedgerUsageLogConditions(replayFilter: UsageLogReplayFilter | undefined) { + const conditions = [isNull(usageLedger.blockedBy)]; + + if (replayFilter === "replay") { + conditions.push(eq(usageLedger.isReplay, true)); + } else if (replayFilter === "non-replay") { + conditions.push(eq(usageLedger.isReplay, false)); + } + + return conditions; +} + export interface UsageLogRow { id: number; createdAt: Date | null; @@ -80,6 +93,8 @@ export interface UsageLogRow { routingTrace?: RoutingTraceV1 | null; blockedBy: string | null; // 拦截类型(如 'sensitive_word') blockedReason: string | null; // 拦截原因(JSON 字符串) + isReplay: boolean; + replaySourceRequestId: number | null; userAgent: string | null; // User-Agent(客户端信息) clientIp: string | null; // 客户端 IP(IPv4/IPv6) messagesCount: number | null; // Messages 数量 @@ -223,6 +238,8 @@ export async function findUsageLogsBatch( routingTrace: messageRequest.routingTrace, blockedBy: messageRequest.blockedBy, blockedReason: messageRequest.blockedReason, + isReplay: messageRequest.isReplay, + replaySourceRequestId: messageRequest.replaySourceRequestId, userAgent: messageRequest.userAgent, clientIp: messageRequest.clientIp, messagesCount: messageRequest.messagesCount, @@ -298,7 +315,7 @@ export async function findUsageLogsBatch( return { logs: [], nextCursor: null, hasMore: false }; } - const ledgerConditions = [LEDGER_BILLING_CONDITION]; + const ledgerConditions = buildLedgerUsageLogConditions(filters.replayFilter); if (userId !== undefined) { ledgerConditions.push(eq(usageLedger.userId, userId)); @@ -403,6 +420,8 @@ export async function findUsageLogsBatch( clientIp: usageLedger.clientIp, context1mApplied: usageLedger.context1mApplied, swapCacheTtlApplied: usageLedger.swapCacheTtlApplied, + isReplay: usageLedger.isReplay, + replaySourceRequestId: usageLedger.replaySourceRequestId, }) .from(usageLedger) .leftJoin(users, eq(usageLedger.userId, users.id)) @@ -462,6 +481,8 @@ export async function findUsageLogsBatch( routingTrace: null, blockedBy: null, blockedReason: null, + isReplay: row.isReplay, + replaySourceRequestId: row.replaySourceRequestId, userAgent: null, clientIp: row.clientIp ?? null, messagesCount: null, @@ -490,6 +511,7 @@ interface UsageLogSlimFilters { endpoint?: string; /** 最低重试次数(按 provider_chain 中“实际请求”数量 - 1 计算;<= 0 视为不筛选) */ minRetryCount?: number; + replayFilter?: UsageLogReplayFilter; } interface UsageLogSlimBatchFilters extends UsageLogSlimFilters { @@ -514,6 +536,8 @@ interface UsageLogSlimRow { cacheCreation5mInputTokens: number | null; cacheCreation1hInputTokens: number | null; cacheTtlApplied: string | null; + isReplay: boolean; + replaySourceRequestId: number | null; anthropicEffort?: string | null; } @@ -543,6 +567,7 @@ export async function findUsageLogsForKeySlim( filters.actualResponseModelMismatch ? "1" : "0", filters.endpoint ?? "", filters.minRetryCount ?? "", + filters.replayFilter ?? "all", ].join("\u0001"); const cachedTotal = usageLogSlimTotalCache.get(totalCacheKey); @@ -647,6 +672,8 @@ function mapUsageLogSlimRow(row: { cacheCreation5mInputTokens: number | null; cacheCreation1hInputTokens: number | null; cacheTtlApplied: string | null; + isReplay: boolean; + replaySourceRequestId: number | null; specialSettings?: SpecialSetting[] | null; }): UsageLogSlimRow { const { specialSettings, ...rest } = row; @@ -725,7 +752,7 @@ function buildKeyLedgerConditions( } const conditions = [ - LEDGER_BILLING_CONDITION, + ...buildLedgerUsageLogConditions(filters.replayFilter), eq(usageLedger.key, keyString), sql`not exists ( select 1 @@ -821,6 +848,8 @@ async function selectKeyScopedMessageSlimRows( cacheCreation5mInputTokens: messageRequest.cacheCreation5mInputTokens, cacheCreation1hInputTokens: messageRequest.cacheCreation1hInputTokens, cacheTtlApplied: messageRequest.cacheTtlApplied, + isReplay: messageRequest.isReplay, + replaySourceRequestId: messageRequest.replaySourceRequestId, specialSettings: messageRequest.specialSettings, }) .from(messageRequest) @@ -865,6 +894,8 @@ async function selectKeyScopedLedgerSlimRows( cacheCreation5mInputTokens: usageLedger.cacheCreation5mInputTokens, cacheCreation1hInputTokens: usageLedger.cacheCreation1hInputTokens, cacheTtlApplied: usageLedger.cacheTtlApplied, + isReplay: usageLedger.isReplay, + replaySourceRequestId: usageLedger.replaySourceRequestId, }) .from(usageLedger) .where(and(...ledgerConditions)) @@ -890,6 +921,8 @@ async function selectKeyScopedLedgerSlimRows( cacheCreation5mInputTokens: row.cacheCreation5mInputTokens, cacheCreation1hInputTokens: row.cacheCreation1hInputTokens, cacheTtlApplied: row.cacheTtlApplied, + isReplay: row.isReplay, + replaySourceRequestId: row.replaySourceRequestId, anthropicEffort: null, })); } @@ -1007,6 +1040,8 @@ function mapUsageLogRowFromMessageResult(row: { routingTrace: RoutingTraceV1 | null; blockedBy: string | null; blockedReason: string | null; + isReplay: boolean; + replaySourceRequestId: number | null; userAgent: string | null; clientIp: string | null; messagesCount: number | null; @@ -1076,6 +1111,8 @@ function mapUsageLogRowFromLedgerResult(row: { clientIp: string | null; context1mApplied: boolean | null; swapCacheTtlApplied: boolean | null; + isReplay: boolean; + replaySourceRequestId: number | null; }) { const totalRowTokens = (row.inputTokens ?? 0) + @@ -1116,6 +1153,8 @@ function mapUsageLogRowFromLedgerResult(row: { routingTrace: null, blockedBy: null, blockedReason: null, + isReplay: row.isReplay, + replaySourceRequestId: row.replaySourceRequestId, userAgent: null, clientIp: row.clientIp ?? null, messagesCount: null, @@ -1174,6 +1213,8 @@ export async function findReadonlyUsageLogsBatchForKey( routingTrace: messageRequest.routingTrace, blockedBy: messageRequest.blockedBy, blockedReason: messageRequest.blockedReason, + isReplay: messageRequest.isReplay, + replaySourceRequestId: messageRequest.replaySourceRequestId, userAgent: messageRequest.userAgent, clientIp: messageRequest.clientIp, messagesCount: messageRequest.messagesCount, @@ -1221,6 +1262,8 @@ export async function findReadonlyUsageLogsBatchForKey( clientIp: usageLedger.clientIp, context1mApplied: usageLedger.context1mApplied, swapCacheTtlApplied: usageLedger.swapCacheTtlApplied, + isReplay: usageLedger.isReplay, + replaySourceRequestId: usageLedger.replaySourceRequestId, }) .from(usageLedger) .leftJoin(users, eq(usageLedger.userId, users.id)) @@ -1428,6 +1471,8 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis routingTrace: messageRequest.routingTrace, blockedBy: messageRequest.blockedBy, // 拦截类型 blockedReason: messageRequest.blockedReason, // 拦截原因 + isReplay: messageRequest.isReplay, + replaySourceRequestId: messageRequest.replaySourceRequestId, userAgent: messageRequest.userAgent, // User-Agent clientIp: messageRequest.clientIp, // 客户端 IP messagesCount: messageRequest.messagesCount, // Messages 数量 @@ -1701,7 +1746,7 @@ export async function findUsageLogsStats( }; } - const conditions = [LEDGER_BILLING_CONDITION]; + const conditions = buildLedgerUsageLogConditions(filters.replayFilter); if (userId !== undefined) { conditions.push(eq(usageLedger.userId, userId)); diff --git a/src/types/message.ts b/src/types/message.ts index 858d919b1..a4758468b 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -287,6 +287,13 @@ export interface MessageRequest { // Session ID(用于会话粘性和日志追踪) sessionId?: string; + // 活跃 Session 聚合 identity;保留 sessionId 作为物理请求/快照主键 + sessionIdentity?: string; + sessionIdentityKind?: "session_id" | "prefix_affinity"; + affinityScopeTag?: string | null; + affinityFingerprint?: string | null; + affinityFingerprintChain?: string[]; + // Request Sequence(Session 内请求序号) requestSequence?: number; @@ -366,6 +373,15 @@ export interface CreateMessageRequestData { // Session ID(用于会话粘性和日志追踪) session_id?: string; + // 活跃 Session 聚合 identity;保留 session_id 作为物理请求/快照主键 + session_identity?: string; + session_identity_kind?: "session_id" | "prefix_affinity"; + affinity_scope_tag?: string | null; + affinity_fingerprint?: string | null; + affinity_fingerprint_chain?: string[]; + is_replay?: boolean; + replay_source_request_id?: number | null; + // Request Sequence(Session 内请求序号,用于区分同一 Session 的不同请求) request_sequence?: number; diff --git a/src/types/session.ts b/src/types/session.ts index 4ab44633a..84a1849b0 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -38,6 +38,16 @@ export interface ActiveSessionInfo { durationMs?: number; // 总耗时 requestCount?: number; // 请求次数 concurrentCount?: number; // 并发请求数(用于实时状态计算) + sessionIdentityKind?: "session_id" | "prefix_affinity"; + sessionFingerprint?: string | null; +} + +export interface SessionIdentityMetadata { + identity: string; + kind: "session_id" | "prefix_affinity"; + scopeTag: string | null; + fingerprint: string | null; + fingerprints: string[]; } /** diff --git a/tests/api/v1/sessions/sessions.test.ts b/tests/api/v1/sessions/sessions.test.ts index 21e5330c5..dad42bee9 100644 --- a/tests/api/v1/sessions/sessions.test.ts +++ b/tests/api/v1/sessions/sessions.test.ts @@ -97,29 +97,30 @@ describe("v1 session endpoints", () => { const detail = await callV1Route({ method: "GET", - pathname: "/api/v1/sessions/s1?requestSequence=2", + pathname: "/api/v1/sessions/s1?requestSequence=2&sourceSessionId=physical-1", headers, }); expect(detail.response.status).toBe(200); - expect(getSessionDetailsMock).toHaveBeenCalledWith("s1", 2); + expect(getSessionDetailsMock).toHaveBeenCalledWith("s1", 2, "physical-1"); }); test("reads session payload subresources", async () => { const headers = { Authorization: "Bearer admin-token" }; const messages = await callV1Route({ method: "GET", - pathname: "/api/v1/sessions/s1/messages?requestSequence=2", + pathname: "/api/v1/sessions/s1/messages?requestSequence=2&sourceSessionId=physical-1", headers, }); expect(messages.response.status).toBe(200); - expect(getSessionMessagesMock).toHaveBeenCalledWith("s1", 2); + expect(getSessionMessagesMock).toHaveBeenCalledWith("s1", 2, "physical-1"); const exists = await callV1Route({ method: "GET", - pathname: "/api/v1/sessions/s1/messages/exists", + pathname: "/api/v1/sessions/s1/messages/exists?requestSequence=2&sourceSessionId=physical-1", headers, }); expect(exists.json).toEqual({ exists: true }); + expect(hasSessionMessagesMock).toHaveBeenCalledWith("s1", 2, "physical-1"); const requests = await callV1Route({ method: "GET", @@ -131,17 +132,19 @@ describe("v1 session endpoints", () => { const origin = await callV1Route({ method: "GET", - pathname: "/api/v1/sessions/s1/origin-chain", + pathname: "/api/v1/sessions/s1/origin-chain?requestSequence=2&sourceSessionId=physical-1", headers, }); expect(origin.response.status).toBe(200); + expect(getSessionOriginChainMock).toHaveBeenCalledWith("s1", 2, "physical-1"); const response = await callV1Route({ method: "GET", - pathname: "/api/v1/sessions/s1/response", + pathname: "/api/v1/sessions/s1/response?requestSequence=2&sourceSessionId=physical-1", headers, }); expect(response.json).toEqual({ response: "ok" }); + expect(getSessionResponseMock).toHaveBeenCalledWith("s1", 2, "physical-1"); }); test("terminates sessions and returns problem+json for action failures", async () => { @@ -171,6 +174,21 @@ describe("v1 session endpoints", () => { }); expect(missing.response.status).toBe(404); expect(missing.json).toMatchObject({ errorCode: "session.not_found" }); + + getSessionDetailsMock.mockResolvedValueOnce({ + ok: false, + error: "Request source does not belong to this session.", + errorCode: "SESSION_REQUEST_SOURCE_MISMATCH", + }); + const mismatchedSource = await callV1Route({ + method: "GET", + pathname: "/api/v1/sessions/s1?requestSequence=2&sourceSessionId=physical-other", + headers, + }); + expect(mismatchedSource.response.status).toBe(400); + expect(mismatchedSource.json).toMatchObject({ + errorCode: "SESSION_REQUEST_SOURCE_MISMATCH", + }); }); test("documents session REST paths", async () => { diff --git a/tests/api/v1/usage-logs/usage-logs.test.ts b/tests/api/v1/usage-logs/usage-logs.test.ts index 68638988f..76054def2 100644 --- a/tests/api/v1/usage-logs/usage-logs.test.ts +++ b/tests/api/v1/usage-logs/usage-logs.test.ts @@ -262,6 +262,47 @@ describe("v1 usage log endpoints", () => { }); }); + test("passes the Replay filter through list, stats, and export requests", async () => { + const list = await callV1Route({ + method: "GET", + pathname: "/api/v1/usage-logs?limit=15&replayFilter=replay", + headers, + }); + expect(list.response.status).toBe(200); + expect(getUsageLogsBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ limit: 15, replayFilter: "replay" }) + ); + + const stats = await callV1Route({ + method: "GET", + pathname: "/api/v1/usage-logs/stats?replayFilter=non-replay", + headers, + }); + expect(stats.response.status).toBe(200); + expect(getUsageLogsStatsMock).toHaveBeenCalledWith( + expect.objectContaining({ replayFilter: "non-replay" }) + ); + + const asyncExport = await callV1Route({ + method: "POST", + pathname: "/api/v1/usage-logs/exports", + headers: { ...headers, Prefer: "respond-async" }, + body: { replayFilter: "replay" }, + }); + expect(asyncExport.response.status).toBe(202); + expect(startUsageLogsExportMock).toHaveBeenCalledWith({ + replayFilter: "replay", + format: "csv", + }); + + const invalid = await callV1Route({ + method: "GET", + pathname: "/api/v1/usage-logs?replayFilter=invalid", + headers, + }); + expect(invalid.response.status).toBe(400); + }); + test("keeps global usage-log metadata admin-only", async () => { validateAuthTokenMock.mockResolvedValue(userSession); diff --git a/tests/integration/ledger-consistency.test.ts b/tests/integration/ledger-consistency.test.ts index e301931ab..2e39c94c1 100644 --- a/tests/integration/ledger-consistency.test.ts +++ b/tests/integration/ledger-consistency.test.ts @@ -143,4 +143,25 @@ describe.skipIf(!process.env.DATABASE_URL)("Ledger data consistency", () => { const row = requireSingleRow<{ checked_count: number; mismatch_count: number }>(result); expect(row.mismatch_count).toBe(0); }); + + it("Session identity and Replay provenance match message_request", async () => { + const result = await db.execute(sql` + SELECT COUNT(*) FILTER ( + WHERE ul.session_identity IS DISTINCT FROM mr.session_identity + OR ul.session_identity_kind IS DISTINCT FROM mr.session_identity_kind + OR ul.affinity_scope_tag IS DISTINCT FROM mr.affinity_scope_tag + OR ul.affinity_fingerprint IS DISTINCT FROM mr.affinity_fingerprint + OR ul.affinity_fingerprint_chain IS DISTINCT FROM mr.affinity_fingerprint_chain + OR ul.is_replay IS DISTINCT FROM mr.is_replay + OR ul.replay_source_request_id IS DISTINCT FROM mr.replay_source_request_id + OR (ul.is_replay AND ul.cost_usd IS DISTINCT FROM 0) + )::integer AS mismatch_count + FROM message_request mr + JOIN usage_ledger ul ON ul.request_id = mr.id + WHERE mr.blocked_by IS DISTINCT FROM 'warmup' + `); + + const row = requireSingleRow<{ mismatch_count: number }>(result); + expect(row.mismatch_count).toBe(0); + }); }); diff --git a/tests/integration/usage-ledger.test.ts b/tests/integration/usage-ledger.test.ts index 0b5d21abf..0209469b4 100644 --- a/tests/integration/usage-ledger.test.ts +++ b/tests/integration/usage-ledger.test.ts @@ -58,6 +58,14 @@ type InsertRequestInput = { inputTokens?: number | null; outputTokens?: number | null; providerChain?: Array<{ id: number; name: string }> | null; + sessionId?: string | null; + sessionIdentity?: string | null; + sessionIdentityKind?: "session_id" | "prefix_affinity" | null; + affinityScopeTag?: string | null; + affinityFingerprint?: string | null; + affinityFingerprintChain?: string[] | null; + isReplay?: boolean; + replaySourceRequestId?: number | null; createdAt?: Date; }; @@ -80,6 +88,14 @@ async function insertMessageRequestRow(input: InsertRequestInput) { inputTokens: input.inputTokens, outputTokens: input.outputTokens, providerChain: input.providerChain, + sessionId: input.sessionId, + sessionIdentity: input.sessionIdentity, + sessionIdentityKind: input.sessionIdentityKind, + affinityScopeTag: input.affinityScopeTag, + affinityFingerprint: input.affinityFingerprint, + affinityFingerprintChain: input.affinityFingerprintChain, + isReplay: input.isReplay, + replaySourceRequestId: input.replaySourceRequestId, createdAt: input.createdAt, }) .returning({ id: messageRequest.id }); @@ -104,6 +120,14 @@ async function selectLedgerRowByRequestId(requestId: number) { originalModel: usageLedger.originalModel, endpoint: usageLedger.endpoint, apiType: usageLedger.apiType, + sessionId: usageLedger.sessionId, + sessionIdentity: usageLedger.sessionIdentity, + sessionIdentityKind: usageLedger.sessionIdentityKind, + affinityScopeTag: usageLedger.affinityScopeTag, + affinityFingerprint: usageLedger.affinityFingerprint, + affinityFingerprintChain: usageLedger.affinityFingerprintChain, + isReplay: usageLedger.isReplay, + replaySourceRequestId: usageLedger.replaySourceRequestId, statusCode: usageLedger.statusCode, isSuccess: usageLedger.isSuccess, successRateOutcome: usageLedger.successRateOutcome, @@ -223,6 +247,36 @@ run("usage ledger integration", () => { expect(rows[0]?.statusCode).toBe(201); }); + test("projects prefix Session identity and zero-cost Replay provenance", async () => { + const requestId = await insertMessageRequestRow({ + key: nextKey("trigger-replay-identity"), + userId: nextUserId(), + providerId: nextProviderId(), + sessionId: "physical-session", + sessionIdentity: "pfx:scope-a:fingerprint-a", + sessionIdentityKind: "prefix_affinity", + affinityScopeTag: "scope-a", + affinityFingerprint: "fingerprint-a", + affinityFingerprintChain: ["fingerprint-root", "fingerprint-a"], + isReplay: true, + replaySourceRequestId: 7, + costUsd: "9.990000000000000", + }); + + const ledgerRow = await selectLedgerRowByRequestId(requestId); + expect(ledgerRow).toMatchObject({ + sessionId: "physical-session", + sessionIdentity: "pfx:scope-a:fingerprint-a", + sessionIdentityKind: "prefix_affinity", + affinityScopeTag: "scope-a", + affinityFingerprint: "fingerprint-a", + affinityFingerprintChain: ["fingerprint-root", "fingerprint-a"], + isReplay: true, + replaySourceRequestId: 7, + }); + expect(toNumber(ledgerRow?.costUsd)).toBe(0); + }); + test("does not insert usage_ledger row for warmup requests", async () => { const requestId = await insertMessageRequestRow({ key: nextKey("trigger-warmup"), @@ -383,6 +437,54 @@ run("usage ledger integration", () => { const ledgerRow = await selectLedgerRowByRequestId(requestId); expect(ledgerRow?.successRateOutcome).toBe("excluded"); }); + + test("backfill repairs stale Session identity and Replay provenance", { + timeout: 60_000, + }, async () => { + const requestId = await insertMessageRequestRow({ + key: nextKey("backfill-replay-identity"), + userId: nextUserId(), + providerId: nextProviderId(), + sessionId: "physical-backfill", + sessionIdentity: "pfx:scope-b:fingerprint-b", + sessionIdentityKind: "prefix_affinity", + affinityScopeTag: "scope-b", + affinityFingerprint: "fingerprint-b", + affinityFingerprintChain: ["fingerprint-b"], + isReplay: true, + replaySourceRequestId: 8, + costUsd: "8.880000000000000", + }); + + await db + .update(usageLedger) + .set({ + sessionIdentity: null, + sessionIdentityKind: null, + affinityScopeTag: null, + affinityFingerprint: null, + affinityFingerprintChain: null, + isReplay: false, + replaySourceRequestId: null, + costUsd: "8.880000000000000", + }) + .where(eq(usageLedger.requestId, requestId)); + + const summary = await backfillUsageLedger(); + expect(summary.alreadyExisted).toBeGreaterThanOrEqual(1); + + const ledgerRow = await selectLedgerRowByRequestId(requestId); + expect(ledgerRow).toMatchObject({ + sessionIdentity: "pfx:scope-b:fingerprint-b", + sessionIdentityKind: "prefix_affinity", + affinityScopeTag: "scope-b", + affinityFingerprint: "fingerprint-b", + affinityFingerprintChain: ["fingerprint-b"], + isReplay: true, + replaySourceRequestId: 8, + }); + expect(toNumber(ledgerRow?.costUsd)).toBe(0); + }); }); describe("read path consistency", () => { diff --git a/tests/unit/actions/active-sessions-detail-snapshots.test.ts b/tests/unit/actions/active-sessions-detail-snapshots.test.ts index 3075202ed..19b18a29d 100644 --- a/tests/unit/actions/active-sessions-detail-snapshots.test.ts +++ b/tests/unit/actions/active-sessions-detail-snapshots.test.ts @@ -20,6 +20,9 @@ const getSessionRequestPhaseSnapshotMock = vi.fn(); const getSessionResponsePhaseSnapshotMock = vi.fn(); const aggregateSessionStatsMock = vi.fn(); +const resolveSessionIdentityMock = vi.fn(); +const isSessionSourceForIdentityMock = vi.fn(); +const findSessionRequestLocatorMock = vi.fn(); const findAdjacentRequestSequencesMock = vi.fn(); const findMessageRequestAuditBySessionIdAndSequenceMock = vi.fn(); @@ -66,6 +69,9 @@ vi.mock("@/lib/session-manager", () => ({ vi.mock("@/repository/message", () => ({ aggregateSessionStats: aggregateSessionStatsMock, + resolveSessionIdentity: resolveSessionIdentityMock, + isSessionSourceForIdentity: isSessionSourceForIdentityMock, + findSessionRequestLocator: findSessionRequestLocatorMock, findAdjacentRequestSequences: findAdjacentRequestSequencesMock, findMessageRequestAuditBySessionIdAndSequence: findMessageRequestAuditBySessionIdAndSequenceMock, })); @@ -76,6 +82,7 @@ describe("getSessionDetails - additive detail snapshots contract", () => { getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); getSessionDetailsCacheMock.mockReturnValue(null); + findSessionRequestLocatorMock.mockReset(); aggregateSessionStatsMock.mockResolvedValue({ sessionId: "sess_x", @@ -98,6 +105,20 @@ describe("getSessionDetails - additive detail snapshots contract", () => { apiType: "chat", cacheTtlApplied: null, }); + resolveSessionIdentityMock.mockResolvedValue(null); + isSessionSourceForIdentityMock.mockResolvedValue(true); + findSessionRequestLocatorMock.mockImplementation( + async ( + identity: string, + selector: { sourceSessionId?: string; requestSequence?: number } = {} + ) => ({ + sourceSessionId: selector.sourceSessionId ?? identity, + requestSequence: selector.requestSequence ?? 1, + identityKind: identity.startsWith("pfx:") ? "prefix_affinity" : "session_id", + scopeTag: identity.startsWith("pfx:") ? "scope" : null, + fingerprint: identity.startsWith("pfx:") ? "fingerprint" : null, + }) + ); findAdjacentRequestSequencesMock.mockResolvedValue({ prevSequence: null, nextSequence: null }); findMessageRequestAuditBySessionIdAndSequenceMock.mockResolvedValue(null); @@ -176,6 +197,97 @@ describe("getSessionDetails - additive detail snapshots contract", () => { }); }); + test("uses an authorized physical source when a prefix identity spans multiple Sessions", async () => { + aggregateSessionStatsMock.mockResolvedValue({ + sessionId: "pfx:scope:fingerprint", + requestCount: 2, + totalCostUsd: "0", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalDurationMs: 0, + firstRequestAt: new Date(), + lastRequestAt: new Date(), + providers: [], + models: [], + userName: "u", + userId: 1, + keyName: "k", + keyId: 1, + userAgent: null, + apiType: "chat", + cacheTtlApplied: null, + }); + resolveSessionIdentityMock.mockResolvedValue({ + sourceSessionId: "physical-latest", + scopeTag: "scope", + fingerprints: ["fingerprint"], + }); + + const { getSessionDetails } = await import("@/actions/active-sessions"); + const result = await getSessionDetails("pfx:scope:fingerprint", 1, "physical-selected"); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.currentSourceSessionId).toBe("physical-selected"); + expect(result.data.currentSequence).toBe(1); + expect(findSessionRequestLocatorMock).toHaveBeenCalledWith("pfx:scope:fingerprint", { + requestSequence: 1, + sourceSessionId: "physical-selected", + }); + expect(getSessionRequestBodyMock).toHaveBeenCalledWith("physical-selected", 1); + expect(findAdjacentRequestSequencesMock).toHaveBeenCalledWith("physical-selected", 1); + expect(findMessageRequestAuditBySessionIdAndSequenceMock).toHaveBeenCalledWith( + "physical-selected", + 1 + ); + }); + + test("rejects a physical source and sequence that do not belong to the prefix identity", async () => { + aggregateSessionStatsMock.mockResolvedValue({ + sessionId: "pfx:scope:fingerprint", + userId: 1, + }); + findSessionRequestLocatorMock + .mockResolvedValueOnce({ + sourceSessionId: "physical-latest", + requestSequence: 10, + identityKind: "prefix_affinity", + scopeTag: "scope", + fingerprint: "fingerprint", + }) + .mockResolvedValueOnce(null); + + const { getSessionDetails } = await import("@/actions/active-sessions"); + const result = await getSessionDetails("pfx:scope:fingerprint", 9, "physical-selected"); + + expect(result).toEqual({ + ok: false, + error: "Request source does not belong to this session.", + errorCode: "SESSION_REQUEST_SOURCE_MISMATCH", + }); + expect(getSessionRequestBodyMock).not.toHaveBeenCalled(); + }); + + test("requires source Session together with sequence for a prefix identity", async () => { + aggregateSessionStatsMock.mockResolvedValue({ + sessionId: "pfx:scope:fingerprint", + userId: 1, + }); + + const { getSessionDetails } = await import("@/actions/active-sessions"); + const result = await getSessionDetails("pfx:scope:fingerprint", 1); + + expect(result).toEqual({ + ok: false, + error: "Prefix Session requests must specify both the physical source and request sequence.", + errorCode: "SESSION_REQUEST_SELECTOR_INCOMPLETE", + }); + expect(findSessionRequestLocatorMock).toHaveBeenCalledTimes(1); + expect(findSessionRequestLocatorMock).toHaveBeenCalledWith("pfx:scope:fingerprint"); + }); + test("builds before-after snapshots from new snapshot getters", async () => { getSessionRequestPhaseSnapshotMock .mockResolvedValueOnce({ @@ -312,6 +424,13 @@ describe("getSessionDetails - additive detail snapshots contract", () => { test("falls back to the latest request sequence when requestSequence is omitted", async () => { getSessionRequestCountMock.mockResolvedValue(3); + findSessionRequestLocatorMock.mockResolvedValueOnce({ + sourceSessionId: "sess_x", + requestSequence: 3, + identityKind: "session_id", + scopeTag: null, + fingerprint: null, + }); findAdjacentRequestSequencesMock.mockResolvedValue({ prevSequence: 2, nextSequence: null }); getSessionRequestPhaseSnapshotMock.mockResolvedValueOnce(null).mockResolvedValueOnce({ body: JSON.stringify({ model: "gpt-5.5", messages: [] }), diff --git a/tests/unit/actions/active-sessions-requests.test.ts b/tests/unit/actions/active-sessions-requests.test.ts new file mode 100644 index 000000000..b63ecef2d --- /dev/null +++ b/tests/unit/actions/active-sessions-requests.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const getSessionMock = vi.fn(); +const aggregateSessionStatsMock = vi.fn(); +const findRequestsBySessionIdMock = vi.fn(); +const findRequestsBySessionIdentityMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ getSession: getSessionMock })); +vi.mock("@/repository/message", () => ({ + aggregateSessionStats: aggregateSessionStatsMock, + findRequestsBySessionId: findRequestsBySessionIdMock, + findRequestsBySessionIdentity: findRequestsBySessionIdentityMock, +})); +vi.mock("@/lib/cache/session-cache", () => ({ + getActiveSessionsCache: vi.fn(() => null), + getSessionDetailsCache: vi.fn(() => null), + setActiveSessionsCache: vi.fn(), + setSessionDetailsCache: vi.fn(), + clearActiveSessionsCache: vi.fn(), + clearSessionDetailsCache: vi.fn(), + clearAllSessionsQueryCache: vi.fn(), +})); +vi.mock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe("getSessionRequests public identity contract", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + aggregateSessionStatsMock.mockResolvedValue({ + sessionId: "public-session-identity", + userId: 1, + }); + findRequestsBySessionIdMock.mockResolvedValue({ requests: [], total: 0 }); + findRequestsBySessionIdentityMock.mockResolvedValue({ requests: [], total: 0 }); + }); + + test("uses the public Session identity query for ordinary identities", async () => { + const { getSessionRequests } = await import("@/actions/active-sessions"); + + await expect(getSessionRequests("public-session-identity", 2, 5, "desc")).resolves.toEqual({ + ok: true, + data: { requests: [], total: 0, hasMore: false }, + }); + expect(findRequestsBySessionIdentityMock).toHaveBeenCalledWith("public-session-identity", { + limit: 5, + offset: 5, + order: "desc", + }); + expect(findRequestsBySessionIdMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/actions/active-sessions-special-settings.test.ts b/tests/unit/actions/active-sessions-special-settings.test.ts index 036b9ceb0..3e7e01538 100644 --- a/tests/unit/actions/active-sessions-special-settings.test.ts +++ b/tests/unit/actions/active-sessions-special-settings.test.ts @@ -19,6 +19,7 @@ const getSessionRequestPhaseSnapshotMock = vi.fn(); const getSessionResponsePhaseSnapshotMock = vi.fn(); const aggregateSessionStatsMock = vi.fn(); +const findSessionRequestLocatorMock = vi.fn(); const findAdjacentRequestSequencesMock = vi.fn(); const findMessageRequestAuditBySessionIdAndSequenceMock = vi.fn(); @@ -65,6 +66,7 @@ vi.mock("@/lib/session-manager", () => ({ vi.mock("@/repository/message", () => ({ aggregateSessionStats: aggregateSessionStatsMock, + findSessionRequestLocator: findSessionRequestLocatorMock, findAdjacentRequestSequences: findAdjacentRequestSequencesMock, findMessageRequestAuditBySessionIdAndSequence: findMessageRequestAuditBySessionIdAndSequenceMock, })); @@ -97,6 +99,13 @@ describe("getSessionDetails - unified specialSettings", () => { apiType: "chat", cacheTtlApplied: null, }); + findSessionRequestLocatorMock.mockResolvedValue({ + sourceSessionId: "sess_x", + requestSequence: 1, + identityKind: "session_id", + scopeTag: null, + fingerprint: null, + }); findAdjacentRequestSequencesMock.mockResolvedValue({ prevSequence: null, nextSequence: null }); diff --git a/tests/unit/actions/active-sessions-termination.test.ts b/tests/unit/actions/active-sessions-termination.test.ts new file mode 100644 index 000000000..c72662060 --- /dev/null +++ b/tests/unit/actions/active-sessions-termination.test.ts @@ -0,0 +1,207 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const getSessionMock = vi.fn(); +const aggregateSessionStatsMock = vi.fn(); +const aggregateMultipleSessionStatsMock = vi.fn(); +const resolveSessionIdentityMock = vi.fn(); +const terminateSessionMock = vi.fn(); +const terminateSessionsBatchMock = vi.fn(); +const terminateObservedSessionMock = vi.fn(); +const invalidateMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ getSession: getSessionMock })); +vi.mock("@/repository/message", () => ({ + aggregateSessionStats: aggregateSessionStatsMock, + aggregateMultipleSessionStats: aggregateMultipleSessionStatsMock, + resolveSessionIdentity: resolveSessionIdentityMock, +})); +vi.mock("@/lib/session-manager", () => ({ + SessionManager: { + terminateSession: terminateSessionMock, + terminateSessionsBatch: terminateSessionsBatchMock, + }, +})); +vi.mock("@/lib/session-tracker", () => ({ + SessionTracker: { + terminateObservedSession: terminateObservedSessionMock, + }, +})); +vi.mock("@/app/v1/_lib/proxy/affinity/affinity-store", () => ({ + getAffinityStore: () => ({ invalidate: invalidateMock }), +})); +vi.mock("@/lib/cache/session-cache", () => ({ + getActiveSessionsCache: vi.fn(() => null), + getSessionDetailsCache: vi.fn(() => null), + setActiveSessionsCache: vi.fn(), + setSessionDetailsCache: vi.fn(), + clearActiveSessionsCache: vi.fn(), + clearSessionDetailsCache: vi.fn(), + clearAllSessionsQueryCache: vi.fn(), +})); +vi.mock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe("active Session termination identity contract", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + aggregateSessionStatsMock.mockResolvedValue({ + sessionId: "pfx:scope:tip", + userId: 1, + }); + aggregateMultipleSessionStatsMock.mockResolvedValue([]); + resolveSessionIdentityMock.mockResolvedValue({ + sourceSessionId: "physical-session", + identityKind: "prefix_affinity", + scopeTag: "scope", + fingerprint: "tip", + fingerprints: ["tip", "parent", "root"], + }); + invalidateMock.mockResolvedValue(true); + terminateSessionMock.mockResolvedValue(true); + terminateSessionsBatchMock.mockResolvedValue(0); + terminateObservedSessionMock.mockResolvedValue(true); + }); + + test("terminating a prefix identity clears affinity and observed state without treating it as a physical Session", async () => { + const { terminateActiveSession } = await import("@/actions/active-sessions"); + + await expect(terminateActiveSession("pfx:scope:tip")).resolves.toEqual({ + ok: true, + data: undefined, + }); + expect(invalidateMock).toHaveBeenCalledWith("scope", ["tip", "parent", "root"]); + expect(terminateObservedSessionMock).toHaveBeenCalledWith("pfx:scope:tip"); + expect(terminateSessionMock).not.toHaveBeenCalled(); + }); + + test("prefix termination fails when affinity invalidation fails", async () => { + invalidateMock.mockResolvedValue(false); + terminateObservedSessionMock.mockResolvedValue(true); + + const { terminateActiveSession } = await import("@/actions/active-sessions"); + const result = await terminateActiveSession("pfx:scope:tip"); + + expect(result).toEqual({ + ok: false, + error: "终止 Session 失败(Redis 不可用或 Session 已过期)", + }); + expect(terminateObservedSessionMock).toHaveBeenCalledWith("pfx:scope:tip"); + }); + + test("prefix termination succeeds when observed state has already expired", async () => { + invalidateMock.mockResolvedValue(true); + terminateObservedSessionMock.mockResolvedValue(false); + + const { terminateActiveSession } = await import("@/actions/active-sessions"); + + await expect(terminateActiveSession("pfx:scope:tip")).resolves.toEqual({ + ok: true, + data: undefined, + }); + }); + + test("treats a client-controlled pfx-prefixed physical Session as a physical Session", async () => { + resolveSessionIdentityMock.mockResolvedValue({ + sourceSessionId: "pfx:foreign-scope:foreign-fingerprint", + identityKind: "session_id", + scopeTag: null, + fingerprint: null, + fingerprints: [], + }); + + const { terminateActiveSession } = await import("@/actions/active-sessions"); + const result = await terminateActiveSession("pfx:foreign-scope:foreign-fingerprint"); + + expect(result).toEqual({ ok: true, data: undefined }); + expect(terminateSessionMock).toHaveBeenCalledWith("pfx:foreign-scope:foreign-fingerprint"); + expect(invalidateMock).not.toHaveBeenCalled(); + }); + + test("batch termination applies prefix and physical Session semantics independently", async () => { + aggregateMultipleSessionStatsMock.mockResolvedValue([ + { sessionId: "pfx:scope:tip", userId: 1 }, + { sessionId: "physical-session", userId: 1 }, + ]); + resolveSessionIdentityMock.mockImplementation(async (identity: string) => + identity.startsWith("pfx:") + ? { + sourceSessionId: "physical-source", + identityKind: "prefix_affinity", + scopeTag: "scope", + fingerprint: "tip", + fingerprints: ["tip", "parent"], + } + : { + sourceSessionId: identity, + identityKind: "session_id", + scopeTag: null, + fingerprint: null, + fingerprints: [], + } + ); + + const { terminateActiveSessionsBatch } = await import("@/actions/active-sessions"); + const result = await terminateActiveSessionsBatch([ + "pfx:scope:tip", + "physical-session", + "pfx:scope:tip", + ]); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data).toMatchObject({ + successCount: 2, + failedCount: 0, + requestedCount: 2, + processedCount: 2, + }); + expect(invalidateMock).toHaveBeenCalledWith("scope", ["tip", "parent"]); + expect(terminateSessionMock).toHaveBeenCalledTimes(1); + expect(terminateSessionMock).toHaveBeenCalledWith("physical-session"); + expect(terminateSessionsBatchMock).not.toHaveBeenCalled(); + expect(terminateObservedSessionMock).toHaveBeenCalledWith("pfx:scope:tip"); + expect(terminateObservedSessionMock).toHaveBeenCalledWith("physical-session"); + }); + + test("batch counts affinity invalidation failures instead of observed cleanup results", async () => { + aggregateMultipleSessionStatsMock.mockResolvedValue([ + { sessionId: "pfx:scope:tip", userId: 1 }, + { sessionId: "pfx:scope:parent", userId: 1 }, + ]); + resolveSessionIdentityMock.mockImplementation(async (identity: string) => ({ + sourceSessionId: "physical-source", + identityKind: "prefix_affinity", + scopeTag: "scope", + fingerprint: identity.endsWith("tip") ? "tip" : "parent", + fingerprints: [], + })); + invalidateMock.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + terminateObservedSessionMock.mockResolvedValue(true); + + const { terminateActiveSessionsBatch } = await import("@/actions/active-sessions"); + const result = await terminateActiveSessionsBatch(["pfx:scope:tip", "pfx:scope:parent"]); + + expect(result).toEqual({ + ok: true, + data: { + successCount: 1, + failedCount: 1, + allowedFailedCount: 1, + unauthorizedCount: 0, + missingCount: 0, + unauthorizedSessionIds: [], + missingSessionIds: [], + requestedCount: 2, + processedCount: 2, + }, + }); + }); +}); diff --git a/tests/unit/actions/session-origin-chain-integration.test.ts b/tests/unit/actions/session-origin-chain-integration.test.ts index a692405f6..fdb700ad8 100644 --- a/tests/unit/actions/session-origin-chain-integration.test.ts +++ b/tests/unit/actions/session-origin-chain-integration.test.ts @@ -1,12 +1,7 @@ import { describe, expect, test, vi } from "vitest"; import type { ProviderChainItem } from "../../../src/types/message"; -type SessionRequestRow = { - requestSequence: number; - providerChain: ProviderChainItem[]; -}; - -describe("getSessionOriginChain integration", () => { +describe("getSessionOriginChain", () => { test("returns the first request origin chain for a multi-request session", async () => { vi.resetModules(); @@ -19,37 +14,18 @@ describe("getSessionOriginChain integration", () => { }, ]; - const secondRequestChain: ProviderChainItem[] = [ - { - id: 101, - name: "provider-a", - reason: "session_reuse", - selectionMethod: "session_reuse", - }, - ]; - - const sessionRequests: SessionRequestRow[] = [ - { requestSequence: 1, providerChain: firstRequestChain }, - { requestSequence: 2, providerChain: secondRequestChain }, - ]; - - const limitMock = vi.fn((limit: number) => - Promise.resolve( - [...sessionRequests] - .sort((a, b) => a.requestSequence - b.requestSequence) - .slice(0, limit) - .map((row) => ({ providerChain: row.providerChain })) - ) - ); - const orderByMock = vi.fn(() => ({ limit: limitMock })); - const whereMock = vi.fn(() => ({ orderBy: orderByMock })); - const fromMock = vi.fn(() => ({ where: whereMock })); - const selectMock = vi.fn(() => ({ from: fromMock })); - - vi.doMock("@/drizzle/db", () => ({ - db: { - select: selectMock, - }, + const aggregateSessionStatsMock = vi.fn().mockResolvedValue({ userId: 1 }); + const findSessionRequestLocatorMock = vi.fn().mockResolvedValue({ + identityKind: "direct", + sourceSessionId: "test-session", + requestSequence: 1, + }); + const findSessionOriginChainMock = vi.fn().mockResolvedValue(firstRequestChain); + + vi.doMock("@/repository/message", () => ({ + aggregateSessionStats: aggregateSessionStatsMock, + findSessionOriginChain: findSessionOriginChainMock, + findSessionRequestLocator: findSessionRequestLocatorMock, })); vi.doMock("@/lib/auth", () => ({ @@ -80,8 +56,7 @@ describe("getSessionOriginChain integration", () => { } expect(result.data[0]?.reason).toBe("initial_selection"); - expect(result.data).not.toEqual(secondRequestChain); - expect(selectMock).toHaveBeenCalledTimes(1); - expect(limitMock).toHaveBeenCalledWith(1); + expect(findSessionRequestLocatorMock).toHaveBeenCalledWith("test-session"); + expect(findSessionOriginChainMock).toHaveBeenCalledWith("test-session"); }); }); diff --git a/tests/unit/actions/session-origin-chain.test.ts b/tests/unit/actions/session-origin-chain.test.ts index 3344e1bde..6b4784e51 100644 --- a/tests/unit/actions/session-origin-chain.test.ts +++ b/tests/unit/actions/session-origin-chain.test.ts @@ -3,6 +3,8 @@ import type { ProviderChainItem } from "@/types/message"; const getSessionMock = vi.fn(); const findSessionOriginChainMock = vi.fn(); +const findSessionRequestLocatorMock = vi.fn(); +const aggregateSessionStatsMock = vi.fn(); const findKeyListMock = vi.fn(); const dbSelectMock = vi.fn(); @@ -16,6 +18,8 @@ vi.mock("@/lib/auth", () => ({ vi.mock("@/repository/message", () => ({ findSessionOriginChain: findSessionOriginChainMock, + findSessionRequestLocator: findSessionRequestLocatorMock, + aggregateSessionStats: aggregateSessionStatsMock, })); vi.mock("@/repository/key", () => ({ @@ -38,6 +42,20 @@ describe("getSessionOriginChain", () => { dbLimitMock.mockResolvedValue([{ id: 1 }]); findKeyListMock.mockResolvedValue([{ key: "user-key-1" }]); + aggregateSessionStatsMock.mockResolvedValue({ userId: 2 }); + findSessionRequestLocatorMock.mockReset(); + findSessionRequestLocatorMock.mockImplementation( + async ( + identity: string, + selector: { sourceSessionId?: string; requestSequence?: number } = {} + ) => ({ + sourceSessionId: selector.sourceSessionId ?? identity, + requestSequence: selector.requestSequence ?? 1, + identityKind: identity.startsWith("pfx:") ? "prefix_affinity" : "session_id", + scopeTag: identity.startsWith("pfx:") ? "scope" : null, + fingerprint: identity.startsWith("pfx:") ? "fingerprint" : null, + }) + ); }); test("admin happy path: returns provider chain", async () => { @@ -53,15 +71,15 @@ describe("getSessionOriginChain", () => { findSessionOriginChainMock.mockResolvedValue(chain); const { getSessionOriginChain } = await import("@/actions/session-origin-chain"); - const result = await getSessionOriginChain("sess-admin"); + const result = await getSessionOriginChain("pfx:scope:fingerprint", 2, "physical-selected"); expect(result).toEqual({ ok: true, data: chain }); - expect(findSessionOriginChainMock).toHaveBeenCalledWith("sess-admin"); + expect(findSessionOriginChainMock).toHaveBeenCalledWith("physical-selected"); expect(findKeyListMock).not.toHaveBeenCalled(); expect(dbSelectMock).not.toHaveBeenCalled(); }); - test("non-admin happy path: returns provider chain after ownership check", async () => { + test("non-admin happy path: authorizes the aggregate identity before reading its physical source", async () => { getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } }); const chain: ProviderChainItem[] = [ @@ -74,12 +92,11 @@ describe("getSessionOriginChain", () => { findSessionOriginChainMock.mockResolvedValue(chain); const { getSessionOriginChain } = await import("@/actions/session-origin-chain"); - const result = await getSessionOriginChain("sess-user"); + const result = await getSessionOriginChain("pfx:scope:fingerprint", 2, "physical-selected"); expect(result).toEqual({ ok: true, data: chain }); - expect(findKeyListMock).toHaveBeenCalledWith(2); - expect(dbSelectMock).toHaveBeenCalledTimes(1); - expect(findSessionOriginChainMock).toHaveBeenCalledWith("sess-user"); + expect(aggregateSessionStatsMock).toHaveBeenCalledWith("pfx:scope:fingerprint"); + expect(findSessionOriginChainMock).toHaveBeenCalledWith("physical-selected"); }); test("unauthenticated: returns not logged in", async () => { @@ -96,8 +113,7 @@ describe("getSessionOriginChain", () => { test("non-admin without access: returns unauthorized error", async () => { getSessionMock.mockResolvedValue({ user: { id: 3, role: "user" } }); - findKeyListMock.mockResolvedValue([{ key: "user-key-3" }]); - dbLimitMock.mockResolvedValue([]); + aggregateSessionStatsMock.mockResolvedValue({ userId: 4 }); const { getSessionOriginChain } = await import("@/actions/session-origin-chain"); const result = await getSessionOriginChain("sess-other-user"); diff --git a/tests/unit/actions/session-response.test.ts b/tests/unit/actions/session-response.test.ts new file mode 100644 index 000000000..8f6cd2b01 --- /dev/null +++ b/tests/unit/actions/session-response.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const getSessionMock = vi.fn(); +const aggregateSessionStatsMock = vi.fn(); +const findSessionRequestLocatorMock = vi.fn(); +const getSessionResponseMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ getSession: getSessionMock })); +vi.mock("@/repository/message", () => ({ + aggregateSessionStats: aggregateSessionStatsMock, + findSessionRequestLocator: findSessionRequestLocatorMock, +})); +vi.mock("@/lib/session-manager", () => ({ + SessionManager: { getSessionResponse: getSessionResponseMock }, +})); +vi.mock("@/lib/logger", () => ({ + logger: { warn: vi.fn(), error: vi.fn() }, +})); + +describe("getSessionResponse request locator", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + aggregateSessionStatsMock.mockResolvedValue({ userId: 1 }); + findSessionRequestLocatorMock + .mockResolvedValueOnce({ + sourceSessionId: "physical-latest", + requestSequence: 4, + identityKind: "prefix_affinity", + scopeTag: "scope", + fingerprint: "fingerprint", + }) + .mockResolvedValueOnce({ + sourceSessionId: "physical-selected", + requestSequence: 2, + identityKind: "prefix_affinity", + scopeTag: "scope", + fingerprint: "fingerprint", + }); + getSessionResponseMock.mockResolvedValue("response-body"); + }); + + test("reads the response from the authorized physical request", async () => { + const { getSessionResponse } = await import("@/actions/session-response"); + + await expect( + getSessionResponse("pfx:scope:fingerprint", 2, "physical-selected") + ).resolves.toEqual({ ok: true, data: "response-body" }); + + expect(findSessionRequestLocatorMock).toHaveBeenNthCalledWith(2, "pfx:scope:fingerprint", { + requestSequence: 2, + sourceSessionId: "physical-selected", + }); + expect(getSessionResponseMock).toHaveBeenCalledWith("physical-selected", 2); + }); +}); diff --git a/tests/unit/api/v1/api-client-actions.test.ts b/tests/unit/api/v1/api-client-actions.test.ts index 5b111aab4..159cfdb34 100644 --- a/tests/unit/api/v1/api-client-actions.test.ts +++ b/tests/unit/api/v1/api-client-actions.test.ts @@ -38,6 +38,15 @@ const usageLogs = await vi.importActual( "@/lib/api-client/v1/actions/keys" ); +const activeSessions = await vi.importActual< + typeof import("@/lib/api-client/v1/actions/active-sessions") +>("@/lib/api-client/v1/actions/active-sessions"); +const sessionResponse = await vi.importActual< + typeof import("@/lib/api-client/v1/actions/session-response") +>("@/lib/api-client/v1/actions/session-response"); +const sessionOriginChain = await vi.importActual< + typeof import("@/lib/api-client/v1/actions/session-origin-chain") +>("@/lib/api-client/v1/actions/session-origin-chain"); describe("v1 action compatibility client", () => { beforeEach(() => { @@ -49,6 +58,43 @@ describe("v1 action compatibility client", () => { vi.unstubAllGlobals(); }); + test("preserves the physical source Session when fetching an aggregated Session request", async () => { + getMock.mockResolvedValue({ currentSequence: 1 }); + + await activeSessions.getSessionDetails("pfx:scope:fingerprint", 1, "physical-session"); + + expect(getMock).toHaveBeenCalledWith( + "/api/v1/sessions/pfx%3Ascope%3Afingerprint?requestSequence=1&sourceSessionId=physical-session" + ); + }); + + test("preserves the physical request locator for every Session payload endpoint", async () => { + getMock.mockResolvedValue({ exists: true, response: "ok" }); + + await activeSessions.getSessionMessages("pfx:scope:fingerprint", 2, "physical-session"); + await activeSessions.hasSessionMessages("pfx:scope:fingerprint", 2, "physical-session"); + await sessionResponse.getSessionResponse("pfx:scope:fingerprint", 2, "physical-session"); + await sessionOriginChain.getSessionOriginChain("pfx:scope:fingerprint", 2, "physical-session"); + + const query = "requestSequence=2&sourceSessionId=physical-session"; + expect(getMock).toHaveBeenNthCalledWith( + 1, + `/api/v1/sessions/pfx%3Ascope%3Afingerprint/messages?${query}` + ); + expect(getMock).toHaveBeenNthCalledWith( + 2, + `/api/v1/sessions/pfx%3Ascope%3Afingerprint/messages/exists?${query}` + ); + expect(getMock).toHaveBeenNthCalledWith( + 3, + `/api/v1/sessions/pfx%3Ascope%3Afingerprint/response?${query}` + ); + expect(getMock).toHaveBeenNthCalledWith( + 4, + `/api/v1/sessions/pfx%3Ascope%3Afingerprint/origin-chain?${query}` + ); + }); + test("preserves provider edit undo metadata from response headers", async () => { patchMock.mockImplementation( async ( diff --git a/tests/unit/drizzle/session-identity-indexes.test.ts b/tests/unit/drizzle/session-identity-indexes.test.ts new file mode 100644 index 000000000..481687c00 --- /dev/null +++ b/tests/unit/drizzle/session-identity-indexes.test.ts @@ -0,0 +1,36 @@ +import type { SQL } from "drizzle-orm"; +import { CasingCache } from "drizzle-orm/casing"; +import { getTableConfig } from "drizzle-orm/pg-core"; +import { describe, expect, test } from "vitest"; +import { messageRequest, usageLedger } from "@/drizzle/schema"; + +function compileSql(value: SQL): string { + return value + .toQuery({ + escapeName: (name) => `"${name}"`, + escapeParam: (num) => `$${num}`, + escapeString: (text) => `'${text}'`, + casing: new CasingCache(), + paramStartIndex: { value: 1 }, + }) + .sql.toLowerCase(); +} + +describe("Session identity query indexes", () => { + test.each([ + ["message_request", messageRequest, "idx_message_request_session_identity_created_at", "deleted_at"], + ["usage_ledger", usageLedger, "idx_usage_ledger_session_identity_created_at", "is_replay"], + ] as const)("%s indexes COALESCE(session_identity, session_id)", (_label, table, name, predicate) => { + const index = getTableConfig(table).indexes.find((entry) => entry.config.name === name); + expect(index).toBeDefined(); + + const expression = index?.config.columns[0]; + expect(expression).toBeDefined(); + expect(compileSql(expression as SQL)).toContain("coalesce"); + expect(compileSql(expression as SQL)).toContain("session_identity"); + expect(compileSql(expression as SQL)).toContain("session_id"); + + expect(index?.config.where).toBeDefined(); + expect(compileSql(index?.config.where as SQL)).toContain(predicate); + }); +}); diff --git a/tests/unit/drizzle/session-replay-migration.test.ts b/tests/unit/drizzle/session-replay-migration.test.ts new file mode 100644 index 000000000..df5e7cdf8 --- /dev/null +++ b/tests/unit/drizzle/session-replay-migration.test.ts @@ -0,0 +1,64 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, test } from "vitest"; + +const migration = readFileSync( + resolve(process.cwd(), "drizzle/0116_gigantic_zombie.sql"), + "utf-8" +); + +describe("0116 Session identity and Replay migration", () => { + test.each(["message_request", "usage_ledger"])( + "adds identity and Replay provenance to %s", + (table) => { + for (const column of [ + "session_identity", + "session_identity_kind", + "affinity_scope_tag", + "affinity_fingerprint", + "affinity_fingerprint_chain", + "is_replay", + "replay_source_request_id", + ]) { + expect(migration).toContain( + `ALTER TABLE "${table}" ADD COLUMN IF NOT EXISTS "${column}"` + ); + } + } + ); + + test("backfills existing ledger identity and Replay rows with zero cost", () => { + expect(migration).toContain("WHERE blocked_by = 'replay_serve'"); + expect(migration).toContain("SET is_replay = true"); + expect(migration).toContain("UPDATE usage_ledger AS ul"); + expect(migration).toContain("ul.request_id = mr.id"); + expect(migration).toContain("is_replay = mr.is_replay"); + expect(migration).toContain("replay_source_request_id = mr.replay_source_request_id"); + expect(migration).toContain("CASE WHEN mr.is_replay THEN 0 ELSE ul.cost_usd END"); + }); + + test("installs the updated projection function and trigger columns", () => { + expect(migration).toContain("CREATE OR REPLACE FUNCTION fn_upsert_usage_ledger()"); + expect(migration).toContain("CASE WHEN NEW.is_replay THEN 0 ELSE NEW.cost_usd END"); + expect(migration).toMatch( + /AFTER INSERT OR UPDATE OF[\s\S]*session_identity[\s\S]*is_replay[\s\S]*replay_source_request_id[\s\S]*ON message_request/ + ); + }); + + test("creates the identity and Replay-aware billing indexes idempotently", () => { + expect(migration).toContain( + 'CREATE INDEX IF NOT EXISTS "idx_message_request_session_identity_created_at"' + ); + expect(migration).toContain( + 'CREATE INDEX IF NOT EXISTS "idx_usage_ledger_session_identity_created_at"' + ); + expect(migration).toContain('"usage_ledger"."is_replay" = false'); + expect(migration).not.toMatch(/^CREATE INDEX (?!IF NOT EXISTS)/m); + }); + + test("preserves concurrently prebuilt 0116 indexes through marker guards", () => { + expect(migration).toContain("cch:migration:0116:session-replay-index:v1"); + expect(migration).toContain("obj_description"); + expect(migration).not.toMatch(/^DROP INDEX IF EXISTS/m); + }); +}); diff --git a/tests/unit/drizzle/usage-ledger-cost-indexes.test.ts b/tests/unit/drizzle/usage-ledger-cost-indexes.test.ts index 9e440e0f0..a0afc5029 100644 --- a/tests/unit/drizzle/usage-ledger-cost-indexes.test.ts +++ b/tests/unit/drizzle/usage-ledger-cost-indexes.test.ts @@ -1,3 +1,5 @@ +import type { SQL } from "drizzle-orm"; +import { CasingCache } from "drizzle-orm/casing"; import { getTableConfig } from "drizzle-orm/pg-core"; import { describe, expect, it } from "vitest"; import { usageLedger } from "@/drizzle/schema"; @@ -27,6 +29,22 @@ describe("usage_ledger cost covering indexes", () => { }); }; + const indexPredicate = (name: string): string => { + const index = indexes.find((entry) => entry.config.name === name); + if (!index?.config.where) { + throw new Error(`index "${name}" has no predicate`); + } + return (index.config.where as SQL) + .toQuery({ + escapeName: (value) => `"${value}"`, + escapeParam: (num) => `$${num}`, + escapeString: (value) => `'${value}'`, + casing: new CasingCache(), + paramStartIndex: { value: 1 }, + }) + .sql.toLowerCase(); + }; + it.each([ ["idx_usage_ledger_user_cost_cover", ["user_id", "created_at", "cost_usd", "endpoint"]], [ @@ -37,4 +55,18 @@ describe("usage_ledger cost covering indexes", () => { ])("%s keeps endpoint as a trailing column so SUM(cost_usd) stays index-only", (name, expected) => { expect(indexColumns(name)).toEqual(expected); }); + + it.each([ + "idx_usage_ledger_user_created_at", + "idx_usage_ledger_key_created_at", + "idx_usage_ledger_provider_created_at", + "idx_usage_ledger_key_cost", + "idx_usage_ledger_user_cost_cover", + "idx_usage_ledger_provider_cost_cover", + "idx_usage_ledger_key_created_at_desc_cover", + ])("%s excludes blocked and Replay audit rows", (name) => { + const predicate = indexPredicate(name); + expect(predicate).toContain('"usage_ledger"."blocked_by" is null'); + expect(predicate).toContain('"usage_ledger"."is_replay" = false'); + }); }); diff --git a/tests/unit/i18n/session-request-errors.test.ts b/tests/unit/i18n/session-request-errors.test.ts new file mode 100644 index 000000000..0d75aaf64 --- /dev/null +++ b/tests/unit/i18n/session-request-errors.test.ts @@ -0,0 +1,22 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; + +const LOCALES = ["zh-CN", "zh-TW", "en", "ja", "ru"] as const; +const ERROR_CODES = [ + "SESSION_REQUEST_SOURCE_MISMATCH", + "SESSION_REQUEST_SELECTOR_INCOMPLETE", +] as const; + +describe.each(LOCALES)("session request locator errors (%s)", (locale) => { + const errors = JSON.parse( + fs.readFileSync(path.join(process.cwd(), "messages", locale, "errors.json"), "utf8") + ) as Record; + + test.each(ERROR_CODES)("translates %s", (code) => { + const value = errors[code]; + expect(value, `${locale}/errors.json must define ${code}`).toBeTypeOf("string"); + expect((value as string).trim().length).toBeGreaterThan(0); + expect(value).not.toBe(errors.OPERATION_FAILED); + }); +}); diff --git a/tests/unit/lib/availability-service.test.ts b/tests/unit/lib/availability-service.test.ts index e36c9a532..fcae47fde 100644 --- a/tests/unit/lib/availability-service.test.ts +++ b/tests/unit/lib/availability-service.test.ts @@ -332,6 +332,8 @@ describe("availability-service", () => { // 终态记录的 success/failure/excluded 分类仍由 outcome 函数完成。 expect(queryText).toContain("fn_compute_message_request_success_rate_outcome"); expect(queryText).toContain('avg("durationms") filter'); + expect(queryText).toContain('"message_request"."is_replay" ='); + expect(sqlToQuery(executeMock.mock.calls[0]?.[0]).params).toContain(false); }); it("queryProviderAvailability 计算 currentStatus 时会按最近 buckets 的请求量加权", async () => { @@ -750,6 +752,8 @@ describe("availability-service", () => { expect(queryText).toContain("<= now()"); expect(queryText).toContain("count(*) filter"); expect(queryText).toContain("max("); + expect(queryText).toContain('"message_request"."is_replay" ='); + expect(sqlToQuery(executeMock.mock.calls[0]?.[0]).params).toContain(false); }); it("getCurrentProviderStatus 在提供商无聚合数据时返回 unknown", async () => { diff --git a/tests/unit/lib/cache-effectiveness-gate.test.ts b/tests/unit/lib/cache-effectiveness-gate.test.ts index f0ba9c62a..8fced9c8c 100644 --- a/tests/unit/lib/cache-effectiveness-gate.test.ts +++ b/tests/unit/lib/cache-effectiveness-gate.test.ts @@ -20,6 +20,7 @@ function makeAffinity(overrides: Partial = {}): SessionAff }, nominatedProviderId: null, matchedFp: null, + generation: "0", matchedTier: null, ...overrides, }; diff --git a/tests/unit/lib/config/system-settings-cache.test.ts b/tests/unit/lib/config/system-settings-cache.test.ts index ffdf98166..1c3a20af8 100644 --- a/tests/unit/lib/config/system-settings-cache.test.ts +++ b/tests/unit/lib/config/system-settings-cache.test.ts @@ -8,6 +8,7 @@ const loggerWarnMock = vi.fn(); const loggerInfoMock = vi.fn(); const originalResponsesWebsocketEnv = process.env.ENABLE_OPENAI_RESPONSES_WEBSOCKET; +const originalStreamGateMode = process.env.STREAM_GATE_MODE; vi.mock("server-only", () => ({})); @@ -95,6 +96,11 @@ afterEach(() => { } else { process.env.ENABLE_OPENAI_RESPONSES_WEBSOCKET = originalResponsesWebsocketEnv; } + if (originalStreamGateMode === undefined) { + delete process.env.STREAM_GATE_MODE; + } else { + process.env.STREAM_GATE_MODE = originalStreamGateMode; + } }); describe("SystemSettingsCache", () => { @@ -165,6 +171,15 @@ describe("SystemSettingsCache", () => { expect(loggerWarnMock).toHaveBeenCalledTimes(1); }); + test("冷缓存读取失败时保留显式 STREAM_GATE_MODE=off", async () => { + process.env.STREAM_GATE_MODE = "off"; + getSystemSettingsMock.mockRejectedValueOnce(new Error("db down")); + const { getCachedSystemSettings } = await loadCache(); + + const settings = await getCachedSystemSettings(); + expect(settings.streamGateMode).toBe("off"); + }); + test("invalidateSystemSettingsCache 应清空缓存并触发下一次重新获取", async () => { const settingsA = createSettings({ id: 401 }); const settingsB = createSettings({ id: 402 }); diff --git a/tests/unit/lib/env-stream-gate-mode.test.ts b/tests/unit/lib/env-stream-gate-mode.test.ts new file mode 100644 index 000000000..4e882e91e --- /dev/null +++ b/tests/unit/lib/env-stream-gate-mode.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "vitest"; +import { EnvSchema } from "@/lib/config/env.schema"; + +describe("EnvSchema - STREAM_GATE_MODE", () => { + test("defaults to enforce when unset", () => { + expect(EnvSchema.parse({}).STREAM_GATE_MODE).toBe("enforce"); + }); + + test.each(["off", "shadow", "enforce"] as const)("preserves an explicit %s mode", (mode) => { + expect(EnvSchema.parse({ STREAM_GATE_MODE: mode }).STREAM_GATE_MODE).toBe(mode); + }); +}); diff --git a/tests/unit/lib/proxy-status-tracker.test.ts b/tests/unit/lib/proxy-status-tracker.test.ts new file mode 100644 index 000000000..911d1576a --- /dev/null +++ b/tests/unit/lib/proxy-status-tracker.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createDrizzleQuery, sqlText } from "../repository/message-query-test-support"; + +const boundary = vi.hoisted(() => ({ + select: vi.fn<(selection?: unknown) => unknown>(), + execute: vi.fn<(query: unknown) => Promise>(), +})); + +vi.mock("@/drizzle/db", () => ({ + db: { select: boundary.select, execute: boundary.execute }, +})); + +describe("ProxyStatusTracker", () => { + beforeEach(() => { + vi.resetModules(); + boundary.select.mockReset(); + boundary.execute.mockReset(); + }); + + it("excludes Replay audit rows from active and last-request status", async () => { + const usersQuery = createDrizzleQuery([{ id: 7, name: "Ada" }]); + const activeQuery = createDrizzleQuery([]); + boundary.select.mockReturnValueOnce(usersQuery).mockReturnValueOnce(activeQuery); + boundary.execute.mockResolvedValueOnce([]); + + const { ProxyStatusTracker } = await import("@/lib/proxy-status-tracker"); + await expect(ProxyStatusTracker.getInstance().getAllUsersStatus()).resolves.toEqual({ + users: [ + { + userId: 7, + userName: "Ada", + activeCount: 0, + activeRequests: [], + lastRequest: null, + }, + ], + }); + + expect(sqlText(activeQuery.trace.where[0])).toContain("is_replay = false"); + expect(sqlText(boundary.execute.mock.calls[0]?.[0])).toContain("mr.is_replay = false"); + }); +}); diff --git a/tests/unit/lib/session-replay-index-preflight.test.ts b/tests/unit/lib/session-replay-index-preflight.test.ts new file mode 100644 index 000000000..9e28bb3d3 --- /dev/null +++ b/tests/unit/lib/session-replay-index-preflight.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test, vi } from "vitest"; +import { + SESSION_REPLAY_INDEX_MARKER, + SESSION_REPLAY_INDEX_SPECS, + SESSION_REPLAY_MIGRATION_CREATED_AT, + runPendingSessionReplayIndexPreflight, + runSessionReplayIndexPreflight, + type MigrationIndexState, +} from "@/lib/migrations/session-replay-index-preflight"; + +function createFakeExecutor(initial: Record = {}) { + const states = new Map(Object.entries(initial)); + const execute = vi.fn(async (sql: string) => { + const createName = sql.match(/^CREATE INDEX CONCURRENTLY "([^"]+)"/)?.[1]; + if (createName) { + states.set(createName, { exists: true, valid: true, marker: null }); + return; + } + + const commentName = sql.match(/^COMMENT ON INDEX "([^"]+)"/)?.[1]; + if (commentName) { + const state = states.get(commentName); + if (!state) throw new Error(`missing index ${commentName}`); + states.set(commentName, { ...state, marker: SESSION_REPLAY_INDEX_MARKER }); + return; + } + + const dropName = sql.match(/^DROP INDEX CONCURRENTLY IF EXISTS "([^"]+)"/)?.[1]; + if (dropName) { + states.delete(dropName); + return; + } + + const rename = sql.match(/^ALTER INDEX "([^"]+)" RENAME TO "([^"]+)"/)?.slice(1); + if (rename) { + const [from, to] = rename; + const state = states.get(from); + if (!state) throw new Error(`missing index ${from}`); + states.delete(from); + states.set(to, state); + } + }); + const inspectIndex = vi.fn( + async (name: string) => states.get(name) ?? { exists: false, valid: false, marker: null } + ); + return { executor: { execute, inspectIndex }, execute, inspectIndex, states }; +} + +describe("0116 concurrent index preflight", () => { + const spec = SESSION_REPLAY_INDEX_SPECS[0]; + + test("does not run after migration 0116 has already been recorded", async () => { + const { executor, execute, inspectIndex } = createFakeExecutor(); + + await runPendingSessionReplayIndexPreflight(executor, SESSION_REPLAY_MIGRATION_CREATED_AT); + + expect(execute).not.toHaveBeenCalled(); + expect(inspectIndex).not.toHaveBeenCalled(); + }); + + test("builds and validates a temporary index before replacing the canonical index", async () => { + const { executor, execute, states } = createFakeExecutor({ + [spec.canonicalName]: { exists: true, valid: true, marker: null }, + }); + + await runSessionReplayIndexPreflight(executor, [spec]); + + expect(states.get(spec.canonicalName)).toEqual({ + exists: true, + valid: true, + marker: SESSION_REPLAY_INDEX_MARKER, + }); + expect(states.has(spec.temporaryName)).toBe(false); + + const sql = execute.mock.calls.map(([statement]) => statement); + const createAt = sql.findIndex((statement) => + statement.startsWith("CREATE INDEX CONCURRENTLY") + ); + const dropAt = sql.findIndex((statement) => + statement.includes(`DROP INDEX CONCURRENTLY IF EXISTS "${spec.canonicalName}"`) + ); + const renameAt = sql.findIndex((statement) => + statement.includes(`ALTER INDEX "${spec.temporaryName}" RENAME TO`) + ); + expect(createAt).toBeGreaterThanOrEqual(0); + expect(dropAt).toBeGreaterThan(createAt); + expect(renameAt).toBeGreaterThan(dropAt); + }); + + test("resumes by renaming a previously validated temporary index", async () => { + const { executor, execute, states } = createFakeExecutor({ + [spec.temporaryName]: { + exists: true, + valid: true, + marker: SESSION_REPLAY_INDEX_MARKER, + }, + }); + + await runSessionReplayIndexPreflight(executor, [spec]); + + expect(states.get(spec.canonicalName)?.marker).toBe(SESSION_REPLAY_INDEX_MARKER); + expect(execute.mock.calls.flat().some((sql) => sql.startsWith("CREATE INDEX"))).toBe(false); + }); + + test("keeps the old canonical index when the concurrent build fails", async () => { + const { executor, execute, states } = createFakeExecutor({ + [spec.canonicalName]: { exists: true, valid: true, marker: null }, + }); + execute.mockImplementationOnce(async () => undefined); + execute.mockImplementationOnce(async () => undefined); + execute.mockImplementationOnce(async () => undefined); + execute.mockImplementationOnce(async () => { + throw new Error("concurrent build failed"); + }); + + await expect(runSessionReplayIndexPreflight(executor, [spec])).rejects.toThrow( + "concurrent build failed" + ); + expect(states.get(spec.canonicalName)).toEqual({ + exists: true, + valid: true, + marker: null, + }); + expect( + execute.mock.calls + .flat() + .some((sql) => sql.includes(`DROP INDEX CONCURRENTLY IF EXISTS "${spec.canonicalName}"`)) + ).toBe(false); + }); + + test("keeps a validated canonical index and only removes a stale temp", async () => { + const { executor, execute, states } = createFakeExecutor({ + [spec.canonicalName]: { + exists: true, + valid: true, + marker: SESSION_REPLAY_INDEX_MARKER, + }, + [spec.temporaryName]: { exists: true, valid: false, marker: null }, + }); + + await runSessionReplayIndexPreflight(executor, [spec]); + + expect(states.get(spec.canonicalName)?.marker).toBe(SESSION_REPLAY_INDEX_MARKER); + expect(states.has(spec.temporaryName)).toBe(false); + expect( + execute.mock.calls + .flat() + .some((sql) => sql.includes(`DROP INDEX CONCURRENTLY IF EXISTS "${spec.canonicalName}"`)) + ).toBe(false); + }); +}); diff --git a/tests/unit/lib/session-request-locator.test.ts b/tests/unit/lib/session-request-locator.test.ts new file mode 100644 index 000000000..c5c38e5a1 --- /dev/null +++ b/tests/unit/lib/session-request-locator.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const findSessionRequestLocatorMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/repository/message", () => ({ + findSessionRequestLocator: findSessionRequestLocatorMock, +})); + +describe("resolveSessionRequestLocator", () => { + beforeEach(() => { + findSessionRequestLocatorMock.mockReset(); + }); + + it("returns a stable source-mismatch code when the identity is unavailable", async () => { + findSessionRequestLocatorMock.mockResolvedValueOnce(null); + const { resolveSessionRequestLocator } = await import("@/lib/session-request-locator"); + + await expect(resolveSessionRequestLocator("pfx:scope:fingerprint")).resolves.toEqual({ + ok: false, + error: "Request source does not belong to this session.", + errorCode: "SESSION_REQUEST_SOURCE_MISMATCH", + }); + }); + + it("requires the physical source and sequence together for a prefix identity", async () => { + findSessionRequestLocatorMock.mockResolvedValueOnce({ + sourceSessionId: "physical-latest", + requestSequence: 8, + identityKind: "prefix_affinity", + scopeTag: "scope", + fingerprint: "fingerprint", + }); + const { resolveSessionRequestLocator } = await import("@/lib/session-request-locator"); + + await expect(resolveSessionRequestLocator("pfx:scope:fingerprint", 7)).resolves.toEqual({ + ok: false, + error: "Prefix Session requests must specify both the physical source and request sequence.", + errorCode: "SESSION_REQUEST_SELECTOR_INCOMPLETE", + }); + }); + + it("returns the source-mismatch code when the selected physical request is outside the identity", async () => { + findSessionRequestLocatorMock + .mockResolvedValueOnce({ + sourceSessionId: "physical-latest", + requestSequence: 8, + identityKind: "prefix_affinity", + scopeTag: "scope", + fingerprint: "fingerprint", + }) + .mockResolvedValueOnce(null); + const { resolveSessionRequestLocator } = await import("@/lib/session-request-locator"); + + await expect( + resolveSessionRequestLocator("pfx:scope:fingerprint", 7, "physical-selected") + ).resolves.toMatchObject({ + ok: false, + errorCode: "SESSION_REQUEST_SOURCE_MISMATCH", + }); + }); +}); diff --git a/tests/unit/lib/session-tracker-cleanup.test.ts b/tests/unit/lib/session-tracker-cleanup.test.ts index 02a1cb046..f299b8244 100644 --- a/tests/unit/lib/session-tracker-cleanup.test.ts +++ b/tests/unit/lib/session-tracker-cleanup.test.ts @@ -1,8 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { getGlobalActiveSessionsKey } from "@/lib/redis/active-session-keys"; +import { + getGlobalActiveSessionsKey, + getObservedGlobalActiveSessionsKey, +} from "@/lib/redis/active-session-keys"; let redisClientRef: any; const pipelineCalls: Array = []; +let pipelineExecResults: Array<[Error | null, unknown]> = []; /** * 构造一个可记录调用的 Redis pipeline mock(用于断言 cleanup/expire 等行为)。 @@ -41,9 +45,17 @@ const makePipeline = () => { pipelineCalls.push(["exists", ...args]); return pipeline; }), + zrem: vi.fn((...args: unknown[]) => { + pipelineCalls.push(["zrem", ...args]); + return pipeline; + }), + del: vi.fn((...args: unknown[]) => { + pipelineCalls.push(["del", ...args]); + return pipeline; + }), exec: vi.fn(async () => { pipelineCalls.push(["exec"]); - return []; + return pipelineExecResults; }), }; return pipeline; @@ -72,6 +84,7 @@ describe("SessionTracker - TTL and cleanup", () => { vi.resetAllMocks(); vi.resetModules(); pipelineCalls.length = 0; + pipelineExecResults = []; vi.useFakeTimers(); vi.setSystemTime(new Date(nowMs)); redisClientRef = { @@ -275,6 +288,53 @@ describe("SessionTracker - TTL and cleanup", () => { }); }); + describe("getObservedActiveSessions", () => { + it("returns only identities with live session info, matching dashboard count eligibility", async () => { + redisClientRef.zrange.mockResolvedValue(["pfx:scope:live", "pfx:scope:stale"]); + pipelineExecResults = [ + [null, 1], + [null, 0], + ]; + + const { SessionTracker } = await import("@/lib/session-tracker"); + + await expect(SessionTracker.getObservedActiveSessions()).resolves.toEqual(["pfx:scope:live"]); + expect(redisClientRef.zremrangebyscore).toHaveBeenCalledWith( + getObservedGlobalActiveSessionsKey(), + "-inf", + nowMs - 300 * 1000 + ); + expect(pipelineCalls).toContainEqual(["exists", "session:pfx:scope:live:info"]); + expect(pipelineCalls).toContainEqual(["exists", "session:pfx:scope:stale:info"]); + }); + }); + + describe("terminateObservedSession", () => { + it("removes the observed identity, concurrent count, and session info", async () => { + pipelineExecResults = [ + [null, 1], + [null, 1], + [null, 1], + ]; + + const { SessionTracker } = await import("@/lib/session-tracker"); + + await expect(SessionTracker.terminateObservedSession("pfx:scope:fingerprint")).resolves.toBe( + true + ); + expect(pipelineCalls).toContainEqual([ + "zrem", + getObservedGlobalActiveSessionsKey(), + "pfx:scope:fingerprint", + ]); + expect(pipelineCalls).toContainEqual([ + "del", + "observed_session:pfx:scope:fingerprint:concurrent_count", + ]); + expect(pipelineCalls).toContainEqual(["del", "session:pfx:scope:fingerprint:info"]); + }); + }); + describe("Fail-Open behavior", () => { it("refreshSession should not throw when Redis is not ready", async () => { redisClientRef.status = "end"; diff --git a/tests/unit/proxy/affinity-recorder.test.ts b/tests/unit/proxy/affinity-recorder.test.ts index 03915f606..244728594 100644 --- a/tests/unit/proxy/affinity-recorder.test.ts +++ b/tests/unit/proxy/affinity-recorder.test.ts @@ -59,6 +59,7 @@ function makeAffinity(overrides: Partial = {}): SessionAff chain: makeChain(), nominatedProviderId: null, matchedFp: null, + generation: "0", ...overrides, }; } @@ -77,7 +78,7 @@ describe("recordAffinityWinner", () => { it("writes a single tip binding for the winning provider with the configured TTL", async () => { await recordAffinityWinner(makeSession(makeAffinity()), 42); expect(storeMocks.put).toHaveBeenCalledTimes(1); - expect(storeMocks.put).toHaveBeenCalledWith("scope123", "fp2", 42, 3600); + expect(storeMocks.put).toHaveBeenCalledWith("scope123", "fp2", 42, 3600, "0"); }); it("skips writing when the chain has no conversation boundaries (system-only tip)", async () => { @@ -96,7 +97,7 @@ describe("recordAffinityWinner", () => { envControl.enabled = false; settingsControl.ignoreClientSessionId = true; await recordAffinityWinner(makeSession(makeAffinity()), 42); - expect(storeMocks.put).toHaveBeenCalledWith("scope123", "fp2", 42, 3600); + expect(storeMocks.put).toHaveBeenCalledWith("scope123", "fp2", 42, 3600, "0"); }); it("is a no-op without affinity state or with a non-positive provider id", async () => { @@ -119,7 +120,7 @@ describe("tombstoneAffinityOnFailure", () => { const session = makeSession(makeAffinity({ nominatedProviderId: 42, matchedFp: "fp2" })); await tombstoneAffinityOnFailure(session, 42); expect(storeMocks.tombstone).toHaveBeenCalledTimes(1); - expect(storeMocks.tombstone).toHaveBeenCalledWith("scope123", "fp2", "failover"); + expect(storeMocks.tombstone).toHaveBeenCalledWith("scope123", "fp2", "failover", "0"); }); it("is a no-op when the failed provider differs from the nominated one", async () => { @@ -158,7 +159,7 @@ describe("tombstoneAffinityOnFailure", () => { settingsControl.ignoreClientSessionId = true; const session = makeSession(makeAffinity({ nominatedProviderId: 42, matchedFp: "fp2" })); await tombstoneAffinityOnFailure(session, 42); - expect(storeMocks.tombstone).toHaveBeenCalledWith("scope123", "fp2", "failover"); + expect(storeMocks.tombstone).toHaveBeenCalledWith("scope123", "fp2", "failover", "0"); }); it("swallows store failures", async () => { diff --git a/tests/unit/proxy/affinity-store.test.ts b/tests/unit/proxy/affinity-store.test.ts index a9689b085..f86b0478e 100644 --- a/tests/unit/proxy/affinity-store.test.ts +++ b/tests/unit/proxy/affinity-store.test.ts @@ -17,18 +17,48 @@ function createLuaFakeRedis(initial: Record = {}) { expired.push({ key, ttl }); return "OK"; }), - del: vi.fn(async () => 1), - eval: vi.fn(async (_script: string, numkeys: number, ...rest: (string | number)[]) => { + del: vi.fn(async (...keys: string[]) => { + for (const redisKey of keys) data.delete(redisKey); + return keys.length; + }), + eval: vi.fn(async (script: string, numkeys: number, ...rest: (string | number)[]) => { const keys = rest.slice(0, numkeys) as string[]; - const ttl = Number(rest[numkeys]); - for (let i = 0; i < keys.length; i++) { - const value = data.get(keys[i]); - if (value?.startsWith("1|")) { - if (ttl > 0) expired.push({ key: keys[i], ttl }); - return [i + 1, value]; + const args = rest.slice(numkeys).map(String); + + if (script.includes("affinity_lookup_v2")) { + const generationKey = keys.at(-1) as string; + if (!data.has(generationKey)) data.set(generationKey, "0"); + const generation = data.get(generationKey) as string; + const ttl = Number(args[0]); + for (let i = 0; i < keys.length - 1; i++) { + const value = data.get(keys[i]); + const bindingGeneration = value?.split("|")[2] ?? "0"; + if (value?.startsWith("1|") && bindingGeneration === generation) { + if (ttl > 0) expired.push({ key: keys[i], ttl }); + return [i + 1, value, generation]; + } } + return [0, "", generation]; + } + + if (script.includes("affinity_cas_write_v1")) { + const [generationKey, bindingKey] = keys; + const [expectedGeneration, value, ttlRaw] = args; + if (data.get(generationKey) !== expectedGeneration) return 0; + data.set(bindingKey, value); + expired.push({ key: bindingKey, ttl: Number(ttlRaw) }); + return 1; } - return null; + + if (script.includes("affinity_invalidate_v1")) { + const [generationKey, ...bindingKeys] = keys; + const generation = Number(data.get(generationKey) ?? "0") + 1; + data.set(generationKey, String(generation)); + for (const bindingKey of bindingKeys) data.delete(bindingKey); + return generation; + } + + throw new Error("unexpected lua script"); }), }; return { client, data, expired }; @@ -39,6 +69,7 @@ function makeStore(client: unknown) { } const key = (scope: string, fp: string) => `cch:pfx:{${scope}}:fp:${fp}`; +const generationKey = (scope: string) => `cch:pfx:{${scope}}:generation`; describe("AffinityStore.lookup", () => { it("returns the deepest active binding (MGET-style deepest-first scan)", async () => { @@ -49,9 +80,12 @@ describe("AffinityStore.lookup", () => { }); const hint = await makeStore(client).lookup("s1", ["deep", "mid", "sysf"], 600); expect(hint).toEqual({ - providerId: 42, - matchedIndex: 0, - matchedFp: "deep", + generation: "0", + hint: { + providerId: 42, + matchedIndex: 0, + matchedFp: "deep", + }, }); }); @@ -60,10 +94,11 @@ describe("AffinityStore.lookup", () => { await makeStore(client).lookup("tag", ["deep", "mid", "sysf"], 300.9); expect(client.eval).toHaveBeenCalledWith( expect.stringContaining("GET"), - 3, + 4, key("tag", "deep"), key("tag", "mid"), key("tag", "sysf"), + generationKey("tag"), "300" ); }); @@ -75,17 +110,20 @@ describe("AffinityStore.lookup", () => { }); const hint = await makeStore(client).lookup("s1", ["deep", "mid", "sysf"], 600); expect(hint).toEqual({ - providerId: 7, - matchedIndex: 1, - matchedFp: "mid", + generation: "0", + hint: { + providerId: 7, + matchedIndex: 1, + matchedFp: "mid", + }, }); }); it("matches the shallowest boundary when only it is active", async () => { const { client } = createLuaFakeRedis({ [key("s1", "shallow")]: "1|9" }); const hint = await makeStore(client).lookup("s1", ["deep", "mid", "shallow"], 600); - expect(hint?.matchedFp).toBe("shallow"); - expect(hint?.matchedIndex).toBe(2); + expect(hint?.hint?.matchedFp).toBe("shallow"); + expect(hint?.hint?.matchedIndex).toBe(2); }); it("returns null when all boundaries are tombstoned or absent", async () => { @@ -94,8 +132,8 @@ describe("AffinityStore.lookup", () => { [key("s1", "sysf")]: "0|failover", }); const store = makeStore(client); - expect(await store.lookup("s1", ["deep", "sysf"], 600)).toBeNull(); - expect(await store.lookup("s1", ["missing-a", "missing-b"], 600)).toBeNull(); + expect((await store.lookup("s1", ["deep", "sysf"], 600))?.hint).toBeNull(); + expect((await store.lookup("s1", ["missing-a", "missing-b"], 600))?.hint).toBeNull(); }); it("slides the TTL on hit and skips renewal when ttl is not positive", async () => { @@ -105,7 +143,13 @@ describe("AffinityStore.lookup", () => { expect(expired).toEqual([{ key: key("s1", "deep"), ttl: 900 }]); await store.lookup("s1", ["deep"], -10); - expect(client.eval).toHaveBeenLastCalledWith(expect.any(String), 1, key("s1", "deep"), "0"); + expect(client.eval).toHaveBeenLastCalledWith( + expect.any(String), + 2, + key("s1", "deep"), + generationKey("s1"), + "0" + ); expect(expired).toHaveLength(1); }); @@ -113,8 +157,8 @@ describe("AffinityStore.lookup", () => { const { client } = createLuaFakeRedis(); const store = makeStore(client); for (const value of ["1|abc", "1|0", "1|-5"]) { - client.eval.mockResolvedValueOnce([1, value]); - expect(await store.lookup("s1", ["deep"], 600)).toBeNull(); + client.eval.mockResolvedValueOnce([1, value, "0"]); + expect((await store.lookup("s1", ["deep"], 600))?.hint).toBeNull(); } client.eval.mockResolvedValueOnce("garbage"); expect(await store.lookup("s1", ["deep"], 600)).toBeNull(); @@ -132,35 +176,54 @@ describe("AffinityStore.lookup", () => { describe("AffinityStore.put", () => { it("writes only the tip boundary with the active encoding and TTL", async () => { - const { client } = createLuaFakeRedis(); - await makeStore(client).put("s1", "tipfp", 42, 900); - expect(client.set).toHaveBeenCalledTimes(1); - expect(client.set).toHaveBeenCalledWith(key("s1", "tipfp"), "1|42", "EX", 900); + const { client } = createLuaFakeRedis({ [generationKey("s1")]: "0" }); + await expect(makeStore(client).put("s1", "tipfp", 42, 900, "0")).resolves.toBe(true); + expect(client.eval).toHaveBeenCalledWith( + expect.stringContaining("affinity_cas_write_v1"), + 2, + generationKey("s1"), + key("s1", "tipfp"), + "0", + "1|42|0", + 900 + ); }); it("ignores invalid arguments", async () => { const { client } = createLuaFakeRedis(); const store = makeStore(client); - await store.put("", "tip", 42, 900); - await store.put("s1", "", 42, 900); - await store.put("s1", "tip", 0, 900); - await store.put("s1", "tip", 42, 0); - expect(client.set).not.toHaveBeenCalled(); + await store.put("", "tip", 42, 900, "0"); + await store.put("s1", "", 42, 900, "0"); + await store.put("s1", "tip", 0, 900, "0"); + await store.put("s1", "tip", 42, 0, "0"); + await store.put("s1", "tip", 42, 900, null); + expect(client.eval).not.toHaveBeenCalled(); }); }); describe("AffinityStore.tombstone", () => { it("writes a short-TTL tombstone with a truncated reason", async () => { - const { client } = createLuaFakeRedis(); + const { client } = createLuaFakeRedis({ [generationKey("s1")]: "0" }); const store = makeStore(client); - await store.tombstone("s1", "deadfp", "failover"); - expect(client.set).toHaveBeenCalledWith(key("s1", "deadfp"), "0|failover", "EX", 60); + await store.tombstone("s1", "deadfp", "failover", "0"); + expect(client.eval).toHaveBeenCalledWith( + expect.stringContaining("affinity_cas_write_v1"), + 2, + generationKey("s1"), + key("s1", "deadfp"), + "0", + "0|failover|0", + 60 + ); - await store.tombstone("s1", "deadfp", "x".repeat(50)); - expect(client.set).toHaveBeenLastCalledWith( + await store.tombstone("s1", "deadfp", "x".repeat(50), "0"); + expect(client.eval).toHaveBeenLastCalledWith( + expect.stringContaining("affinity_cas_write_v1"), + 2, + generationKey("s1"), key("s1", "deadfp"), - `0|${"x".repeat(32)}`, - "EX", + "0", + `0|${"x".repeat(32)}|0`, 60 ); }); @@ -168,9 +231,69 @@ describe("AffinityStore.tombstone", () => { it("ignores empty scope or fingerprint", async () => { const { client } = createLuaFakeRedis(); const store = makeStore(client); - await store.tombstone("", "fp", "r"); - await store.tombstone("s1", "", "r"); - expect(client.set).not.toHaveBeenCalled(); + await store.tombstone("", "fp", "r", "0"); + await store.tombstone("s1", "", "r", "0"); + await store.tombstone("s1", "fp", "r", null); + expect(client.eval).not.toHaveBeenCalled(); + }); +}); + +describe("AffinityStore.invalidate", () => { + it("deletes the target and known ancestor bindings in one call", async () => { + const { client, data } = createLuaFakeRedis({ + [key("s1", "deep")]: "1|42", + [key("s1", "mid")]: "1|7", + }); + + await expect(makeStore(client).invalidate("s1", ["deep", "mid"])).resolves.toBe(true); + + expect(data.get(generationKey("s1"))).toBe("1"); + expect(data.has(key("s1", "deep"))).toBe(false); + expect(data.has(key("s1", "mid"))).toBe(false); + }); + + it("treats a zero-count DEL as an idempotent success", async () => { + const { client } = createLuaFakeRedis(); + await expect(makeStore(client).invalidate("s1", ["missing"])).resolves.toBe(true); + }); + + it("returns false when redis is unavailable or DEL fails", async () => { + await expect(makeStore(null).invalidate("s1", ["deep"])).resolves.toBe(false); + + const { client } = createLuaFakeRedis(); + client.eval.mockRejectedValueOnce(new Error("boom")); + await expect(makeStore(client).invalidate("s1", ["deep"])).resolves.toBe(false); + }); + + it("invalidates unknown descendants through the scope generation", async () => { + const { client } = createLuaFakeRedis(); + const store = makeStore(client); + const initial = await store.lookup("s1", ["child", "parent"], 600); + + expect(initial).toMatchObject({ hint: null, generation: "0" }); + await expect(store.put("s1", "child", 42, 600, initial?.generation)).resolves.toBe(true); + await expect(store.invalidate("s1", ["parent"])).resolves.toBe(true); + + await expect(store.lookup("s1", ["child", "parent"], 600)).resolves.toMatchObject({ + hint: null, + generation: "1", + }); + }); + + it("rejects stale writeback after termination and accepts the next generation", async () => { + const { client } = createLuaFakeRedis(); + const store = makeStore(client); + const stale = await store.lookup("s1", ["tip"], 600); + + await expect(store.invalidate("s1", ["tip"])).resolves.toBe(true); + await expect(store.put("s1", "tip", 42, 600, stale?.generation)).resolves.toBe(false); + + const fresh = await store.lookup("s1", ["tip"], 600); + await expect(store.put("s1", "tip", 7, 600, fresh?.generation)).resolves.toBe(true); + await expect(store.lookup("s1", ["tip"], 600)).resolves.toMatchObject({ + hint: { providerId: 7, matchedFp: "tip" }, + generation: "1", + }); }); }); @@ -179,15 +302,19 @@ describe("AffinityStore round-trip through the fake Lua", () => { const { client } = createLuaFakeRedis(); const store = makeStore(client); - await store.put("s1", "tip", 42, 600); + const lookup = await store.lookup("s1", ["tip"], 600); + await store.put("s1", "tip", 42, 600, lookup?.generation); expect(await store.lookup("s1", ["tip"], 600)).toEqual({ - providerId: 42, - matchedIndex: 0, - matchedFp: "tip", + generation: "0", + hint: { + providerId: 42, + matchedIndex: 0, + matchedFp: "tip", + }, }); - await store.tombstone("s1", "tip", "failover"); - expect(await store.lookup("s1", ["tip"], 600)).toBeNull(); + await store.tombstone("s1", "tip", "failover", lookup?.generation); + expect((await store.lookup("s1", ["tip"], 600))?.hint).toBeNull(); }); }); @@ -195,15 +322,15 @@ describe("AffinityStore fail-open behavior", () => { it("fails open when redis is unavailable or not ready", async () => { const nullStore = makeStore(null); expect(await nullStore.lookup("s1", ["fp"], 600)).toBeNull(); - await expect(nullStore.put("s1", "tip", 42, 600)).resolves.toBeUndefined(); - await expect(nullStore.tombstone("s1", "fp", "r")).resolves.toBeUndefined(); + await expect(nullStore.put("s1", "tip", 42, 600, "0")).resolves.toBe(false); + await expect(nullStore.tombstone("s1", "fp", "r", "0")).resolves.toBe(false); const { client } = createLuaFakeRedis(); client.status = "connecting"; const store = makeStore(client); expect(await store.lookup("s1", ["fp"], 600)).toBeNull(); - await store.put("s1", "tip", 42, 600); - await store.tombstone("s1", "fp", "r"); + await store.put("s1", "tip", 42, 600, "0"); + await store.tombstone("s1", "fp", "r", "0"); expect(client.eval).not.toHaveBeenCalled(); expect(client.set).not.toHaveBeenCalled(); }); @@ -214,8 +341,8 @@ describe("AffinityStore fail-open behavior", () => { client.set.mockRejectedValue(new Error("boom")); const store = makeStore(client); expect(await store.lookup("s1", ["fp"], 600)).toBeNull(); - await expect(store.put("s1", "tip", 42, 600)).resolves.toBeUndefined(); - await expect(store.tombstone("s1", "fp", "r")).resolves.toBeUndefined(); + await expect(store.put("s1", "tip", 42, 600, "0")).resolves.toBe(false); + await expect(store.tombstone("s1", "fp", "r", "0")).resolves.toBe(false); }); it("fails open when redis rejects with a non-Error value", async () => { @@ -224,8 +351,8 @@ describe("AffinityStore fail-open behavior", () => { client.set.mockRejectedValue("string failure"); const store = makeStore(client); expect(await store.lookup("s1", ["fp"], 600)).toBeNull(); - await expect(store.put("s1", "tip", 42, 600)).resolves.toBeUndefined(); - await expect(store.tombstone("s1", "fp", "r")).resolves.toBeUndefined(); + await expect(store.put("s1", "tip", 42, 600, "0")).resolves.toBe(false); + await expect(store.tombstone("s1", "fp", "r", "0")).resolves.toBe(false); }); }); diff --git a/tests/unit/proxy/connected-non-reader-lifetime.test.ts b/tests/unit/proxy/connected-non-reader-lifetime.test.ts index f41e2a7c6..73f8173b5 100644 --- a/tests/unit/proxy/connected-non-reader-lifetime.test.ts +++ b/tests/unit/proxy/connected-non-reader-lifetime.test.ts @@ -223,7 +223,9 @@ describe("connected non-reader response lifetime", () => { return { statusCode: 200, headers: { "content-type": "text/event-stream" }, - body: Readable.from(["data: {}\n\n"]), + body: Readable.from([ + encoder.encode('data: {"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}\n\n'), + ]), }; }); diff --git a/tests/unit/proxy/hedge-error-pipeline.test.ts b/tests/unit/proxy/hedge-error-pipeline.test.ts index 377d6071a..e2048284c 100644 --- a/tests/unit/proxy/hedge-error-pipeline.test.ts +++ b/tests/unit/proxy/hedge-error-pipeline.test.ts @@ -23,6 +23,7 @@ const h = vi.hoisted(() => ({ h.session.rawCrossProviderFallbackEnabled = enabled; }, isRawCrossProviderFallbackEnabled: () => !!h.session.rawCrossProviderFallbackEnabled, + shouldTrackSessionObservability: () => false, recordForwardStart: vi.fn(), rawCrossProviderFallbackEnabled: false, } as any, diff --git a/tests/unit/proxy/provider-selector-affinity-ignore-session.test.ts b/tests/unit/proxy/provider-selector-affinity-ignore-session.test.ts index a5ba6a7bd..0d265f2cb 100644 --- a/tests/unit/proxy/provider-selector-affinity-ignore-session.test.ts +++ b/tests/unit/proxy/provider-selector-affinity-ignore-session.test.ts @@ -172,15 +172,21 @@ function makeSession(overrides: Record = {}): any { setGroupCostMultiplier: vi.fn(), getProvidersSnapshot: vi.fn(async () => [makeProvider(55)]), recordProviderSessionRef: vi.fn(), + setSessionIdentityMetadata: vi.fn((metadata: unknown) => { + session._sessionIdentityMetadata = metadata; + }), }; return Object.assign(session, overrides); } const affinityHint = { - providerId: 42, - matchedFp: "deepfp", - matchedIndex: 0, - tier: "conversation" as const, + generation: "0", + hint: { + providerId: 42, + matchedFp: "deepfp", + matchedIndex: 0, + tier: "conversation" as const, + }, }; beforeEach(() => { @@ -249,6 +255,86 @@ describe("affinity candidate cost limits", () => { }); describe("ignore client session id semantics", () => { + test("uses the actual matched fingerprint as the prefix Session identity", async () => { + storeMocks.lookup + .mockResolvedValueOnce({ + ...affinityHint, + hint: { ...affinityHint.hint, matchedFp: "fingerprint-f" }, + }) + .mockResolvedValueOnce({ + ...affinityHint, + hint: { ...affinityHint.hint, matchedFp: "fingerprint-g" }, + }) + .mockResolvedValueOnce({ + ...affinityHint, + hint: { ...affinityHint.hint, matchedFp: "fingerprint-g" }, + }); + providerRepositoryMocks.findProviderById.mockResolvedValue(makeProvider(42)); + + const sessions = [ + makeSession({ sessionId: "physical-session-1" }), + makeSession({ sessionId: "physical-session-2" }), + makeSession({ sessionId: "physical-session-3" }), + ]; + for (const session of sessions) { + await ProxyProviderResolver.ensure(session); + } + + const identities = sessions.map((session) => { + const metadata = session._sessionIdentityMetadata as { + identity: string; + scopeTag: string; + fingerprint: string; + }; + expect(metadata.identity).toBe(`pfx:${metadata.scopeTag}:${metadata.fingerprint}`); + return metadata.fingerprint; + }); + + expect(identities).toEqual(["fingerprint-f", "fingerprint-g", "fingerprint-g"]); + }); + + test("stores only the matched fingerprint and its ancestors for an intermediate hit", async () => { + let deepestFirst: string[] = []; + storeMocks.lookup.mockImplementation(async (_scopeTag, fingerprints: string[]) => { + deepestFirst = fingerprints; + return { + generation: "0", + hint: { + ...affinityHint.hint, + matchedFp: fingerprints[1], + matchedIndex: 1, + }, + }; + }); + providerRepositoryMocks.findProviderById.mockResolvedValue(makeProvider(42)); + + const session = makeSession({ + sessionId: "physical-session", + request: { + message: { + ...claudeMessage, + messages: [ + { role: "user", content: "one" }, + { role: "assistant", content: "two" }, + { role: "user", content: "three" }, + { role: "assistant", content: "four" }, + { role: "user", content: "five" }, + ], + }, + }, + }); + + await ProxyProviderResolver.ensure(session); + + expect(deepestFirst.length).toBeGreaterThan(1); + expect(session._sessionIdentityMetadata).toMatchObject({ + identity: expect.stringContaining(`:${deepestFirst[1]}`), + fingerprint: deepestFirst[1], + fingerprints: deepestFirst.slice(1), + }); + expect(session._sessionIdentityMetadata.fingerprints).not.toContain(deepestFirst[0]); + }); + test("ignore on + fingerprintable request never reads the session binding", async () => { sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValue(91); providerRepositoryMocks.findProviderById.mockResolvedValue(makeProvider(91)); @@ -291,6 +377,80 @@ describe("ignore client session id semantics", () => { expect.objectContaining({ reason: "session_reuse" }) ); }); + + test("escapes a client-controlled pfx prefix from the logical Session identity namespace", async () => { + const session = makeSession({ + sessionId: "pfx:foreign-scope:foreign-fingerprint", + request: { message: { model: "claude-sonnet-4-5" } }, + }); + + const result = await ProxyProviderResolver.ensure(session); + + expect(result).toBeNull(); + expect(session.setSessionIdentityMetadata).toHaveBeenCalledWith({ + identity: "sid:ce9895f7ae1c9727ce21d234b659482e", + kind: "session_id", + scopeTag: null, + fingerprint: null, + fingerprints: [], + }); + }); + + test("isolates both reserved physical Session namespaces without changing ordinary identities", async () => { + const reservedPfx = makeSession({ + sessionId: "pfx:foreign-scope:foreign-fingerprint", + request: { message: { model: "claude-sonnet-4-5" } }, + }); + const reservedSid = makeSession({ + sessionId: "sid:pfx:foreign-scope:foreign-fingerprint", + request: { message: { model: "claude-sonnet-4-5" } }, + }); + const otherKey = makeSession({ + sessionId: "pfx:foreign-scope:foreign-fingerprint", + authState: { key: { id: 6, providerGroup: "default" }, user: null }, + request: { message: { model: "claude-sonnet-4-5" } }, + }); + const ordinary = makeSession({ + sessionId: "physical-session-1", + request: { message: { model: "claude-sonnet-4-5" } }, + }); + + for (const session of [reservedPfx, reservedSid, otherKey, ordinary]) { + await ProxyProviderResolver.ensure(session); + } + + expect(reservedPfx._sessionIdentityMetadata.identity).toBe( + "sid:ce9895f7ae1c9727ce21d234b659482e" + ); + expect(reservedSid._sessionIdentityMetadata.identity).toBe( + "sid:c300a896dbf9af49bbe231ce1ed2767a" + ); + expect(otherKey._sessionIdentityMetadata.identity).toBe("sid:11d7b35b031557d5573098565a9ee514"); + expect( + new Set([ + reservedPfx._sessionIdentityMetadata.identity, + reservedSid._sessionIdentityMetadata.identity, + otherKey._sessionIdentityMetadata.identity, + ]) + ).toHaveLength(3); + expect(reservedPfx._sessionIdentityMetadata.identity).toHaveLength(36); + expect(ordinary._sessionIdentityMetadata.identity).toBe("physical-session-1"); + }); + + test.each(["pfx:", "sid:"])( + "keeps a 64-character %s physical Session identity inside the database limit", + async (prefix) => { + const session = makeSession({ + sessionId: `${prefix}${"x".repeat(64 - prefix.length)}`, + request: { message: { model: "claude-sonnet-4-5" } }, + }); + + await ProxyProviderResolver.ensure(session); + + expect(session._sessionIdentityMetadata.identity).toMatch(/^sid:[0-9a-f]{32}$/); + expect(session._sessionIdentityMetadata.identity.length).toBeLessThanOrEqual(64); + } + ); }); describe("metrics-only and endpoint policy gating", () => { diff --git a/tests/unit/proxy/provider-selector-affinity-priority.test.ts b/tests/unit/proxy/provider-selector-affinity-priority.test.ts index 5199567bd..8ec65846b 100644 --- a/tests/unit/proxy/provider-selector-affinity-priority.test.ts +++ b/tests/unit/proxy/provider-selector-affinity-priority.test.ts @@ -173,6 +173,9 @@ function makeSession(overrides: Record = {}): any { setGroupCostMultiplier: vi.fn(), getProvidersSnapshot: vi.fn(async () => [makeProvider(55)]), recordProviderSessionRef: vi.fn(), + setSessionIdentityMetadata: vi.fn((metadata: unknown) => { + session._sessionIdentityMetadata = metadata; + }), }; return Object.assign(session, overrides); } @@ -220,10 +223,13 @@ describe("ensure() nomination priority", () => { test("affinity hit wins over weighted random and records affinity_hit in the chain", async () => { storeMocks.lookup.mockResolvedValue({ - providerId: 42, - matchedFp: "deepfp", - matchedIndex: 0, - tier: "conversation", + generation: "0", + hint: { + providerId: 42, + matchedFp: "deepfp", + matchedIndex: 0, + tier: "conversation", + }, }); providerRepositoryMocks.findProviderById.mockResolvedValue(makeProvider(42)); @@ -261,10 +267,13 @@ describe("ensure() nomination priority", () => { test("affinity hit that fails hard validation falls back without nomination", async () => { storeMocks.lookup.mockResolvedValue({ - providerId: 42, - matchedFp: "deepfp", - matchedIndex: 0, - tier: "conversation", + generation: "0", + hint: { + providerId: 42, + matchedFp: "deepfp", + matchedIndex: 0, + tier: "conversation", + }, }); providerRepositoryMocks.findProviderById.mockResolvedValue( makeProvider(42, { isEnabled: false }) @@ -281,10 +290,13 @@ describe("ensure() nomination priority", () => { test("circuit-open affinity candidate is rejected by hard validation", async () => { storeMocks.lookup.mockResolvedValue({ - providerId: 42, - matchedFp: "deepfp", - matchedIndex: 0, - tier: "conversation", + generation: "0", + hint: { + providerId: 42, + matchedFp: "deepfp", + matchedIndex: 0, + tier: "conversation", + }, }); providerRepositoryMocks.findProviderById.mockResolvedValue(makeProvider(42)); circuitBreakerMocks.isCircuitOpen.mockImplementation(async (id: number) => id === 42); diff --git a/tests/unit/proxy/provider-selector-select-provider-by-type.test.ts b/tests/unit/proxy/provider-selector-select-provider-by-type.test.ts index b98a2d509..e0156d6bf 100644 --- a/tests/unit/proxy/provider-selector-select-provider-by-type.test.ts +++ b/tests/unit/proxy/provider-selector-select-provider-by-type.test.ts @@ -247,6 +247,7 @@ describe("ProxyProviderResolver.ensure - 分组倍率", () => { getProviderChain: vi.fn(() => []), getOriginalModel: vi.fn(() => "gpt-5.5"), recordProviderSessionRef: vi.fn(), + setSessionIdentityMetadata: vi.fn(), } as unknown as Parameters[0]; try { diff --git a/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts b/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts index 3bc0b6c23..8d2d8948f 100644 --- a/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts +++ b/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; const mocks = vi.hoisted(() => ({ @@ -331,7 +331,11 @@ function createStreamingResponse(params: { controller.close(); return; } - controller.enqueue(encoder.encode(`data: {"provider":"${params.label}"}\n\n`)); + controller.enqueue( + encoder.encode( + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"${params.label}"},"provider":"${params.label}"}\n\n` + ) + ); controller.close(); }, params.firstChunkDelayMs); }, @@ -435,6 +439,11 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { mocks.isWebsocketClientRequest.mockReturnValue(false); }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + test("Discovery actively probes an unknown binding capability before acquiring its lease", async () => { const provider = createProvider({ id: 1 }); const session = createSession(); @@ -2698,7 +2707,7 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { "doForward" ); doForward.mockResolvedValueOnce( - new Response('data: {"type":"message_stop"}\n\n', { + new Response('data: {"type":"content_block_delta","delta":{"text":"lease-conflict"}}\n\n', { status: 200, headers: { "content-type": "text/event-stream" }, }) @@ -2745,10 +2754,13 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { doForward .mockRejectedValueOnce(new UpstreamProxyError("initial provider failed", 500)) .mockResolvedValueOnce( - new Response('data: {"type":"message_stop"}\n\n', { - status: 200, - headers: { "content-type": "text/event-stream" }, - }) + new Response( + 'data: {"type":"content_block_delta","delta":{"text":"serial-fallback"}}\n\n', + { + status: 200, + headers: { "content-type": "text/event-stream" }, + } + ) ); const response = await ProxyForwarder.send(session); @@ -4058,7 +4070,7 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { "doForward" ); doForward.mockResolvedValueOnce( - new Response('data: {"type":"message_stop"}\n\n', { + new Response('data: {"type":"content_block_delta","delta":{"text":"websocket"}}\n\n', { status: 200, headers: { "content-type": "text/event-stream" }, }) diff --git a/tests/unit/proxy/proxy-handler-concurrency-ownership.test.ts b/tests/unit/proxy/proxy-handler-concurrency-ownership.test.ts index 9e3b5650e..f0085b2b7 100644 --- a/tests/unit/proxy/proxy-handler-concurrency-ownership.test.ts +++ b/tests/unit/proxy/proxy-handler-concurrency-ownership.test.ts @@ -9,10 +9,13 @@ type ProxySettingsFixture = { const boundary = vi.hoisted(() => ({ decrementConcurrentCount: vi.fn<(sessionId: string) => Promise>(), + decrementObservedConcurrentCount: vi.fn<(sessionId: string) => Promise>(), incrementConcurrentCount: vi.fn<(sessionId: string) => Promise>(), + incrementObservedConcurrentCount: vi.fn<(sessionId: string) => Promise>(), loadSettings: vi.fn<() => Promise>(), runGuards: vi.fn<(session: ProxySession) => Promise>(), send: vi.fn<(session: ProxySession) => Promise>(), + trackObservedSession: vi.fn<(sessionId: string) => Promise>(), })); vi.mock("@/lib/config", async (importOriginal) => ({ @@ -33,7 +36,10 @@ vi.mock("@/app/v1/_lib/proxy/forwarder", () => ({ vi.mock("@/lib/session-tracker", () => ({ SessionTracker: { decrementConcurrentCount: boundary.decrementConcurrentCount, + decrementObservedConcurrentCount: boundary.decrementObservedConcurrentCount, incrementConcurrentCount: boundary.incrementConcurrentCount, + incrementObservedConcurrentCount: boundary.incrementObservedConcurrentCount, + trackObservedSession: boundary.trackObservedSession, }, })); @@ -45,11 +51,13 @@ vi.mock("@/lib/proxy-status-tracker", () => ({ import { handleProxyRequest } from "@/app/v1/_lib/proxy-handler"; -function createContext(): Context { +function createContext( + message: Record = { model: "claude-test", messages: [] } +): Context { const request = new Request("http://localhost/v1/messages", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "claude-test", messages: [] }), + body: JSON.stringify(message), }); return new Context(request); } @@ -60,12 +68,18 @@ describe("handleProxyRequest concurrency ownership", () => { boundary.send.mockReset(); boundary.incrementConcurrentCount.mockReset(); boundary.decrementConcurrentCount.mockReset(); + boundary.incrementObservedConcurrentCount.mockReset(); + boundary.decrementObservedConcurrentCount.mockReset(); + boundary.trackObservedSession.mockReset(); boundary.loadSettings.mockResolvedValue({ enableHighConcurrencyMode: false, allowNonConversationEndpointProviderFallback: true, }); boundary.incrementConcurrentCount.mockResolvedValue(undefined); boundary.decrementConcurrentCount.mockResolvedValue(undefined); + boundary.incrementObservedConcurrentCount.mockResolvedValue(undefined); + boundary.decrementObservedConcurrentCount.mockResolvedValue(undefined); + boundary.trackObservedSession.mockResolvedValue(undefined); boundary.send.mockResolvedValue(new Response("unused", { status: 200 })); }); @@ -82,6 +96,55 @@ describe("handleProxyRequest concurrency ownership", () => { expect(boundary.incrementConcurrentCount).not.toHaveBeenCalled(); expect(boundary.decrementConcurrentCount).not.toHaveBeenCalled(); expect(boundary.send).not.toHaveBeenCalled(); + expect(boundary.trackObservedSession).toHaveBeenCalledWith("session-early"); + }); + + it.each(["completed", "live"] as const)( + "does not track a %s Replay early response as a new active Session", + async (mode) => { + boundary.runGuards.mockImplementation(async (session) => { + session.setSessionId("session-replay"); + return new Response("replayed", { + status: 200, + headers: { "x-cch-replay": mode }, + }); + }); + + const response = await handleProxyRequest(createContext()); + + expect(response.headers.get("x-cch-replay")).toBe(mode); + expect(boundary.trackObservedSession).not.toHaveBeenCalled(); + expect(boundary.send).not.toHaveBeenCalled(); + } + ); + + it("does not track a handled warmup early response as an active Session", async () => { + boundary.runGuards.mockImplementation(async (session) => { + session.setSessionId("session-warmup"); + return new Response("warmed", { status: 200 }); + }); + + const response = await handleProxyRequest( + createContext({ + model: "claude-test", + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: "Warmup", + cache_control: { type: "ephemeral" }, + }, + ], + }, + ], + }) + ); + + expect(response.status).toBe(200); + expect(boundary.trackObservedSession).not.toHaveBeenCalled(); + expect(boundary.send).not.toHaveBeenCalled(); }); it("releases exactly one concurrency count after acquiring it", async () => { diff --git a/tests/unit/proxy/proxy-handler-public-errors.test.ts b/tests/unit/proxy/proxy-handler-public-errors.test.ts index a0a504328..812dd7fe3 100644 --- a/tests/unit/proxy/proxy-handler-public-errors.test.ts +++ b/tests/unit/proxy/proxy-handler-public-errors.test.ts @@ -13,12 +13,15 @@ type ProxySettingsFixture = { const boundary = vi.hoisted(() => ({ decrementConcurrentCount: vi.fn<(sessionId: string) => Promise>(), + decrementObservedConcurrentCount: vi.fn<(identity: string) => Promise>(), emitProxyLangfuseTrace: vi.fn(), getErrorOverride: vi.fn<(error: Error) => Promise>(), incrementConcurrentCount: vi.fn<(sessionId: string) => Promise>(), + incrementObservedConcurrentCount: vi.fn<(identity: string) => Promise>(), loadSettings: vi.fn<() => Promise>(), runGuards: vi.fn<(session: ProxySession) => Promise>(), send: vi.fn<(session: ProxySession) => Promise>(), + trackObservedSession: vi.fn<(identity: string) => Promise>(), updateMessageRequestDetailsDurably: vi.fn(), })); @@ -58,8 +61,11 @@ vi.mock("@/repository/message", () => ({ vi.mock("@/lib/session-tracker", () => ({ SessionTracker: { decrementConcurrentCount: boundary.decrementConcurrentCount, + decrementObservedConcurrentCount: boundary.decrementObservedConcurrentCount, incrementConcurrentCount: boundary.incrementConcurrentCount, + incrementObservedConcurrentCount: boundary.incrementObservedConcurrentCount, refreshSession: vi.fn(), + trackObservedSession: boundary.trackObservedSession, }, })); @@ -96,12 +102,18 @@ describe("handleProxyRequest public error behavior", () => { boundary.send.mockReset(); boundary.incrementConcurrentCount.mockReset(); boundary.decrementConcurrentCount.mockReset(); + boundary.incrementObservedConcurrentCount.mockReset(); + boundary.decrementObservedConcurrentCount.mockReset(); + boundary.trackObservedSession.mockReset(); boundary.loadSettings.mockReset(); boundary.getErrorOverride.mockReset(); boundary.loadSettings.mockResolvedValue(settings); boundary.getErrorOverride.mockResolvedValue(null); boundary.incrementConcurrentCount.mockResolvedValue(undefined); boundary.decrementConcurrentCount.mockResolvedValue(undefined); + boundary.incrementObservedConcurrentCount.mockResolvedValue(undefined); + boundary.decrementObservedConcurrentCount.mockResolvedValue(undefined); + boundary.trackObservedSession.mockResolvedValue(undefined); }); it("translates a post-session forwarding error through the real error handler", async () => { diff --git a/tests/unit/proxy/proxy-handler-session-id-error.test.ts b/tests/unit/proxy/proxy-handler-session-id-error.test.ts index 25885f496..d22aa2d0f 100644 --- a/tests/unit/proxy/proxy-handler-session-id-error.test.ts +++ b/tests/unit/proxy/proxy-handler-session-id-error.test.ts @@ -25,6 +25,7 @@ const h = vi.hoisted(() => ({ h.session.rawCrossProviderFallbackEnabled = enabled; }, isRawCrossProviderFallbackEnabled: () => !!h.session.rawCrossProviderFallbackEnabled, + shouldTrackSessionObservability: () => false, recordForwardStart: () => {}, messageContext: null, provider: null, diff --git a/tests/unit/proxy/replay-guard.test.ts b/tests/unit/proxy/replay-guard.test.ts index 1bfde400a..f5b10efcf 100644 --- a/tests/unit/proxy/replay-guard.test.ts +++ b/tests/unit/proxy/replay-guard.test.ts @@ -34,7 +34,9 @@ const storeControl = vi.hoisted(() => ({ const dbControl = vi.hoisted(() => ({ rows: [] as Record[], insertError: null as Error | null, + nextId: 501, })); +const materializeReplayAuditFromSourceMock = vi.hoisted(() => vi.fn()); vi.mock("@/lib/logger", () => ({ logger: { @@ -78,14 +80,21 @@ vi.mock("@/app/v1/_lib/proxy/replay/replay-store", () => ({ vi.mock("@/drizzle/db", () => ({ db: { insert: () => ({ - values: async (values: Record) => { + values: (values: Record) => { if (dbControl.insertError) throw dbControl.insertError; dbControl.rows.push(values); + return { + returning: async () => [{ id: dbControl.nextId }], + }; }, }), }, })); +vi.mock("@/repository/message", () => ({ + materializeReplayAuditFromSource: materializeReplayAuditFromSourceMock, +})); + interface GuardSessionOverrides { message?: Record; headers?: Record; @@ -147,6 +156,9 @@ beforeEach(() => { envControl.liveDedup = true; dbControl.rows = []; dbControl.insertError = null; + dbControl.nextId = 501; + materializeReplayAuditFromSourceMock.mockReset(); + materializeReplayAuditFromSourceMock.mockResolvedValue(true); }); describe("ProxyReplayGuard:放行路径", () => { @@ -320,7 +332,7 @@ describe("ProxyReplayGuard:completed 全量重放", () => { it("Redis 热层 completed:全量重放响应头与 body,并写审计行", async () => { const identity = expectedIdentity(); storeControl.getMeta.mockResolvedValueOnce( - makeMeta(identity, { status: "completed", statusCode: 200 }) + makeMeta(identity, { status: "completed", statusCode: 200, messageRequestId: 101 }) ); storeControl.readChunks.mockResolvedValueOnce(["data: a\n\n", "data: b\n\n"]); const session = makeSession(); @@ -346,13 +358,16 @@ describe("ProxyReplayGuard:completed 全量重放", () => { sessionId: "sess-1", statusCode: 200, costUsd: "0", - blockedBy: "replay_serve", + blockedBy: null, + isReplay: true, + replaySourceRequestId: 101, endpoint: "/v1/messages", messagesCount: 1, userAgent: "vitest-agent", }); expect(String(dbControl.rows[0].blockedReason)).toContain("redis_completed"); expect(String(dbControl.rows[0].blockedReason)).toContain(identity.replayId.slice(0, 12)); + expect(materializeReplayAuditFromSourceMock).toHaveBeenCalledWith(501, 101); }); it("completed JSON 恢复语义 headers 和原始 body,不注入 SSE headers", async () => { @@ -394,6 +409,7 @@ describe("ProxyReplayGuard:completed 全量重放", () => { statusCode: 200, headersJson: { "content-type": "text/event-stream" }, payload: "data: pg\n\n", + sourceMessageRequestId: 102, }); const response = await ProxyReplayGuard.ensure(makeSession()); @@ -410,6 +426,7 @@ describe("ProxyReplayGuard:completed 全量重放", () => { statusCode: 201, headersJson: null, payload: "data: durable\n\n", + sourceMessageRequestId: 103, }); const response = await ProxyReplayGuard.ensure(makeSession()); @@ -417,7 +434,13 @@ describe("ProxyReplayGuard:completed 全量重放", () => { expect(response?.status).toBe(201); expect(response?.headers.get("content-type")).toBe("text/event-stream"); await expect(response?.text()).resolves.toBe("data: durable\n\n"); - expect(dbControl.rows[0]).toMatchObject({ statusCode: 201, blockedBy: "replay_serve" }); + expect(dbControl.rows[0]).toMatchObject({ + statusCode: 201, + blockedBy: null, + isReplay: true, + replaySourceRequestId: 103, + }); + expect(materializeReplayAuditFromSourceMock).toHaveBeenCalledWith(501, 103); }); it("审计行写失败不影响重放响应", async () => { @@ -445,7 +468,7 @@ describe("ProxyReplayGuard:completed 全量重放", () => { describe("ProxyReplayGuard:owning attach-live 跟尾", () => { it("先吐已缓存前缀,轮询跟尾直到 completed 收尾", async () => { const identity = expectedIdentity(); - const completed = makeMeta(identity, { status: "completed" }); + const completed = makeMeta(identity, { status: "completed", messageRequestId: 202 }); const metaSequence: ReplayMeta[] = [makeMeta(identity, { status: "owning" })]; storeControl.getMeta.mockImplementation(async () => metaSequence.shift() ?? completed); @@ -463,11 +486,13 @@ describe("ProxyReplayGuard:owning attach-live 跟尾", () => { expect(dbControl.rows).toHaveLength(1); expect(dbControl.rows[0]).toMatchObject({ - blockedBy: "replay_serve", + blockedBy: null, costUsd: "0", providerId: 0, + isReplay: true, }); expect(String(dbControl.rows[0].blockedReason)).toContain("attached_live"); + expect(materializeReplayAuditFromSourceMock).toHaveBeenCalledWith(501, 202); expect(storeControl.tryClaimOwner).not.toHaveBeenCalled(); }); @@ -480,7 +505,7 @@ describe("ProxyReplayGuard:owning attach-live 跟尾", () => { expect(response).not.toBeNull(); await expect(response?.text()).rejects.toThrow("replay attach lost redis connection"); - expect(dbControl.rows).toHaveLength(1); + expect(dbControl.rows).toHaveLength(0); }); it("attach 中源条目转为 aborted 时终止流", async () => { @@ -498,5 +523,29 @@ describe("ProxyReplayGuard:owning attach-live 跟尾", () => { expect(response).not.toBeNull(); await expect(response?.text()).rejects.toThrow("replay source aborted"); + expect(dbControl.rows).toHaveLength(0); + }); + + it("client cancel does not cancel live Replay audit completion", async () => { + const identity = expectedIdentity(); + const owning = makeMeta(identity, { status: "owning" }); + const completed = makeMeta(identity, { status: "completed", messageRequestId: 202 }); + let resolveTerminal: ((meta: ReplayMeta) => void) | null = null; + storeControl.getMeta.mockResolvedValueOnce(owning).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveTerminal = resolve; + }) + ); + storeControl.readChunks.mockResolvedValue([]); + + const response = await ProxyReplayGuard.ensure(makeSession()); + await response?.body?.cancel(); + + expect(resolveTerminal).not.toBeNull(); + resolveTerminal?.(completed); + await vi.waitFor(() => { + expect(materializeReplayAuditFromSourceMock).toHaveBeenCalledWith(501, 202); + }); }); }); diff --git a/tests/unit/repository/cache-hit-rate-alert-integer-cast.test.ts b/tests/unit/repository/cache-hit-rate-alert-integer-cast.test.ts index 29ba1ff1c..dc4c60ddf 100644 --- a/tests/unit/repository/cache-hit-rate-alert-integer-cast.test.ts +++ b/tests/unit/repository/cache-hit-rate-alert-integer-cast.test.ts @@ -13,11 +13,12 @@ function sqlToString(sqlObj: unknown): string { const visited = new Set(); const walk = (node: unknown): string => { - if (!node || visited.has(node)) return ""; + if (node === null || node === undefined || visited.has(node)) return ""; visited.add(node); if (typeof node === "string") return node; if (typeof node === "number") return String(node); + if (typeof node === "boolean") return String(node); if (typeof node === "object") { const anyNode = node as Record; @@ -50,6 +51,7 @@ function sqlToString(sqlObj: unknown): string { } let capturedSelectArgs: unknown = null; +const capturedCalls: Array<{ method: string; args: unknown[] }> = []; vi.mock("server-only", () => ({})); @@ -65,7 +67,10 @@ vi.mock("@/drizzle/db", () => { return new Proxy({}, handler); }; } - return (..._args: unknown[]) => new Proxy({}, handler); + return (...args: unknown[]) => { + capturedCalls.push({ method: String(prop), args }); + return new Proxy({}, handler); + }; }, }; return { @@ -91,6 +96,7 @@ vi.mock("@/drizzle/schema", () => ({ cacheCreation1hInputTokens: "cache_creation_1h_input_tokens", cacheTtlApplied: "cache_ttl_applied", swapCacheTtlApplied: "swap_cache_ttl_applied", + isReplay: "is_replay", }, providers: { id: "id", @@ -111,7 +117,13 @@ vi.mock("drizzle-orm/pg-core", async () => { const actual = await vi.importActual("drizzle-orm/pg-core"); return { ...(actual as object), - alias: (table: Record) => ({ ...table }), + alias: (table: Record) => + Object.fromEntries( + Object.entries(table).map(([key, value]) => [ + key, + typeof value === "string" ? `prev_${value}` : value, + ]) + ), }; }); @@ -128,6 +140,7 @@ vi.mock("@/lib/logger", () => ({ describe("cache-hit-rate-alert - integer cast regression", () => { beforeEach(() => { capturedSelectArgs = null; + capturedCalls.length = 0; }); it("ttlFallbackSecondsExpr CASE must cast THEN/ELSE values to ::integer", async () => { @@ -152,4 +165,24 @@ describe("cache-hit-rate-alert - integer cast regression", () => { const integerCastCount = (sqlStr.match(/::integer/g) || []).length; expect(integerCastCount).toBeGreaterThanOrEqual(2); }); + + it("excludes Replay rows from both the aggregate and predecessor eligibility join", async () => { + const { findProviderModelCacheHitRateMetricsForAlert } = await import( + "@/repository/cache-hit-rate-alert" + ); + + const end = new Date("2026-08-01T12:00:00.000Z"); + await findProviderModelCacheHitRateMetricsForAlert({ + start: new Date(end.getTime() - 3600_000), + end, + }); + + const whereCall = capturedCalls.find((call) => call.method === "where"); + const leftJoinCall = capturedCalls.find((call) => call.method === "leftJoin"); + const whereSql = sqlToString(whereCall?.args[0]).replace(/\s+/g, " ").toLowerCase(); + const joinSql = sqlToString(leftJoinCall?.args[1]).replace(/\s+/g, " ").toLowerCase(); + + expect(whereSql).toContain("is_replay = false"); + expect(joinSql).toContain("prev_is_replayfalse"); + }); }); diff --git a/tests/unit/repository/message-aggregate-session-stats.test.ts b/tests/unit/repository/message-aggregate-session-stats.test.ts index 6e7c1275c..e0a925df9 100644 --- a/tests/unit/repository/message-aggregate-session-stats.test.ts +++ b/tests/unit/repository/message-aggregate-session-stats.test.ts @@ -114,6 +114,17 @@ describe("message repository aggregateSessionStats", () => { expect(boundary.selectDistinct).not.toHaveBeenCalled(); }); + test("按 session identity 聚合并兼容 migration 前的 sessionId", async () => { + const stats = createDrizzleQuery([]); + boundary.select.mockReturnValueOnce(stats); + + await aggregateSessionStats("pfx:scope123:fp-deep"); + + const whereSql = sqlText(stats.trace.where); + expect(whereSql).toContain("session_identity"); + expect(whereSql).toContain("session_id"); + }); + test("returns populated statistics and preserves a single cache TTL", async () => { const queries = queuePopulatedAggregate(["1h"]); diff --git a/tests/unit/repository/message-replay-audit-terminal.test.ts b/tests/unit/repository/message-replay-audit-terminal.test.ts new file mode 100644 index 000000000..4a32536d6 --- /dev/null +++ b/tests/unit/repository/message-replay-audit-terminal.test.ts @@ -0,0 +1,72 @@ +import { CasingCache } from "drizzle-orm/casing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type SqlQuery = { + toQuery: (config: { + escapeName: (name: string) => string; + escapeParam: (index: number) => string; + escapeString: (value: string) => string; + casing: CasingCache; + paramStartIndex: { value: number }; + }) => { sql: string; params: unknown[] }; +}; + +const boundary = vi.hoisted(() => ({ + execute: vi.fn<(query: unknown) => Promise>(), + getWriterDb: vi.fn(() => ({ execute: vi.fn() })), +})); + +vi.mock("@/drizzle/db", () => ({ + db: { execute: boundary.execute }, + getMessageWriterDb: boundary.getWriterDb, +})); +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ MESSAGE_REQUEST_WRITE_MODE: "sync" }), + isDevelopment: () => false, +})); +vi.mock("@/lib/ledger-fallback", () => ({ isLedgerOnlyMode: vi.fn(async () => false) })); + +function renderSql(value: unknown) { + if (typeof value !== "object" || value === null || !("toQuery" in value)) { + throw new TypeError("Expected a Drizzle SQL query"); + } + + return (value as SqlQuery).toQuery({ + escapeName: (name) => `"${name}"`, + escapeParam: (index) => `$${index}`, + escapeString: (text) => `'${text}'`, + casing: new CasingCache(), + paramStartIndex: { value: 1 }, + }); +} + +describe("materializeReplayAuditFromSource", () => { + beforeEach(() => { + boundary.execute.mockReset(); + }); + + it("only materializes a successful terminal source while preserving zero-cost Replay", async () => { + boundary.execute.mockResolvedValueOnce([{ id: 501 }]); + const { materializeReplayAuditFromSource } = await import("@/repository/message"); + + await expect(materializeReplayAuditFromSource(501, 202)).resolves.toBe(true); + + const query = renderSql(boundary.execute.mock.calls[0]?.[0]); + expect(query.sql).toMatch(/UPDATE message_request AS replay/i); + expect(query.sql).toMatch(/source\.status_code\s*>=\s*200/i); + expect(query.sql).toMatch(/source\.status_code\s*<\s*400/i); + expect(query.sql).toMatch(/COALESCE\(source\.error_message, ''\)\s*=\s*''/i); + expect(query.sql).toMatch(/replay_source_request_id\s*=\s*source\.id/i); + expect(query.sql).toMatch(/is_replay\s*=\s*TRUE/i); + expect(query.sql).toMatch(/cost_usd\s*=\s*0/i); + expect(query.sql).toMatch(/cost_breakdown\s*=\s*NULL/i); + expect(query.params).toEqual([501, 202]); + }); + + it("reports no materialization when the source does not satisfy the terminal guard", async () => { + boundary.execute.mockResolvedValueOnce([]); + const { materializeReplayAuditFromSource } = await import("@/repository/message"); + + await expect(materializeReplayAuditFromSource(502, 203)).resolves.toBe(false); + }); +}); diff --git a/tests/unit/repository/message-session-readback.test.ts b/tests/unit/repository/message-session-readback.test.ts index 895a85363..e212744e1 100644 --- a/tests/unit/repository/message-session-readback.test.ts +++ b/tests/unit/repository/message-session-readback.test.ts @@ -235,7 +235,10 @@ describe("message session readback", () => { }); expect(result).toEqual({ - requests: [rows[0], { ...rows[1], sequence: 1 }], + requests: [ + { ...rows[0], sourceSessionId: "session-readback" }, + { ...rows[1], sourceSessionId: "session-readback", sequence: 1 }, + ], total: 2, }); expect(events).toEqual(["limit:2", "offset:1"]); diff --git a/tests/unit/repository/message-session-request-query.test.ts b/tests/unit/repository/message-session-request-query.test.ts index 0cea807d3..9021b6a18 100644 --- a/tests/unit/repository/message-session-request-query.test.ts +++ b/tests/unit/repository/message-session-request-query.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { messageRequest } from "@/drizzle/schema"; -import { findAdjacentRequestSequences, findRequestsBySessionId } from "@/repository/message"; +import { + findAdjacentRequestSequences, + findRequestsBySessionId, + findRequestsBySessionIdentity, + findSessionRequestLocator, +} from "@/repository/message"; import { createDrizzleQuery, sqlText } from "./message-query-test-support"; const boundary = vi.hoisted(() => { @@ -34,6 +39,7 @@ type RequestRow = Pick< | "inputTokens" | "outputTokens" | "errorMessage" + | "sessionId" > & { readonly sequence: MessageRow["requestSequence"] }; const firstCreatedAt = new Date("2026-05-04T10:00:00.000Z"); @@ -51,6 +57,7 @@ describe("message repository session request queries", () => { const rows = createDrizzleQuery([ { id: 31, + sessionId: "session-requests", sequence: null, model: "model-a", statusCode: 200, @@ -62,6 +69,7 @@ describe("message repository session request queries", () => { }, { id: 32, + sessionId: "session-requests", sequence: 3, model: "model-b", statusCode: 429, @@ -81,6 +89,7 @@ describe("message repository session request queries", () => { requests: [ { id: 31, + sourceSessionId: "session-requests", sequence: 1, model: "model-a", statusCode: 200, @@ -92,6 +101,7 @@ describe("message repository session request queries", () => { }, { id: 32, + sourceSessionId: "session-requests", sequence: 3, model: "model-b", statusCode: 429, @@ -117,6 +127,7 @@ describe("message repository session request queries", () => { const rows = createDrizzleQuery([ { id: 35, + sessionId: "session-desc", sequence: 5, model: null, statusCode: null, @@ -142,6 +153,79 @@ describe("message repository session request queries", () => { expect(rows.trace.offset).toEqual([2]); }); + test("preserves the physical source Session for requests aggregated by prefix identity", async () => { + const count = createDrizzleQuery([{ count: 2 }]); + const rows = createDrizzleQuery([ + { + id: 41, + sessionId: "physical-a", + sequence: 1, + model: "model-a", + statusCode: 200, + costUsd: "0", + createdAt: firstCreatedAt, + inputTokens: 10, + outputTokens: 5, + errorMessage: null, + }, + { + id: 42, + sessionId: "physical-b", + sequence: 1, + model: "model-b", + statusCode: 200, + costUsd: "0", + createdAt: secondCreatedAt, + inputTokens: 20, + outputTokens: 8, + errorMessage: null, + }, + ]); + boundary.select.mockReturnValueOnce(count).mockReturnValueOnce(rows); + + const result = await findRequestsBySessionIdentity("pfx:scope:fingerprint"); + + expect( + result.requests.map(({ sourceSessionId, sequence }) => ({ sourceSessionId, sequence })) + ).toEqual([ + { sourceSessionId: "physical-a", sequence: 1 }, + { sourceSessionId: "physical-b", sequence: 1 }, + ]); + expect(sqlText(rows.trace.where)).toContain("pfx:scope:fingerprint"); + }); + + test("resolves an exact request locator inside a prefix identity", async () => { + const locator = createDrizzleQuery([ + { + sourceSessionId: "physical-a", + requestSequence: 3, + identityKind: "prefix_affinity", + scopeTag: "scope", + fingerprint: "fingerprint", + }, + ]); + boundary.select.mockReturnValueOnce(locator); + + await expect( + findSessionRequestLocator("pfx:scope:fingerprint", { + sourceSessionId: "physical-a", + requestSequence: 3, + }) + ).resolves.toEqual({ + sourceSessionId: "physical-a", + requestSequence: 3, + identityKind: "prefix_affinity", + scopeTag: "scope", + fingerprint: "fingerprint", + }); + + const where = sqlText(locator.trace.where); + expect(where).toContain("pfx:scope:fingerprint"); + expect(where).toContain("physical-a"); + expect(where).toContain("= 3"); + expect(where).toContain("deleted_at"); + }); + test("returns adjacent neighbors using session-scoped sequence predicates", async () => { const previous = createDrizzleQuery([{ sequence: 4 }]); const next = createDrizzleQuery([{ sequence: 9 }]); diff --git a/tests/unit/repository/usage-logs-replay-filter.test.ts b/tests/unit/repository/usage-logs-replay-filter.test.ts new file mode 100644 index 000000000..3fb54a49a --- /dev/null +++ b/tests/unit/repository/usage-logs-replay-filter.test.ts @@ -0,0 +1,55 @@ +import type { SQL } from "drizzle-orm"; +import { CasingCache } from "drizzle-orm/casing"; +import { describe, expect, test } from "vitest"; + +import { buildUsageLogConditions } from "@/repository/_shared/usage-log-filters"; + +function sqlToString(sqlObj: SQL): string { + return sqlObj.toQuery({ + escapeName: (name: string) => `"${name}"`, + escapeParam: (num: number, _value: unknown) => `$${num}`, + escapeString: (value: string) => `'${value}'`, + casing: new CasingCache(), + paramStartIndex: { value: 1 }, + }).sql; +} + +function buildWhereSql(replayFilter?: "all" | "replay" | "non-replay"): string { + return buildUsageLogConditions({ replayFilter }) + .map((condition) => sqlToString(condition).toLowerCase()) + .join("\n"); +} + +function findReplayCondition(replayFilter: "replay" | "non-replay") { + const condition = buildUsageLogConditions({ replayFilter }).find((candidate) => + sqlToString(candidate).toLowerCase().includes("is_replay") + ); + expect(condition).toBeDefined(); + if (!condition) { + throw new Error("Expected Replay filter SQL condition to be present"); + } + return condition.toQuery({ + escapeName: (name: string) => `"${name}"`, + escapeParam: (num: number, _value: unknown) => `$${num}`, + escapeString: (value: string) => `'${value}'`, + casing: new CasingCache(), + paramStartIndex: { value: 1 }, + }); +} + +describe("Usage logs Replay filter", () => { + test("shows all requests by default and for the explicit all filter", () => { + expect(buildWhereSql()).not.toContain("is_replay"); + expect(buildWhereSql("all")).not.toContain("is_replay"); + }); + + test("filters Replay and non-Replay requests explicitly", () => { + const replayCondition = findReplayCondition("replay"); + const nonReplayCondition = findReplayCondition("non-replay"); + + expect(replayCondition.sql.toLowerCase()).toContain('"message_request"."is_replay" = $1'); + expect(replayCondition.params).toEqual([true]); + expect(nonReplayCondition.sql.toLowerCase()).toContain('"message_request"."is_replay" = $1'); + expect(nonReplayCondition.params).toEqual([false]); + }); +}); diff --git a/tests/unit/repository/usage-logs-replay-projection.test.ts b/tests/unit/repository/usage-logs-replay-projection.test.ts new file mode 100644 index 000000000..5e2093649 --- /dev/null +++ b/tests/unit/repository/usage-logs-replay-projection.test.ts @@ -0,0 +1,194 @@ +import type { SQL } from "drizzle-orm"; +import { CasingCache } from "drizzle-orm/casing"; +import { describe, expect, test, vi } from "vitest"; + +function createThenableQuery(result: T, whereArgs?: unknown[]) { + const query: any = Promise.resolve(result); + query.from = vi.fn(() => query); + query.innerJoin = vi.fn(() => query); + query.leftJoin = vi.fn(() => query); + query.orderBy = vi.fn(() => query); + query.limit = vi.fn(() => query); + query.offset = vi.fn(() => query); + query.where = vi.fn((condition: unknown) => { + whereArgs?.push(condition); + return query; + }); + return query; +} + +function compileSql(value: SQL) { + return value.toQuery({ + escapeName: (name) => `"${name}"`, + escapeParam: (num) => `$${num}`, + escapeString: (text) => `'${text}'`, + casing: new CasingCache(), + paramStartIndex: { value: 1 }, + }); +} + +function makeReplayRow(overrides: Record = {}) { + return { + id: 42, + createdAt: new Date("2026-08-01T00:00:00Z"), + createdAtRaw: "2026-08-01T00:00:00.000000Z", + sessionId: "session-1", + requestSequence: 1, + userName: "user", + keyName: "key", + providerName: "provider", + model: "claude-sonnet-4-5", + originalModel: "claude-sonnet-4-5", + actualResponseModel: "claude-sonnet-4-5", + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 10, + outputTokens: 20, + cacheCreationInputTokens: 30, + cacheReadInputTokens: 40, + cacheCreation5mInputTokens: 30, + cacheCreation1hInputTokens: 0, + cacheTtlApplied: "5m", + costUsd: "0", + costMultiplier: "1", + groupCostMultiplier: "1", + costBreakdown: null, + hedgeLosers: null, + durationMs: null, + tfftMs: null, + firstByteMs: null, + errorMessage: null, + providerChain: null, + routingTrace: null, + blockedBy: null, + blockedReason: null, + userAgent: null, + clientIp: null, + messagesCount: 1, + context1mApplied: false, + swapCacheTtlApplied: false, + specialSettings: null, + isReplay: true, + replaySourceRequestId: 7, + ...overrides, + }; +} + +describe("findUsageLogsBatch Replay projection", () => { + test("projects Replay provenance from message_request", async () => { + vi.resetModules(); + const selectMock = vi.fn(() => createThenableQuery([makeReplayRow()])); + + vi.doMock("@/drizzle/db", () => ({ db: { select: selectMock } })); + vi.doMock("@/lib/ledger-fallback", () => ({ + isLedgerOnlyMode: vi.fn(async () => false), + })); + + const { findUsageLogsBatch } = await import("@/repository/usage-logs"); + const result = await findUsageLogsBatch({}); + + expect(selectMock.mock.calls[0]?.[0]).toMatchObject({ + isReplay: expect.anything(), + replaySourceRequestId: expect.anything(), + }); + expect(result.logs[0]).toMatchObject({ isReplay: true, replaySourceRequestId: 7 }); + }); + + test("projects Replay provenance from usage_ledger fallback", async () => { + vi.resetModules(); + const ledgerRow = makeReplayRow({ + requestSequence: undefined, + userId: 1, + key: "sk-test", + }); + const selectMock = vi + .fn() + .mockImplementationOnce(() => createThenableQuery([])) + .mockImplementationOnce(() => createThenableQuery([ledgerRow])); + + vi.doMock("@/drizzle/db", () => ({ db: { select: selectMock } })); + vi.doMock("@/lib/ledger-fallback", () => ({ + isLedgerOnlyMode: vi.fn(async () => true), + })); + + const { findUsageLogsBatch } = await import("@/repository/usage-logs"); + const result = await findUsageLogsBatch({ replayFilter: "replay" }); + + expect(selectMock.mock.calls[1]?.[0]).toMatchObject({ + isReplay: expect.anything(), + replaySourceRequestId: expect.anything(), + }); + expect(result.logs[0]).toMatchObject({ isReplay: true, replaySourceRequestId: 7 }); + }); +}); + +describe("findUsageLogsStats Replay audit semantics", () => { + test("includes Replay token usage in Replay-only stats while keeping persisted cost at zero", async () => { + vi.resetModules(); + const whereArgs: unknown[] = []; + const selectMock = vi.fn(() => + createThenableQuery( + [ + { + totalRequests: 1, + totalCost: "0", + totalInputTokens: 10, + totalOutputTokens: 20, + totalCacheCreationTokens: 30, + totalCacheReadTokens: 40, + totalCacheCreation5mTokens: 30, + totalCacheCreation1hTokens: 0, + }, + ], + whereArgs + ) + ); + + vi.doMock("@/drizzle/db", () => ({ db: { select: selectMock } })); + vi.doMock("@/lib/ledger-fallback", () => ({ + isLedgerOnlyMode: vi.fn(async () => false), + })); + + const { findUsageLogsStats } = await import("@/repository/usage-logs"); + const result = await findUsageLogsStats({ replayFilter: "replay" }); + + expect(result).toMatchObject({ totalRequests: 1, totalCost: 0, totalTokens: 100 }); + const query = compileSql(whereArgs[0] as SQL); + expect(query.sql.toLowerCase()).toContain('"usage_ledger"."blocked_by" is null'); + expect(query.sql.toLowerCase()).toContain('"usage_ledger"."is_replay" ='); + expect(query.params).toContain(true); + }); + + test("does not silently exclude Replay rows from all-request stats", async () => { + vi.resetModules(); + const whereArgs: unknown[] = []; + const selectMock = vi.fn(() => + createThenableQuery( + [ + { + totalRequests: 0, + totalCost: "0", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreation5mTokens: 0, + totalCacheCreation1hTokens: 0, + }, + ], + whereArgs + ) + ); + + vi.doMock("@/drizzle/db", () => ({ db: { select: selectMock } })); + vi.doMock("@/lib/ledger-fallback", () => ({ + isLedgerOnlyMode: vi.fn(async () => false), + })); + + const { findUsageLogsStats } = await import("@/repository/usage-logs"); + await findUsageLogsStats({ replayFilter: "all" }); + + const query = compileSql(whereArgs[0] as SQL); + expect(query.sql.toLowerCase()).not.toContain('"usage_ledger"."is_replay"'); + }); +}); diff --git a/tests/unit/repository/warmup-stats-exclusion.test.ts b/tests/unit/repository/warmup-stats-exclusion.test.ts index 799aa7215..1e270c629 100644 --- a/tests/unit/repository/warmup-stats-exclusion.test.ts +++ b/tests/unit/repository/warmup-stats-exclusion.test.ts @@ -1,4 +1,17 @@ +import type { SQL } from "drizzle-orm"; +import { CasingCache } from "drizzle-orm/casing"; import { describe, expect, test, vi } from "vitest"; +import { LEDGER_BILLING_CONDITION } from "@/repository/_shared/ledger-conditions"; + +function compileSql(sqlObj: SQL): string { + return sqlObj.toQuery({ + escapeName: (name: string) => `"${name}"`, + escapeParam: (num: number, _value: unknown) => `$${num}`, + escapeString: (value: string) => `'${value}'`, + casing: new CasingCache(), + paramStartIndex: { value: 1 }, + }).sql; +} function sqlToString(sqlObj: unknown): string { const visited = new Set(); @@ -53,6 +66,12 @@ function createThenableQuery(result: T, whereArgs?: unknown[]) { } describe("Warmup 请求:不计入任何聚合统计", () => { + test("ledger billing condition excludes blocked and Replay audit rows", () => { + const condition = compileSql(LEDGER_BILLING_CONDITION).toLowerCase(); + expect(condition).toContain('"usage_ledger"."blocked_by" is null'); + expect(condition).toContain('"usage_ledger"."is_replay" = false'); + }); + test("usage logs:分页 total 包含 warmup,但 summary.totalRequests 排除 warmup", async () => { vi.resetModules(); @@ -107,7 +126,7 @@ describe("Warmup 请求:不计入任何聚合统计", () => { expect(firstSelect).toEqual(expect.objectContaining({ totalRequests: expect.anything() })); }); - test("usage logs stats:WHERE 条件应包含 blocked_by IS NULL 过滤", async () => { + test("usage logs stats:默认审计口径仅排除 blocked 请求", async () => { vi.resetModules(); const whereArgs: unknown[] = []; @@ -148,7 +167,7 @@ describe("Warmup 请求:不计入任何聚合统计", () => { expect(whereSql.toLowerCase()).toContain("is null"); }); - test("provider statistics:SQL 应使用 blocked_by IS NULL 计费过滤", async () => { + test("provider statistics:SQL 应排除 blocked 与 Replay 请求", async () => { vi.resetModules(); const executeMock = vi.fn(async () => [ @@ -198,5 +217,7 @@ describe("Warmup 请求:不计入任何聚合统计", () => { const querySql = sqlToString(queryArg); expect(querySql.toLowerCase()).toContain("blocked_by"); expect(querySql.toLowerCase()).toContain("is null"); + expect(querySql.toLowerCase()).toContain("is_replay"); + expect(querySql.toLowerCase()).toContain("false"); }); }); diff --git a/tests/unit/usage-ledger/backfill.test.ts b/tests/unit/usage-ledger/backfill.test.ts index e78efc6e7..2269f0fc1 100644 --- a/tests/unit/usage-ledger/backfill.test.ts +++ b/tests/unit/usage-ledger/backfill.test.ts @@ -47,6 +47,38 @@ describe("backfillUsageLedger", () => { expect(serviceSource).toContain("fn_compute_message_request_success_rate_outcome"); }); + it("repairs Session identity and Replay provenance in existing ledger rows", () => { + const projectionFields = [ + "session_identity", + "session_identity_kind", + "affinity_scope_tag", + "affinity_fingerprint", + "affinity_fingerprint_chain", + "is_replay", + "replay_source_request_id", + ]; + + for (const field of projectionFields) { + expect(serviceSource).toContain(`mr.${field}`); + expect(serviceSource).toContain(`${field} = EXCLUDED.${field}`); + expect(serviceSource).toContain(`ul.${field} IS DISTINCT FROM mr.${field}`); + } + }); + + it("forces Replay rows to zero cost during backfill", () => { + expect(serviceSource).toContain("CASE WHEN mr.is_replay THEN 0 ELSE mr.cost_usd END"); + expect(serviceSource).toContain("mr.is_replay AND ul.cost_usd IS DISTINCT FROM 0"); + }); + + it("keeps the recovery projection aligned with the trigger", () => { + expect(serviceSource).toContain("mr.group_cost_multiplier"); + expect(serviceSource).toContain("mr.client_ip"); + expect(serviceSource).toContain("mr.status_code IS NULL OR mr.status_code < 400"); + expect(serviceSource).toContain("group_cost_multiplier = EXCLUDED.group_cost_multiplier"); + expect(serviceSource).toContain("client_ip = EXCLUDED.client_ip"); + expect(serviceSource).toContain("is_success = EXCLUDED.is_success"); + }); + it("rejects before opening a transaction when already aborted", async () => { const controller = new AbortController(); controller.abort(); diff --git a/tests/unit/usage-ledger/trigger.test.ts b/tests/unit/usage-ledger/trigger.test.ts index a8c8d804f..000dca2c8 100644 --- a/tests/unit/usage-ledger/trigger.test.ts +++ b/tests/unit/usage-ledger/trigger.test.ts @@ -42,4 +42,27 @@ describe("fn_upsert_usage_ledger trigger SQL", () => { expect(sql).toContain("AFTER INSERT OR UPDATE OF"); expect(sql).not.toMatch(/UPDATE OF[\s\S]*routing_trace[\s\S]*ON message_request/); }); + + it("projects Session identity and Replay provenance through insert and upsert", () => { + const projectionFields = [ + "session_identity", + "session_identity_kind", + "affinity_scope_tag", + "affinity_fingerprint", + "affinity_fingerprint_chain", + "is_replay", + "replay_source_request_id", + ]; + + for (const field of projectionFields) { + expect(sql).toMatch(new RegExp(`INSERT INTO usage_ledger \\([\\s\\S]*${field}`)); + expect(sql).toContain(`NEW.${field}`); + expect(sql).toContain(`${field} = EXCLUDED.${field}`); + expect(sql).toMatch(new RegExp(`UPDATE OF[\\s\\S]*${field}[\\s\\S]*ON message_request`)); + } + }); + + it("forces Replay rows to zero cost at the ledger projection boundary", () => { + expect(sql).toContain("CASE WHEN NEW.is_replay THEN 0 ELSE NEW.cost_usd END"); + }); }); From 88cf76db09045899cfbaa3581056650129120374 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sat, 1 Aug 2026 21:24:53 +0800 Subject: [PATCH 2/4] fix: align affinity sessions and replay audits --- drizzle/0116_gigantic_zombie.sql | 61 ---- package.json | 2 +- scripts/migrate.ts | 3 + src/actions/active-sessions.ts | 133 ++++--- .../_components/error-details-dialog.test.tsx | 61 +++- .../components/LogicTraceTab.tsx | 7 +- .../components/MetadataTab.tsx | 19 +- .../components/SummaryTab.tsx | 19 +- .../error-details-dialog/index.tsx | 7 +- .../_components/error-details-dialog/types.ts | 2 + .../_components/usage-logs-table.test.tsx | 30 +- .../logs/_components/usage-logs-table.tsx | 3 + .../usage-logs-view-virtualized.test.tsx | 13 + .../usage-logs-view-virtualized.tsx | 5 +- .../virtualized-logs-table.test.tsx | 21 +- .../_components/virtualized-logs-table.tsx | 1 + src/app/v1/_lib/proxy-handler.ts | 10 +- .../_lib/proxy/affinity/affinity-recorder.ts | 18 +- .../v1/_lib/proxy/affinity/affinity-store.ts | 273 ++++++++++---- src/app/v1/_lib/proxy/provider-selector.ts | 48 ++- src/app/v1/_lib/proxy/replay/replay-guard.ts | 6 + src/app/v1/_lib/proxy/response-handler.ts | 11 +- src/app/v1/_lib/proxy/session-guard.ts | 26 +- src/app/v1/_lib/proxy/session.ts | 4 +- src/lib/cache/session-cache.ts | 2 + src/lib/config/system-settings-cache.ts | 17 +- src/lib/ledger-backfill/service.ts | 31 +- src/lib/migrate.ts | 28 +- .../session-replay-index-preflight.ts | 31 +- src/lib/rate-limit/service.ts | 81 +++++ src/lib/redis/lua-scripts.ts | 23 ++ src/lib/session-manager.ts | 11 +- src/repository/_shared/ledger-conditions.ts | 6 + src/repository/_shared/transformers.test.ts | 2 + src/repository/_shared/transformers.ts | 2 + src/repository/activity-stream.ts | 8 +- src/repository/message.ts | 196 ++++++++-- src/repository/usage-logs.ts | 45 ++- src/types/message.ts | 4 + .../active-sessions-detail-snapshots.test.ts | 13 + .../active-sessions-termination.test.ts | 40 +- .../drizzle/session-replay-migration.test.ts | 22 +- .../unit/lib/cache-effectiveness-gate.test.ts | 1 + .../lib/config/system-settings-cache.test.ts | 35 ++ .../provider-session-release.test.ts | 59 +++ .../session-replay-index-preflight.test.ts | 93 ++++- tests/unit/proxy/affinity-recorder.test.ts | 43 ++- tests/unit/proxy/affinity-store.test.ts | 344 +++++++++++++----- ...r-selector-affinity-ignore-session.test.ts | 6 +- ...rovider-selector-affinity-priority.test.ts | 36 +- ...roxy-handler-concurrency-ownership.test.ts | 50 +++ tests/unit/proxy/replay-guard.test.ts | 30 +- .../response-handler-lease-decrement.test.ts | 24 ++ .../session-guard-warmup-intercept.test.ts | 68 ++++ .../repository/activity-stream-replay.test.ts | 84 +++++ ...e-aggregate-multiple-session-stats.test.ts | 41 +++ .../message-aggregate-session-stats.test.ts | 35 +- .../message-public-readback.test.ts | 28 +- .../message-replay-audit-terminal.test.ts | 7 + .../message-session-readback.test.ts | 28 +- .../message-session-request-query.test.ts | 24 ++ .../usage-logs-actual-response-model.test.ts | 6 + tests/unit/usage-ledger/backfill.test.ts | 50 ++- 63 files changed, 1951 insertions(+), 486 deletions(-) create mode 100644 scripts/migrate.ts create mode 100644 tests/unit/repository/activity-stream-replay.test.ts diff --git a/drizzle/0116_gigantic_zombie.sql b/drizzle/0116_gigantic_zombie.sql index e0423d297..4fdc1189c 100644 --- a/drizzle/0116_gigantic_zombie.sql +++ b/drizzle/0116_gigantic_zombie.sql @@ -27,67 +27,6 @@ SET is_replay = true, cost_usd = 0 WHERE blocked_by = 'replay_serve';--> statement-breakpoint --- AUTO_MIGRATE prebuilds these indexes concurrently outside the Drizzle transaction. --- The marker makes this transactional fallback a no-op after a successful preflight. -DO $$ -DECLARE - v_marker CONSTANT text := 'cch:migration:0116:session-replay-index:v1'; -BEGIN - IF obj_description(to_regclass('idx_message_request_session_identity_created_at'), 'pg_class') IS DISTINCT FROM v_marker THEN - DROP INDEX IF EXISTS "idx_message_request_session_identity_created_at"; - CREATE INDEX IF NOT EXISTS "idx_message_request_session_identity_created_at" ON "message_request" USING btree (COALESCE("session_identity", "session_id"),"created_at" DESC NULLS LAST) WHERE "message_request"."deleted_at" IS NULL; - COMMENT ON INDEX "idx_message_request_session_identity_created_at" IS 'cch:migration:0116:session-replay-index:v1'; - END IF; - - IF obj_description(to_regclass('idx_usage_ledger_session_identity_created_at'), 'pg_class') IS DISTINCT FROM v_marker THEN - DROP INDEX IF EXISTS "idx_usage_ledger_session_identity_created_at"; - CREATE INDEX IF NOT EXISTS "idx_usage_ledger_session_identity_created_at" ON "usage_ledger" USING btree (COALESCE("session_identity", "session_id"),"created_at" DESC NULLS LAST) WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false; - COMMENT ON INDEX "idx_usage_ledger_session_identity_created_at" IS 'cch:migration:0116:session-replay-index:v1'; - END IF; - - IF obj_description(to_regclass('idx_usage_ledger_user_created_at'), 'pg_class') IS DISTINCT FROM v_marker THEN - DROP INDEX IF EXISTS "idx_usage_ledger_user_created_at"; - CREATE INDEX IF NOT EXISTS "idx_usage_ledger_user_created_at" ON "usage_ledger" USING btree ("user_id","created_at") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false; - COMMENT ON INDEX "idx_usage_ledger_user_created_at" IS 'cch:migration:0116:session-replay-index:v1'; - END IF; - - IF obj_description(to_regclass('idx_usage_ledger_key_created_at'), 'pg_class') IS DISTINCT FROM v_marker THEN - DROP INDEX IF EXISTS "idx_usage_ledger_key_created_at"; - CREATE INDEX IF NOT EXISTS "idx_usage_ledger_key_created_at" ON "usage_ledger" USING btree ("key","created_at") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false; - COMMENT ON INDEX "idx_usage_ledger_key_created_at" IS 'cch:migration:0116:session-replay-index:v1'; - END IF; - - IF obj_description(to_regclass('idx_usage_ledger_provider_created_at'), 'pg_class') IS DISTINCT FROM v_marker THEN - DROP INDEX IF EXISTS "idx_usage_ledger_provider_created_at"; - CREATE INDEX IF NOT EXISTS "idx_usage_ledger_provider_created_at" ON "usage_ledger" USING btree ("final_provider_id","created_at") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false; - COMMENT ON INDEX "idx_usage_ledger_provider_created_at" IS 'cch:migration:0116:session-replay-index:v1'; - END IF; - - IF obj_description(to_regclass('idx_usage_ledger_key_cost'), 'pg_class') IS DISTINCT FROM v_marker THEN - DROP INDEX IF EXISTS "idx_usage_ledger_key_cost"; - CREATE INDEX IF NOT EXISTS "idx_usage_ledger_key_cost" ON "usage_ledger" USING btree ("key","created_at","cost_usd","endpoint") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false; - COMMENT ON INDEX "idx_usage_ledger_key_cost" IS 'cch:migration:0116:session-replay-index:v1'; - END IF; - - IF obj_description(to_regclass('idx_usage_ledger_user_cost_cover'), 'pg_class') IS DISTINCT FROM v_marker THEN - DROP INDEX IF EXISTS "idx_usage_ledger_user_cost_cover"; - CREATE INDEX IF NOT EXISTS "idx_usage_ledger_user_cost_cover" ON "usage_ledger" USING btree ("user_id","created_at","cost_usd","endpoint") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false; - COMMENT ON INDEX "idx_usage_ledger_user_cost_cover" IS 'cch:migration:0116:session-replay-index:v1'; - END IF; - - IF obj_description(to_regclass('idx_usage_ledger_provider_cost_cover'), 'pg_class') IS DISTINCT FROM v_marker THEN - DROP INDEX IF EXISTS "idx_usage_ledger_provider_cost_cover"; - CREATE INDEX IF NOT EXISTS "idx_usage_ledger_provider_cost_cover" ON "usage_ledger" USING btree ("final_provider_id","created_at","cost_usd","endpoint") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false; - COMMENT ON INDEX "idx_usage_ledger_provider_cost_cover" IS 'cch:migration:0116:session-replay-index:v1'; - END IF; - - IF obj_description(to_regclass('idx_usage_ledger_key_created_at_desc_cover'), 'pg_class') IS DISTINCT FROM v_marker THEN - DROP INDEX IF EXISTS "idx_usage_ledger_key_created_at_desc_cover"; - CREATE INDEX IF NOT EXISTS "idx_usage_ledger_key_created_at_desc_cover" ON "usage_ledger" USING btree ("key","created_at" DESC NULLS LAST,"final_provider_id") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false; - COMMENT ON INDEX "idx_usage_ledger_key_created_at_desc_cover" IS 'cch:migration:0116:session-replay-index:v1'; - END IF; -END $$;--> statement-breakpoint - -- Existing ledger rows must receive the same identity and Replay provenance as their source request. -- Replay cost is normalized at this projection boundary as an additional accounting safeguard. UPDATE usage_ledger AS ul diff --git a/package.json b/package.json index bc60d55ca..8b5b3aa3a 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "openapi:lint": "bun scripts/lint-openapi.ts", "cui": "npx cui-server --host 0.0.0.0 --port 30000 --token a7564bc8882aa9a2d25d8b4ea6ea1e2e", "db:generate": "drizzle-kit generate && node scripts/validate-migrations.js", - "db:migrate": "drizzle-kit migrate", + "db:migrate": "bun --conditions=react-server scripts/migrate.ts", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", "validate:migrations": "node scripts/validate-migrations.js", diff --git a/scripts/migrate.ts b/scripts/migrate.ts new file mode 100644 index 000000000..08b8d8997 --- /dev/null +++ b/scripts/migrate.ts @@ -0,0 +1,3 @@ +import { runMigrations } from "@/lib/migrate"; + +await runMigrations(); diff --git a/src/actions/active-sessions.ts b/src/actions/active-sessions.ts index f6ff986ff..15b708a58 100644 --- a/src/actions/active-sessions.ts +++ b/src/actions/active-sessions.ts @@ -10,6 +10,7 @@ import { import { logger } from "@/lib/logger"; import { extractAfterRequestMessages, isSessionMessages } from "@/lib/session-detail-snapshots"; import { resolveSessionRequestLocator } from "@/lib/session-request-locator"; +import { normalizeRequestSequence } from "@/lib/utils/request-sequence"; import { buildUnifiedSpecialSettings } from "@/lib/utils/special-settings"; import { type ActiveSessionInfo, @@ -22,14 +23,56 @@ import type { SpecialSetting } from "@/types/special-settings"; import { summarizeTerminateSessionsBatch } from "./active-sessions-utils"; import type { ActionResult } from "./types"; -function isPrefixAffinityIdentity(identity: string): boolean { - return identity.startsWith("pfx:"); -} +type ResolvedSessionIdentity = NonNullable< + Awaited> +>; + +async function terminateResolvedSessionIdentity( + identity: string, + resolution: ResolvedSessionIdentity | null +): Promise { + const { SessionManager } = await import("@/lib/session-manager"); + const { SessionTracker } = await import("@/lib/session-tracker"); + + if ( + resolution?.identityKind !== "prefix_affinity" || + !resolution.scopeTag || + !resolution.fingerprint + ) { + const terminated = await SessionManager.terminateSession( + resolution?.sourceSessionId ?? identity + ); + if (terminated) { + await SessionTracker.terminateObservedSession(identity); + } + return terminated; + } -function getSessionFingerprint(identity: string): string | null { - if (!isPrefixAffinityIdentity(identity)) return null; - const fingerprint = identity.split(":").at(-1); - return fingerprint || null; + const { getAffinityStore } = await import("@/app/v1/_lib/proxy/affinity/affinity-store"); + const invalidated = await getAffinityStore().invalidate( + resolution.scopeTag, + resolution.fingerprint, + [...new Set([resolution.fingerprint, ...resolution.fingerprints])] + ); + if (!invalidated) return false; + + const { listPhysicalSessionSourcesForIdentity } = await import("@/repository/message"); + const physicalSources = await listPhysicalSessionSourcesForIdentity(identity); + + for (const source of physicalSources) { + if (source.providerIds.length === 0) continue; + if ( + !(await SessionManager.terminateSession(source.sessionId, source.providerIds, source.keyId)) + ) { + logger.debug("[ActiveSessions] Physical Session state already absent or superseded", { + identity, + sourceSessionId: source.sessionId, + }); + } + } + + await SessionTracker.terminateObservedSession(identity); + return true; } function normalizeRequestSnapshot( @@ -196,10 +239,8 @@ export async function getActiveSessions(): Promise !isPrefixAffinityIdentity(id)), - ]) - ); + const allSessionIds = Array.from(new Set([...observedSessionIds, ...storedSessionIds])); if (allSessionIds.length === 0) { return { @@ -477,10 +509,8 @@ export async function getAllSessions( const lastRequestTime = s.lastRequestAt ? new Date(s.lastRequestAt).getTime() : 0; const sessionInfo: ActiveSessionInfo = { sessionId: s.sessionId, - sessionIdentityKind: isPrefixAffinityIdentity(s.sessionId) - ? "prefix_affinity" - : "session_id", - sessionFingerprint: getSessionFingerprint(s.sessionId), + sessionIdentityKind: s.sessionIdentityKind, + sessionFingerprint: s.sessionFingerprint, userName: s.userName, userId: s.userId, keyId: s.keyId, @@ -684,8 +714,8 @@ export async function hasSessionMessages( const { SessionManager } = await import("@/lib/session-manager"); const sourceSessionId = locatorResult.locator.sourceSessionId; - // 如果指定了序号,检查特定请求 - if (requestSequence !== undefined) { + // 只有有效的显式序号才检查特定请求;非法值按未指定序号处理。 + if (normalizeRequestSequence(requestSequence) !== null) { const messages = await SessionManager.getSessionMessages( sourceSessionId, locatorResult.locator.requestSequence @@ -1124,27 +1154,7 @@ export async function terminateActiveSession(sessionId: string): Promise ({ - hasSessionMessages: (...args: [string, number | undefined]) => hasSessionMessagesMock(...args), +vi.mock("@/lib/api-client/v1/actions/active-sessions", () => ({ + hasSessionMessages: (...args: [string, number | undefined, string | undefined]) => + hasSessionMessagesMock(...args), })); const getSessionOriginChainMock = vi.fn(); -vi.mock("@/actions/session-origin-chain", () => ({ - getSessionOriginChain: (...args: [string]) => getSessionOriginChainMock(...args), +vi.mock("@/lib/api-client/v1/actions/session-origin-chain", () => ({ + getSessionOriginChain: (...args: [string, number | undefined, string | undefined]) => + getSessionOriginChainMock(...args), })); const useRealProviderTimelineMock = vi.fn(() => false); @@ -429,6 +431,51 @@ function click(element: Element | null) { } describe("error-details-dialog layout", () => { + test("uses the physical source when checking messages for a prefix Session request", async () => { + const { unmount } = renderClientWithIntl( + + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(hasSessionMessagesMock).toHaveBeenCalledWith("pfx:scope:root", 3, "physical-a"); + unmount(); + }); + + test("includes the physical source in the Session detail link", async () => { + hasSessionMessagesMock.mockResolvedValue({ ok: true, data: true }); + const { container, unmount } = renderClientWithIntl( + + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container.querySelector('a[href*="sourceSessionId=physical-a"]')).toBeTruthy(); + expect(container.querySelector('a[href*="seq=3"]')).toBeTruthy(); + unmount(); + }); + test("marks Replay requests and shows their source request", () => { const html = renderWithIntl( { externalOpen statusCode={200} errorMessage={null} - sessionId={"sess-origin-3"} + sessionId={"pfx:scope:root"} + sourceSessionId="physical-origin" + requestSequence={3} providerChain={ [ { @@ -2251,7 +2300,7 @@ describe("error-details-dialog origin decision chain", () => { await Promise.resolve(); }); - expect(getSessionOriginChainMock).toHaveBeenCalledWith("sess-origin-3"); + expect(getSessionOriginChainMock).toHaveBeenCalledWith("pfx:scope:root", 3, "physical-origin"); expect(getSessionOriginChainMock).toHaveBeenCalledTimes(1); expect(container.textContent).toContain("Original decision record unavailable"); unmount(); diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx index 3d3d3a031..0479ec22e 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx @@ -73,6 +73,7 @@ export function LogicTraceTab({ providerChain, routingTrace, sessionId, + sourceSessionId, blockedBy, blockedReason, isReplay, @@ -480,7 +481,11 @@ export function LogicTraceTab({ setOriginOpen(open); if (open && originChain === undefined && !originLoading) { setOriginLoading(true); - getSessionOriginChain(sessionId) + getSessionOriginChain( + sessionId, + requestSequence ?? undefined, + sourceSessionId ?? undefined + ) .then((result) => { setOriginChain(result.ok ? result.data : null); }) diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/MetadataTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/MetadataTab.tsx index bca50207a..6a6294d27 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/MetadataTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/MetadataTab.tsx @@ -28,6 +28,7 @@ import type { MetadataTabProps } from "../types"; export function MetadataTab({ sessionId, + sourceSessionId, requestSequence, userAgent, endpoint, @@ -48,6 +49,16 @@ export function MetadataTab({ checkingMessages, }: MetadataTabProps) { const t = useTranslations("dashboard.logs.details"); + const sessionRequestParams = new URLSearchParams(); + if (requestSequence != null) { + sessionRequestParams.set("seq", String(requestSequence)); + } + if (sourceSessionId) { + sessionRequestParams.set("sourceSessionId", sourceSessionId); + } + const sessionMessagesHref = sessionId + ? `/dashboard/sessions/${sessionId}/messages${sessionRequestParams.size > 0 ? `?${sessionRequestParams.toString()}` : ""}` + : ""; const tChain = useTranslations("provider-chain"); const [timelineCopied, setTimelineCopied] = useState(false); @@ -104,13 +115,7 @@ export function MetadataTab({
{hasMessages && !checkingMessages && ( - +
{hasMessages && !checkingMessages && ( - + @@ -182,6 +182,8 @@ function buildDetailsData( sessionStats: unknown | null; currentSourceSessionId: string | null; currentSequence: number | null; + prevRequest: { requestId: number; sourceSessionId: string; requestSequence: number } | null; + nextRequest: { requestId: number; sourceSessionId: string; requestSequence: number } | null; prevSequence: number | null; nextSequence: number | null; }> = {} @@ -203,6 +205,8 @@ function buildDetailsData( sessionStats: null, currentSourceSessionId: "physical-current", currentSequence: 7, + prevRequest: null, + nextRequest: null, prevSequence: null, nextSequence: null, ...overrides, @@ -344,6 +348,8 @@ describe("SessionMessagesClient (request export actions)", () => { cacheTtlApplied: "mixed", totalCostUsd: "0.123456", }, + prevRequest: { requestId: 206, sourceSessionId: "physical-prev", requestSequence: 6 }, + nextRequest: { requestId: 208, sourceSessionId: "physical-next", requestSequence: 8 }, prevSequence: 6, nextSequence: 8, }), @@ -365,10 +371,10 @@ describe("SessionMessagesClient (request export actions)", () => { click(nextBtn as HTMLButtonElement); expect(routerReplaceMock).toHaveBeenCalledWith( - "/dashboard/sessions/0123456789abcdef/messages?seq=6&sourceSessionId=physical-current" + "/dashboard/sessions/0123456789abcdef/messages?seq=6&sourceSessionId=physical-prev&requestId=206" ); expect(routerReplaceMock).toHaveBeenCalledWith( - "/dashboard/sessions/0123456789abcdef/messages?seq=8&sourceSessionId=physical-current" + "/dashboard/sessions/0123456789abcdef/messages?seq=8&sourceSessionId=physical-next&requestId=208" ); expect(container.querySelector("[data-testid='mock-view-mode']")?.textContent).toBe("before"); @@ -389,7 +395,7 @@ describe("SessionMessagesClient (request export actions)", () => { ); expect(routerReplaceMock).toHaveBeenCalledWith( - "/dashboard/sessions/0123456789abcdef/messages?seq=1&sourceSessionId=physical-selected" + "/dashboard/sessions/0123456789abcdef/messages?seq=1&sourceSessionId=physical-selected&requestId=201" ); unmount(); diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx index 7f4c05d46..9ee35839c 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx @@ -66,12 +66,19 @@ export function SessionMessagesClient() { // URL state const seqParam = searchParams.get("seq"); const selectedSourceSessionId = searchParams.get("sourceSessionId"); + const requestIdParam = searchParams.get("requestId"); const selectedSeq = (() => { if (!seqParam) return null; const parsed = Number.parseInt(seqParam, 10); if (!Number.isFinite(parsed) || parsed <= 0) return null; return parsed; })(); + const selectedRequestId = (() => { + if (!requestIdParam) return null; + const parsed = Number.parseInt(requestIdParam, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return null; + return parsed; + })(); // Data State const [snapshots, setSnapshots] = useState(null); @@ -86,10 +93,17 @@ export function SessionMessagesClient() { useState< Extract>, { ok: true }>["data"]["sessionStats"] >(null); - const [currentSourceSessionId, setCurrentSourceSessionId] = useState(null); const [currentSequence, setCurrentSequence] = useState(null); - const [prevSequence, setPrevSequence] = useState(null); - const [nextSequence, setNextSequence] = useState(null); + const [prevRequest, setPrevRequest] = useState<{ + requestId: number; + sourceSessionId: string; + requestSequence: number; + } | null>(null); + const [nextRequest, setNextRequest] = useState<{ + requestId: number; + sourceSessionId: string; + requestSequence: number; + } | null>(null); // UI State const [isLoading, setIsLoading] = useState(true); @@ -107,10 +121,9 @@ export function SessionMessagesClient() { setSnapshots(null); setSpecialSettings(null); setSessionStats(null); - setCurrentSourceSessionId(null); setCurrentSequence(null); - setPrevSequence(null); - setNextSequence(null); + setPrevRequest(null); + setNextRequest(null); }, []); const { data: systemSettings } = useQuery({ @@ -121,7 +134,7 @@ export function SessionMessagesClient() { const currencyCode = systemSettings?.currencyDisplay || "USD"; const handleSelectRequest = useCallback( - (sourceSessionId: string | null, seq: number) => { + (sourceSessionId: string | null, seq: number, requestId?: number) => { const params = new URLSearchParams(window.location.search); params.set("seq", seq.toString()); if (sourceSessionId) { @@ -129,6 +142,11 @@ export function SessionMessagesClient() { } else { params.delete("sourceSessionId"); } + if (requestId) { + params.set("requestId", requestId.toString()); + } else { + params.delete("requestId"); + } router.replace(`${pathname}?${params.toString()}`); setIsMobileMenuOpen(false); }, @@ -146,7 +164,8 @@ export function SessionMessagesClient() { const result = await getSessionDetails( sessionId, selectedSeq ?? undefined, - selectedSourceSessionId ?? undefined + selectedSourceSessionId ?? undefined, + selectedRequestId ?? undefined ); if (cancelled) return; @@ -154,10 +173,9 @@ export function SessionMessagesClient() { setSnapshots(result.data.snapshots); setSpecialSettings(result.data.specialSettings); setSessionStats(result.data.sessionStats); - setCurrentSourceSessionId(result.data.currentSourceSessionId); setCurrentSequence(result.data.currentSequence); - setPrevSequence(result.data.prevSequence); - setNextSequence(result.data.nextSequence); + setPrevRequest(result.data.prevRequest); + setNextRequest(result.data.nextRequest); } else { resetDetailsState(); setError( @@ -182,7 +200,15 @@ export function SessionMessagesClient() { return () => { cancelled = true; }; - }, [resetDetailsState, selectedSeq, selectedSourceSessionId, sessionId, t, tErrors]); + }, [ + resetDetailsState, + selectedRequestId, + selectedSeq, + selectedSourceSessionId, + sessionId, + t, + tErrors, + ]); const currentRequestSnapshot = snapshots?.request[viewMode] ?? null; const currentResponseSnapshot = snapshots?.response[viewMode] ?? null; @@ -487,12 +513,13 @@ export function SessionMessagesClient() {