From f1b6ff3b1f9e20f4dc7c9f73e3920d7e2a989330 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 12 May 2026 13:20:43 -0700 Subject: [PATCH 1/4] Cache free-mode country access (#660) --- .../src/db/migrations/0051_easy_sersi.sql | 18 + .../src/db/migrations/meta/0051_snapshot.json | 3316 +++++++++++++++++ .../src/db/migrations/meta/_journal.json | 7 + packages/internal/src/db/schema.ts | 48 + .../completions/__tests__/completions.test.ts | 101 +- web/src/app/api/v1/chat/completions/_post.ts | 21 +- .../free-mode-country-access-cache.test.ts | 170 + .../server/free-mode-country-access-cache.ts | 190 + web/src/server/free-mode-country.ts | 4 +- 9 files changed, 3828 insertions(+), 47 deletions(-) create mode 100644 packages/internal/src/db/migrations/0051_easy_sersi.sql create mode 100644 packages/internal/src/db/migrations/meta/0051_snapshot.json create mode 100644 web/src/server/__tests__/free-mode-country-access-cache.test.ts create mode 100644 web/src/server/free-mode-country-access-cache.ts diff --git a/packages/internal/src/db/migrations/0051_easy_sersi.sql b/packages/internal/src/db/migrations/0051_easy_sersi.sql new file mode 100644 index 0000000000..caa8eb2892 --- /dev/null +++ b/packages/internal/src/db/migrations/0051_easy_sersi.sql @@ -0,0 +1,18 @@ +CREATE TABLE "free_mode_country_access_cache" ( + "user_id" text NOT NULL, + "client_ip_hash" text NOT NULL, + "allowed" boolean NOT NULL, + "country_code" text, + "cf_country" text, + "geoip_country" text, + "country_block_reason" text, + "ip_privacy_signals" text[], + "checked_at" timestamp with time zone NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "free_mode_country_access_cache_user_id_client_ip_hash_pk" PRIMARY KEY("user_id","client_ip_hash") +); +--> statement-breakpoint +ALTER TABLE "free_mode_country_access_cache" ADD CONSTRAINT "free_mode_country_access_cache_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_free_mode_country_cache_expires_at" ON "free_mode_country_access_cache" USING btree ("expires_at"); \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/0051_snapshot.json b/packages/internal/src/db/migrations/meta/0051_snapshot.json new file mode 100644 index 0000000000..fee986ea24 --- /dev/null +++ b/packages/internal/src/db/migrations/meta/0051_snapshot.json @@ -0,0 +1,3316 @@ +{ + "id": "ead7b227-50a8-4758-8dbf-a5a402606f64", + "prevId": "4c7aa6ac-8afc-4c2c-b0a4-2bbfcde731b8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": ["provider", "providerAccountId"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ad_impression": { + "name": "ad_impression", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'gravity'" + }, + "ad_text": { + "name": "ad_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cta": { + "name": "cta", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "click_url": { + "name": "click_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imp_url": { + "name": "imp_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "extra_pixels": { + "name": "extra_pixels", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "payout": { + "name": "payout", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false + }, + "credits_granted": { + "name": "credits_granted", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grant_operation_id": { + "name": "grant_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "served_at": { + "name": "served_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "impression_fired_at": { + "name": "impression_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "clicked_at": { + "name": "clicked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_ad_impression_user": { + "name": "idx_ad_impression_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ad_impression_imp_url": { + "name": "idx_ad_impression_imp_url", + "columns": [ + { + "expression": "imp_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ad_impression_user_id_user_id_fk": { + "name": "ad_impression_user_id_user_id_fk", + "tableFrom": "ad_impression", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ad_impression_imp_url_unique": { + "name": "ad_impression_imp_url_unique", + "nullsNotDistinct": false, + "columns": ["imp_url"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config": { + "name": "agent_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 1) AS INTEGER)", + "type": "stored" + } + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 2) AS INTEGER)", + "type": "stored" + } + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 3) AS INTEGER)", + "type": "stored" + } + }, + "data": { + "name": "data", + "type": "jsonb", + "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": { + "idx_agent_config_publisher": { + "name": "idx_agent_config_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_publisher_id_publisher_id_fk": { + "name": "agent_config_publisher_id_publisher_id_fk", + "tableFrom": "agent_config", + "tableTo": "publisher", + "columnsFrom": ["publisher_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_config_publisher_id_id_version_pk": { + "name": "agent_config_publisher_id_id_version_pk", + "columns": ["publisher_id", "id", "version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_run": { + "name": "agent_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '/', 1)\n ELSE NULL\n END", + "type": "stored" + } + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(split_part(agent_id, '/', 2), '@', 1)\n ELSE agent_id\n END", + "type": "stored" + } + }, + "agent_version": { + "name": "agent_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '@', 2)\n ELSE NULL\n END", + "type": "stored" + } + }, + "ancestor_run_ids": { + "name": "ancestor_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "root_run_id": { + "name": "root_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[1] ELSE id END", + "type": "stored" + } + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[array_length(ancestor_run_ids, 1)] ELSE NULL END", + "type": "stored" + } + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "COALESCE(array_length(ancestor_run_ids, 1), 1)", + "type": "stored" + } + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "total_steps": { + "name": "total_steps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "direct_credits": { + "name": "direct_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_credits": { + "name": "total_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "agent_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "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()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_agent_run_user_id": { + "name": "idx_agent_run_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_parent": { + "name": "idx_agent_run_parent", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_root": { + "name": "idx_agent_run_root", + "columns": [ + { + "expression": "root_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_agent_id": { + "name": "idx_agent_run_agent_id", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_publisher": { + "name": "idx_agent_run_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_status": { + "name": "idx_agent_run_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_ancestors_gin": { + "name": "idx_agent_run_ancestors_gin", + "columns": [ + { + "expression": "ancestor_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_agent_run_completed_publisher_agent": { + "name": "idx_agent_run_completed_publisher_agent", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_recent": { + "name": "idx_agent_run_completed_recent", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_version": { + "name": "idx_agent_run_completed_version", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_user": { + "name": "idx_agent_run_completed_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_run_user_id_user_id_fk": { + "name": "agent_run_user_id_user_id_fk", + "tableFrom": "agent_run", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_step": { + "name": "agent_step", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "credits": { + "name": "credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "child_run_ids": { + "name": "child_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "spawned_count": { + "name": "spawned_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "array_length(child_run_ids, 1)", + "type": "stored" + } + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "agent_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "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()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_step_number_per_run": { + "name": "unique_step_number_per_run", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_run_id": { + "name": "idx_agent_step_run_id", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_children_gin": { + "name": "idx_agent_step_children_gin", + "columns": [ + { + "expression": "child_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "agent_step_agent_run_id_agent_run_id_fk": { + "name": "agent_step_agent_run_id_agent_run_id_fk", + "tableFrom": "agent_step", + "tableTo": "agent_run", + "columnsFrom": ["agent_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credit_ledger": { + "name": "credit_ledger", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal": { + "name": "principal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "grant_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_credit_ledger_active_balance": { + "name": "idx_credit_ledger_active_balance", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "balance", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"credit_ledger\".\"balance\" != 0 AND \"credit_ledger\".\"expires_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_org": { + "name": "idx_credit_ledger_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_subscription": { + "name": "idx_credit_ledger_subscription", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credit_ledger_user_id_user_id_fk": { + "name": "credit_ledger_user_id_user_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credit_ledger_org_id_org_id_fk": { + "name": "credit_ledger_org_id_org_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encrypted_api_keys": { + "name": "encrypted_api_keys", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "api_key_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "encrypted_api_keys_user_id_user_id_fk": { + "name": "encrypted_api_keys_user_id_user_id_fk", + "tableFrom": "encrypted_api_keys", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "encrypted_api_keys_user_id_type_pk": { + "name": "encrypted_api_keys_user_id_type_pk", + "columns": ["user_id", "type"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fingerprint": { + "name": "fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sig_hash": { + "name": "sig_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_mode_country_access_cache": { + "name": "free_mode_country_access_cache", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_ip_hash": { + "name": "client_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cf_country": { + "name": "cf_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "geoip_country": { + "name": "geoip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_block_reason": { + "name": "country_block_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_privacy_signals": { + "name": "ip_privacy_signals", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "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": { + "idx_free_mode_country_cache_expires_at": { + "name": "idx_free_mode_country_cache_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_mode_country_access_cache_user_id_user_id_fk": { + "name": "free_mode_country_access_cache_user_id_user_id_fk", + "tableFrom": "free_mode_country_access_cache", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "free_mode_country_access_cache_user_id_client_ip_hash_pk": { + "name": "free_mode_country_access_cache_user_id_client_ip_hash_pk", + "columns": ["user_id", "client_ip_hash"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_session": { + "name": "free_session", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "free_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "active_instance_id": { + "name": "active_instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cf_country": { + "name": "cf_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "geoip_country": { + "name": "geoip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_block_reason": { + "name": "country_block_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_privacy_signals": { + "name": "ip_privacy_signals", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "client_ip_hash": { + "name": "client_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_checked_at": { + "name": "country_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "admitted_at": { + "name": "admitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_free_session_queue": { + "name": "idx_free_session_queue", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_free_session_expiry": { + "name": "idx_free_session_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_session_user_id_user_id_fk": { + "name": "free_session_user_id_user_id_fk", + "tableFrom": "free_session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_session_admit": { + "name": "free_session_admit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "admitted_at": { + "name": "admitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session_units": { + "name": "session_units", + "type": "numeric(3, 1)", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + } + }, + "indexes": { + "idx_free_session_admit_user_model_time": { + "name": "idx_free_session_admit_user_model_time", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "admitted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_session_admit_user_id_user_id_fk": { + "name": "free_session_admit_user_id_user_id_fk", + "tableFrom": "free_session_admit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_eval_results": { + "name": "git_eval_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cost_mode": { + "name": "cost_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoner_model": { + "name": "reasoner_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_model": { + "name": "agent_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.limit_override": { + "name": "limit_override", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credits_per_block": { + "name": "credits_per_block", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "block_duration_hours": { + "name": "block_duration_hours", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "weekly_credit_limit": { + "name": "weekly_credit_limit", + "type": "integer", + "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": {}, + "foreignKeys": { + "limit_override_user_id_user_id_fk": { + "name": "limit_override_user_id_user_id_fk", + "tableFrom": "limit_override", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message": { + "name": "message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "\"message\".\"request\" -> -1", + "type": "stored" + } + }, + "reasoning_text": { + "name": "reasoning_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "numeric(100, 20)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "byok": { + "name": "byok", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttft_ms": { + "name": "ttft_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "message_user_id_idx": { + "name": "message_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_finished_at_user_id_idx": { + "name": "message_finished_at_user_id_idx", + "columns": [ + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_idx": { + "name": "message_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_finished_at_idx": { + "name": "message_org_id_finished_at_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_user_id_user_id_fk": { + "name": "message_user_id_user_id_fk", + "tableFrom": "message", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_org_id_org_id_fk": { + "name": "message_org_id_org_id_fk", + "tableFrom": "message", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org": { + "name": "org", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credit_limit": { + "name": "credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_alerts": { + "name": "billing_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weekly_reports": { + "name": "weekly_reports", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "org_owner_id_user_id_fk": { + "name": "org_owner_id_user_id_fk", + "tableFrom": "org", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_slug_unique": { + "name": "org_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "org_stripe_customer_id_unique": { + "name": "org_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": ["stripe_customer_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_feature": { + "name": "org_feature", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_org_feature_active": { + "name": "idx_org_feature_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_feature_org_id_org_id_fk": { + "name": "org_feature_org_id_org_id_fk", + "tableFrom": "org_feature", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_feature_org_id_feature_pk": { + "name": "org_feature_org_id_feature_pk", + "columns": ["org_id", "feature"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_invite": { + "name": "org_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_org_invite_token": { + "name": "idx_org_invite_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_email": { + "name": "idx_org_invite_email", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_expires": { + "name": "idx_org_invite_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_invite_org_id_org_id_fk": { + "name": "org_invite_org_id_org_id_fk", + "tableFrom": "org_invite", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_invite_invited_by_user_id_fk": { + "name": "org_invite_invited_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "org_invite_accepted_by_user_id_fk": { + "name": "org_invite_accepted_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": ["accepted_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_invite_token_unique": { + "name": "org_invite_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_member": { + "name": "org_member", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_member_org_id_org_id_fk": { + "name": "org_member_org_id_org_id_fk", + "tableFrom": "org_member", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_member_user_id_user_id_fk": { + "name": "org_member_user_id_user_id_fk", + "tableFrom": "org_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_member_org_id_user_id_pk": { + "name": "org_member_org_id_user_id_pk", + "columns": ["org_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_repo": { + "name": "org_repo", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "idx_org_repo_active": { + "name": "idx_org_repo_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_repo_unique": { + "name": "idx_org_repo_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_repo_org_id_org_id_fk": { + "name": "org_repo_org_id_org_id_fk", + "tableFrom": "org_repo", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_repo_approved_by_user_id_fk": { + "name": "org_repo_approved_by_user_id_fk", + "tableFrom": "org_repo", + "tableTo": "user", + "columnsFrom": ["approved_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publisher": { + "name": "publisher", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "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": {}, + "foreignKeys": { + "publisher_user_id_user_id_fk": { + "name": "publisher_user_id_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_org_id_org_id_fk": { + "name": "publisher_org_id_org_id_fk", + "tableFrom": "publisher", + "tableTo": "org", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_created_by_user_id_fk": { + "name": "publisher_created_by_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "publisher_single_owner": { + "name": "publisher_single_owner", + "value": "(\"publisher\".\"user_id\" IS NOT NULL AND \"publisher\".\"org_id\" IS NULL) OR\n (\"publisher\".\"user_id\" IS NULL AND \"publisher\".\"org_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.referral": { + "name": "referral", + "schema": "", + "columns": { + "referrer_id": { + "name": "referrer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referred_id": { + "name": "referred_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "referral_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_legacy": { + "name": "is_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "referral_referrer_id_user_id_fk": { + "name": "referral_referrer_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": ["referrer_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "referral_referred_id_user_id_fk": { + "name": "referral_referred_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": ["referred_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_referrer_id_referred_id_pk": { + "name": "referral_referrer_id_referred_id_pk", + "columns": ["referrer_id", "referred_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "fingerprint_id": { + "name": "fingerprint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_auth_hash": { + "name": "cli_auth_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "session_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_cli_auth_code_idx": { + "name": "session_cli_auth_code_idx", + "columns": [ + { + "expression": "fingerprint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cli_auth_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"session\".\"cli_auth_hash\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_fingerprint_id_fingerprint_id_fk": { + "name": "session_fingerprint_id_fingerprint_id_fk", + "tableFrom": "session", + "tableTo": "fingerprint", + "columnsFrom": ["fingerprint_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scheduled_tier": { + "name": "scheduled_tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_subscription_customer": { + "name": "idx_subscription_customer", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_user": { + "name": "idx_subscription_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_status": { + "name": "idx_subscription_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_user_id_user_id_fk": { + "name": "subscription_user_id_user_id_fk", + "tableFrom": "subscription", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_failure": { + "name": "sync_failure", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_sync_failure_retry": { + "name": "idx_sync_failure_retry", + "columns": [ + { + "expression": "retry_count", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sync_failure\".\"retry_count\" < 5", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_quota_reset": { + "name": "next_quota_reset", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now() + INTERVAL '1 month'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referral_code": { + "name": "referral_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'ref-' || gen_random_uuid()" + }, + "referral_limit": { + "name": "referral_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fallback_to_a_la_carte": { + "name": "fallback_to_a_la_carte", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_stripe_customer_id_unique": { + "name": "user_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": ["stripe_customer_id"] + }, + "user_referral_code_unique": { + "name": "user_referral_code_unique", + "nullsNotDistinct": false, + "columns": ["referral_code"] + }, + "user_discord_id_unique": { + "name": "user_discord_id_unique", + "nullsNotDistinct": false, + "columns": ["discord_id"] + }, + "user_handle_unique": { + "name": "user_handle_unique", + "nullsNotDistinct": false, + "columns": ["handle"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": ["identifier", "token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.referral_status": { + "name": "referral_status", + "schema": "public", + "values": ["pending", "completed"] + }, + "public.agent_run_status": { + "name": "agent_run_status", + "schema": "public", + "values": ["running", "completed", "failed", "cancelled"] + }, + "public.agent_step_status": { + "name": "agent_step_status", + "schema": "public", + "values": ["running", "completed", "skipped"] + }, + "public.api_key_type": { + "name": "api_key_type", + "schema": "public", + "values": ["anthropic", "gemini", "openai"] + }, + "public.free_session_status": { + "name": "free_session_status", + "schema": "public", + "values": ["queued", "active"] + }, + "public.grant_type": { + "name": "grant_type", + "schema": "public", + "values": [ + "free", + "referral", + "referral_legacy", + "subscription", + "purchase", + "admin", + "organization", + "ad" + ] + }, + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": ["owner", "admin", "member"] + }, + "public.session_type": { + "name": "session_type", + "schema": "public", + "values": ["web", "pat", "cli"] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "incomplete", + "incomplete_expired", + "trialing", + "active", + "past_due", + "canceled", + "unpaid", + "paused" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/internal/src/db/migrations/meta/_journal.json b/packages/internal/src/db/migrations/meta/_journal.json index 6dcc930048..d45dbc4759 100644 --- a/packages/internal/src/db/migrations/meta/_journal.json +++ b/packages/internal/src/db/migrations/meta/_journal.json @@ -358,6 +358,13 @@ "when": 1777936763321, "tag": "0050_overrated_stellaris", "breakpoints": true + }, + { + "idx": 51, + "version": "7", + "when": 1778611718988, + "tag": "0051_easy_sersi", + "breakpoints": true } ] } diff --git a/packages/internal/src/db/schema.ts b/packages/internal/src/db/schema.ts index 79357c2b61..e6c1d013c2 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -909,6 +909,54 @@ export const freeSession = pgTable( ], ) +/** + * Shared cache for free-mode country/privacy decisions. Raw IP addresses are + * never persisted; client_ip_hash is HMAC-SHA256 with the server auth secret. + */ +export const freeModeCountryAccessCache = pgTable( + 'free_mode_country_access_cache', + { + user_id: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + client_ip_hash: text('client_ip_hash').notNull(), + allowed: boolean('allowed').notNull(), + country_code: text('country_code'), + cf_country: text('cf_country'), + geoip_country: text('geoip_country'), + country_block_reason: text( + 'country_block_reason', + ).$type(), + ip_privacy_signals: text('ip_privacy_signals') + .array() + .$type(), + checked_at: timestamp('checked_at', { + mode: 'date', + withTimezone: true, + }).notNull(), + expires_at: timestamp('expires_at', { + mode: 'date', + withTimezone: true, + }).notNull(), + created_at: timestamp('created_at', { + mode: 'date', + withTimezone: true, + }) + .notNull() + .defaultNow(), + updated_at: timestamp('updated_at', { + mode: 'date', + withTimezone: true, + }) + .notNull() + .defaultNow(), + }, + (table) => [ + primaryKey({ columns: [table.user_id, table.client_ip_hash] }), + index('idx_free_mode_country_cache_expires_at').on(table.expires_at), + ], +) + /** * Audit log of every admission — one row per queued→active transition. Used * to track shared premium-session usage for Freebuff's 5 sessions per Pacific diff --git a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts index 8bf708487e..b8bec42579 100644 --- a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts +++ b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts @@ -12,6 +12,7 @@ import { import { openCodeZenModels } from '@codebuff/common/constants/model-config' import { postChatCompletions } from '../_post' import { resetFreeModeRateLimits } from '../free-mode-rate-limiter' +import { getFreeModeCountryAccess } from '@/server/free-mode-country' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' import type { InsertMessageBigqueryFn } from '@codebuff/common/types/contracts/bigquery' @@ -86,6 +87,18 @@ describe('/api/v1/chat/completions POST endpoint', () => { // path so downstream logic proceeds normally. const mockCheckSessionAdmissibleAllow = async () => ({ ok: true, reason: 'disabled' }) as const + const mockResolveFreeModeCountryAccess = async ( + _userId: string, + req: Parameters[0], + options: Parameters[1], + ) => getFreeModeCountryAccess(req, options) + const postChatCompletionsForTest = ( + params: Parameters[0], + ) => + postChatCompletions({ + resolveFreeModeCountryAccess: mockResolveFreeModeCountryAccess, + ...params, + }) const allowedFreeModeHeaders = (apiKey: string) => ({ Authorization: `Bearer ${apiKey}`, @@ -289,7 +302,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -317,7 +330,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -347,7 +360,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -375,7 +388,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -406,7 +419,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -439,7 +452,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -474,7 +487,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -509,7 +522,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -548,7 +561,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -591,7 +604,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -633,7 +646,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -671,7 +684,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -713,7 +726,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -755,7 +768,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -818,7 +831,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -910,7 +923,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1027,7 +1040,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1084,7 +1097,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1123,7 +1136,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1159,7 +1172,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1197,7 +1210,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1233,7 +1246,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1252,7 +1265,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }) it('rejects the Gemini thinker subagent when the session gate rejects it', async () => { - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req: new NextRequest('http://localhost:3000/api/v1/chat/completions', { method: 'POST', headers: allowedFreeModeHeaders('test-api-key-new-free-gemini'), @@ -1300,7 +1313,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { return { limited: false as const } }) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req: new NextRequest( 'http://localhost:3000/api/v1/chat/completions', { @@ -1387,8 +1400,10 @@ describe('/api/v1/chat/completions POST endpoint', () => { checkFreeModeRateLimit: checkFreeModeRateLimitForTest, }) - const firstResponse = await postChatCompletions(createPostParams()) - const limitedResponse = await postChatCompletions(createPostParams()) + const firstResponse = + await postChatCompletionsForTest(createPostParams()) + const limitedResponse = + await postChatCompletionsForTest(createPostParams()) expect(firstResponse.status).toBe(200) expect(limitedResponse.status).toBe(429) @@ -1419,7 +1434,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1456,7 +1471,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1494,7 +1509,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1530,7 +1545,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1569,7 +1584,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1614,7 +1629,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1675,7 +1690,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { fallbackToALaCarte: false, })) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req: createValidRequest(), getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1732,7 +1747,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req: freeModeRequest, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1768,7 +1783,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { fallbackToALaCarte: false, })) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req: createValidRequest(), getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1808,7 +1823,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { fallbackToALaCarte: true, })) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req: createValidRequest(), getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1843,7 +1858,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { fallbackToALaCarte: false, })) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req: createValidRequest(), getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1873,7 +1888,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { fallbackToALaCarte: false, })) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req: createValidRequest(), getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1901,7 +1916,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { fallbackToALaCarte: false, })) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req: createValidRequest(), getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -1936,7 +1951,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { async () => weeklyLimitError, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req: createValidRequest(), getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -2001,7 +2016,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -2037,7 +2052,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, @@ -2073,7 +2088,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { }, ) - const response = await postChatCompletions({ + const response = await postChatCompletionsForTest({ req, getUserInfoFromApiKey: mockGetUserInfoFromApiKey, logger: mockLogger, diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index 6a61be1739..2adaea3dde 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -85,9 +85,13 @@ import { OpenRouterError, } from '@/llm-api/openrouter' import { checkSessionAdmissible } from '@/server/free-session/public-api' -import { getFreeModeCountryAccess } from '@/server/free-mode-country' +import { getCachedFreeModeCountryAccess } from '@/server/free-mode-country-access-cache' import type { SessionGateResult } from '@/server/free-session/public-api' +import type { + FreeModeCountryAccess, + FreeModeCountryAccessOptions, +} from '@/server/free-mode-country' import { extractApiKeyFromHeader } from '@/util/auth' import { withDefaultProperties } from '@codebuff/common/analytics' import { checkFreeModeRateLimit as defaultCheckFreeModeRateLimit } from './free-mode-rate-limiter' @@ -130,6 +134,11 @@ export const formatQuotaResetCountdown = ( export type CheckSessionAdmissibleFn = typeof checkSessionAdmissible export type CheckFreeModeRateLimitFn = typeof defaultCheckFreeModeRateLimit +export type ResolveFreeModeCountryAccessFn = ( + userId: string, + req: NextRequest, + options: FreeModeCountryAccessOptions, +) => Promise const FREEBUFF_SUCCESS_SAMPLE_RATE = 0.01 @@ -174,6 +183,9 @@ export async function postChatCompletions(params: { /** Optional override for the free-mode rate limiter. Tests inject this to * avoid coupling to process-global limiter state. */ checkFreeModeRateLimit?: CheckFreeModeRateLimitFn + /** Optional override for country/cache checks. Tests inject this to avoid + * coupling to Postgres-backed cache state. */ + resolveFreeModeCountryAccess?: ResolveFreeModeCountryAccessFn }) { const { req, @@ -187,9 +199,14 @@ export async function postChatCompletions(params: { getUserPreferences, checkSessionAdmissible: checkSession = checkSessionAdmissible, checkFreeModeRateLimit = defaultCheckFreeModeRateLimit, + resolveFreeModeCountryAccess, } = params let { logger } = params let { trackEvent } = params + const resolveCountryAccess: ResolveFreeModeCountryAccessFn = + resolveFreeModeCountryAccess ?? + ((userId, req, options) => + getCachedFreeModeCountryAccess({ userId, req, options, logger })) try { // Parse request body @@ -470,7 +487,7 @@ export async function postChatCompletions(params: { isFreeModeRequest && (!freeModeSessionGate || freeModeSessionGate.reason === 'disabled') ) { - const countryAccess = await getFreeModeCountryAccess(req, { + const countryAccess = await resolveCountryAccess(userId, req, { fetch, ipinfoToken: env.IPINFO_TOKEN, ipHashSecret: env.NEXTAUTH_SECRET, diff --git a/web/src/server/__tests__/free-mode-country-access-cache.test.ts b/web/src/server/__tests__/free-mode-country-access-cache.test.ts new file mode 100644 index 0000000000..7fd16cd690 --- /dev/null +++ b/web/src/server/__tests__/free-mode-country-access-cache.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, mock, test } from 'bun:test' +import { NextRequest } from 'next/server' + +import { + expiresAtForCountryAccess, + FREE_MODE_COUNTRY_CACHE_ALLOWED_TTL_MS, + FREE_MODE_COUNTRY_CACHE_ANONYMOUS_NETWORK_TTL_MS, + FREE_MODE_COUNTRY_CACHE_COUNTRY_NOT_ALLOWED_TTL_MS, + FREE_MODE_COUNTRY_CACHE_TRANSIENT_BLOCK_TTL_MS, + getCachedFreeModeCountryAccess, +} from '../free-mode-country-access-cache' +import { hashClientIp } from '../free-mode-country' + +import type { FreeModeCountryAccess } from '../free-mode-country' +import type { FreeModeCountryAccessCacheStore } from '../free-mode-country-access-cache' + +const now = new Date('2026-05-12T12:00:00Z') +const userId = 'user-123' +const ipHashSecret = 'test-secret' +const clientIp = '203.0.113.10' +const clientIpHash = hashClientIp(clientIp, ipHashSecret)! + +function makeReq(headers: Record = {}): NextRequest { + return new NextRequest('http://localhost:3000/api/v1/chat/completions', { + headers, + }) +} + +function allowedAccess(): FreeModeCountryAccess { + return { + allowed: true, + countryCode: 'US', + blockReason: null, + cfCountry: 'US', + geoipCountry: null, + ipPrivacy: { signals: [] }, + hasClientIp: true, + clientIpHash, + } +} + +describe('free mode country access cache', () => { + test('uses a fresh cached country decision without calling IPinfo', async () => { + const cached = allowedAccess() + const cacheStore: FreeModeCountryAccessCacheStore = { + get: mock(async () => cached), + set: mock(async () => {}), + } + const fetch = mock(async () => { + throw new Error('IPinfo should not be called on cache hit') + }) as unknown as typeof globalThis.fetch + + const access = await getCachedFreeModeCountryAccess({ + userId, + req: makeReq({ + 'cf-ipcountry': 'US', + 'cf-connecting-ip': clientIp, + }), + options: { + fetch, + ipinfoToken: 'test-token', + ipHashSecret, + }, + cacheStore, + now, + }) + + expect(access).toBe(cached) + expect(cacheStore.get).toHaveBeenCalledWith({ + userId, + clientIpHash, + cfCountry: 'US', + now, + }) + expect(cacheStore.set).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() + }) + + test('stores a fresh country decision after a cache miss', async () => { + const stored: FreeModeCountryAccess[] = [] + const cacheStore: FreeModeCountryAccessCacheStore = { + get: mock(async () => null), + set: mock(async ({ access }) => { + stored.push(access) + }), + } + const fetch = mock(async () => + Response.json({}), + ) as unknown as typeof globalThis.fetch + + const access = await getCachedFreeModeCountryAccess({ + userId, + req: makeReq({ + 'cf-ipcountry': 'US', + 'cf-connecting-ip': clientIp, + }), + options: { + fetch, + ipinfoToken: 'test-token', + ipHashSecret, + }, + cacheStore, + now, + }) + + expect(access.allowed).toBe(true) + expect(access.countryCode).toBe('US') + expect(stored[0]).toEqual(access) + expect(fetch).toHaveBeenCalledTimes(1) + }) + + test('refreshes when the cache store reports a stale entry', async () => { + const stale = allowedAccess() + const staleRefreshIp = '203.0.113.11' + const cacheStore: FreeModeCountryAccessCacheStore = { + get: mock(async ({ now: cacheNow }) => + cacheNow.getTime() < now.getTime() ? stale : null, + ), + set: mock(async () => {}), + } + const fetch = mock(async () => + Response.json({}), + ) as unknown as typeof globalThis.fetch + + const access = await getCachedFreeModeCountryAccess({ + userId, + req: makeReq({ + 'cf-ipcountry': 'US', + 'cf-connecting-ip': staleRefreshIp, + }), + options: { + fetch, + ipinfoToken: 'test-token', + ipHashSecret, + }, + cacheStore, + now, + }) + + expect(access.allowed).toBe(true) + expect(cacheStore.set).toHaveBeenCalled() + expect(fetch).toHaveBeenCalledTimes(1) + }) + + test('uses shorter TTLs for VPN and transient blocks than country blocks', () => { + const base = allowedAccess() + + expect(expiresAtForCountryAccess(base, now).getTime() - now.getTime()).toBe( + FREE_MODE_COUNTRY_CACHE_ALLOWED_TTL_MS, + ) + expect( + expiresAtForCountryAccess( + { ...base, allowed: false, blockReason: 'anonymous_network' }, + now, + ).getTime() - now.getTime(), + ).toBe(FREE_MODE_COUNTRY_CACHE_ANONYMOUS_NETWORK_TTL_MS) + expect( + expiresAtForCountryAccess( + { ...base, allowed: false, blockReason: 'country_not_allowed' }, + now, + ).getTime() - now.getTime(), + ).toBe(FREE_MODE_COUNTRY_CACHE_COUNTRY_NOT_ALLOWED_TTL_MS) + expect( + expiresAtForCountryAccess( + { ...base, allowed: false, blockReason: 'ip_privacy_lookup_failed' }, + now, + ).getTime() - now.getTime(), + ).toBe(FREE_MODE_COUNTRY_CACHE_TRANSIENT_BLOCK_TTL_MS) + }) +}) diff --git a/web/src/server/free-mode-country-access-cache.ts b/web/src/server/free-mode-country-access-cache.ts new file mode 100644 index 0000000000..877eba316b --- /dev/null +++ b/web/src/server/free-mode-country-access-cache.ts @@ -0,0 +1,190 @@ +import { db } from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { getErrorObject } from '@codebuff/common/util/error' +import { and, eq, gt, isNull } from 'drizzle-orm' + +import { + extractClientIp, + getFreeModeCountryAccess, + hashClientIp, + IPINFO_PRIVACY_CACHE_TTL_MS, +} from './free-mode-country' + +import type { + FreeModeCountryAccess, + FreeModeCountryAccessOptions, +} from './free-mode-country' +import type { Logger } from '@codebuff/common/types/contracts/logger' + +export const FREE_MODE_COUNTRY_CACHE_ALLOWED_TTL_MS = + IPINFO_PRIVACY_CACHE_TTL_MS +export const FREE_MODE_COUNTRY_CACHE_ANONYMOUS_NETWORK_TTL_MS = 15 * 60 * 1000 +export const FREE_MODE_COUNTRY_CACHE_COUNTRY_NOT_ALLOWED_TTL_MS = + 6 * 60 * 60 * 1000 +export const FREE_MODE_COUNTRY_CACHE_TRANSIENT_BLOCK_TTL_MS = 5 * 60 * 1000 + +export type FreeModeCountryAccessCacheStore = { + get(params: { + userId: string + clientIpHash: string + cfCountry: string | null + now: Date + }): Promise + set(params: { + userId: string + access: FreeModeCountryAccess + now: Date + }): Promise +} + +export function expiresAtForCountryAccess( + access: FreeModeCountryAccess, + now: Date, +): Date { + let ttlMs = FREE_MODE_COUNTRY_CACHE_TRANSIENT_BLOCK_TTL_MS + if (access.allowed) { + ttlMs = FREE_MODE_COUNTRY_CACHE_ALLOWED_TTL_MS + } else if (access.blockReason === 'anonymous_network') { + ttlMs = FREE_MODE_COUNTRY_CACHE_ANONYMOUS_NETWORK_TTL_MS + } else if (access.blockReason === 'country_not_allowed') { + ttlMs = FREE_MODE_COUNTRY_CACHE_COUNTRY_NOT_ALLOWED_TTL_MS + } + return new Date(now.getTime() + ttlMs) +} + +function countryAccessFromCacheRow( + row: typeof schema.freeModeCountryAccessCache.$inferSelect, +): FreeModeCountryAccess { + return { + allowed: row.allowed, + countryCode: row.country_code, + blockReason: row.country_block_reason, + cfCountry: row.cf_country, + geoipCountry: row.geoip_country, + ipPrivacy: row.ip_privacy_signals + ? { signals: row.ip_privacy_signals } + : null, + hasClientIp: true, + clientIpHash: row.client_ip_hash, + } +} + +export const dbFreeModeCountryAccessCacheStore: FreeModeCountryAccessCacheStore = + { + async get({ userId, clientIpHash, cfCountry, now }) { + const row = await db.query.freeModeCountryAccessCache.findFirst({ + where: and( + eq(schema.freeModeCountryAccessCache.user_id, userId), + eq(schema.freeModeCountryAccessCache.client_ip_hash, clientIpHash), + cfCountry === null + ? isNull(schema.freeModeCountryAccessCache.cf_country) + : eq(schema.freeModeCountryAccessCache.cf_country, cfCountry), + gt(schema.freeModeCountryAccessCache.expires_at, now), + ), + }) + if (!row) return null + return countryAccessFromCacheRow(row) + }, + + async set({ userId, access, now }) { + if (!access.clientIpHash) return + + const expiresAt = expiresAtForCountryAccess(access, now) + await db + .insert(schema.freeModeCountryAccessCache) + .values({ + user_id: userId, + client_ip_hash: access.clientIpHash, + allowed: access.allowed, + country_code: access.countryCode, + cf_country: access.cfCountry, + geoip_country: access.geoipCountry, + country_block_reason: access.blockReason, + ip_privacy_signals: access.ipPrivacy?.signals ?? null, + checked_at: now, + expires_at: expiresAt, + created_at: now, + updated_at: now, + }) + .onConflictDoUpdate({ + target: [ + schema.freeModeCountryAccessCache.user_id, + schema.freeModeCountryAccessCache.client_ip_hash, + ], + set: { + allowed: access.allowed, + country_code: access.countryCode, + cf_country: access.cfCountry, + geoip_country: access.geoipCountry, + country_block_reason: access.blockReason, + ip_privacy_signals: access.ipPrivacy?.signals ?? null, + checked_at: now, + expires_at: expiresAt, + updated_at: now, + }, + }) + }, + } + +export async function getCachedFreeModeCountryAccess(params: { + userId: string + req: Parameters[0] + options: FreeModeCountryAccessOptions + cacheStore?: FreeModeCountryAccessCacheStore + logger?: Logger + now?: Date +}): Promise { + const { + userId, + req, + options, + cacheStore = dbFreeModeCountryAccessCacheStore, + logger, + now = new Date(), + } = params + const cfCountry = req.headers.get('cf-ipcountry')?.toUpperCase() ?? null + const clientIp = extractClientIp(req) + const clientIpHash = hashClientIp(clientIp, options.ipHashSecret) + + if (clientIpHash) { + try { + const cached = await cacheStore.get({ + userId, + clientIpHash, + cfCountry, + now, + }) + if (cached) return cached + } catch (error) { + logger?.warn( + { + userId, + clientIpHash, + error: getErrorObject(error), + }, + 'Free mode country access cache read failed', + ) + // Cache failures should not make free-mode availability depend on DB + // health; fall back to the direct country/privacy check. + } + } + + const access = await getFreeModeCountryAccess(req, options) + if (access.clientIpHash) { + try { + await cacheStore.set({ userId, access, now }) + } catch (error) { + logger?.warn( + { + userId, + clientIpHash: access.clientIpHash, + error: getErrorObject(error), + }, + 'Free mode country access cache write failed', + ) + // Best-effort cache write. The direct country/privacy result is still + // authoritative for this request. + } + } + return access +} diff --git a/web/src/server/free-mode-country.ts b/web/src/server/free-mode-country.ts index d586a55eb0..6a64f6b321 100644 --- a/web/src/server/free-mode-country.ts +++ b/web/src/server/free-mode-country.ts @@ -60,7 +60,7 @@ export type LookupIpPrivacyFn = ( ip: string, ) => Promise -type FreeModeCountryAccessOptions = { +export type FreeModeCountryAccessOptions = { lookupIpPrivacy?: LookupIpPrivacyFn fetch?: typeof globalThis.fetch ipinfoToken: string @@ -113,7 +113,7 @@ export function extractClientIp(req: NextRequest): string | undefined { return undefined } -function hashClientIp( +export function hashClientIp( clientIp: string | undefined, secret: string | undefined, ): string | null { From ee1b87867f6dd23bb7f2bbc4be8cf61c3ef43be7 Mon Sep 17 00:00:00 2001 From: brandon chen <9735006+brandonkachen@users.noreply.github.com> Date: Tue, 12 May 2026 13:30:11 -0700 Subject: [PATCH 2/4] [codex] Set Freebuff ad request user agent (#661) Co-authored-by: James Grugett --- cli/src/hooks/use-gravity-ad.ts | 14 ++++- web/src/app/api/v1/ads/_post.ts | 4 +- web/src/app/api/v1/ads/impression/_post.ts | 7 ++- .../lib/ad-providers/__tests__/carbon.test.ts | 62 +++++++++++++++++++ web/src/lib/ad-providers/carbon.ts | 14 +++-- web/src/lib/ad-providers/types.ts | 4 +- 6 files changed, 96 insertions(+), 9 deletions(-) create mode 100644 web/src/lib/ad-providers/__tests__/carbon.test.ts diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts index d012817860..2d527c6f9e 100644 --- a/cli/src/hooks/use-gravity-ad.ts +++ b/cli/src/hooks/use-gravity-ad.ts @@ -7,6 +7,7 @@ import { useChatStore } from '../state/chat-store' import { isUserActive, subscribeToActivity } from '../utils/activity-tracker' import { getAuthToken } from '../utils/auth' import { IS_FREEBUFF } from '../utils/constants' +import { getCliEnv } from '../utils/env' import { logger } from '../utils/logger' import type { Message } from '@codebuff/sdk' @@ -165,8 +166,12 @@ export const useGravityAd = (options?: { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, + 'User-Agent': getCliAdRequestUserAgent(), }, - body: JSON.stringify({ impUrl, mode: agentMode }), + body: JSON.stringify({ + impUrl, + mode: agentMode, + }), }) if (!res.ok) { @@ -282,6 +287,7 @@ export const useGravityAd = (options?: { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, + 'User-Agent': getCliAdRequestUserAgent(), }, body: JSON.stringify({ provider: providerToTry, @@ -482,3 +488,9 @@ function getAdUserAgent(): string { } return osUA[process.platform] ?? osUA.linux } + +function getCliAdRequestUserAgent(): string { + const product = IS_FREEBUFF ? 'Freebuff-CLI' : 'Codebuff-CLI' + const version = getCliEnv().CODEBUFF_CLI_VERSION ?? 'dev' + return `${product}/${version}` +} diff --git a/web/src/app/api/v1/ads/_post.ts b/web/src/app/api/v1/ads/_post.ts index 51419d8fb5..7762d151c1 100644 --- a/web/src/app/api/v1/ads/_post.ts +++ b/web/src/app/api/v1/ads/_post.ts @@ -46,7 +46,7 @@ const bodySchema = z.object({ sessionId: z.string().optional(), device: deviceSchema.optional(), surface: surfaceSchema.optional(), - /** Browser/CLI useragent passed through to providers that require it. */ + /** Browser-like useragent passed through to providers that require it. */ userAgent: z.string().optional(), }) @@ -120,6 +120,7 @@ export async function postAds(params: { const providerId: AdProviderId = parsedBody.provider ?? 'gravity' const userAgent = parsedBody.userAgent ?? req.headers.get('user-agent') ?? undefined + const requestUserAgent = req.headers.get('user-agent') ?? undefined // Pick a provider. If the requested one isn't configured, return no ad // rather than failing — the client falls back to its cache / fallback UI. @@ -151,6 +152,7 @@ export async function postAds(params: { sessionId: parsedBody.sessionId, clientIp, userAgent, + requestUserAgent, device: parsedBody.device, surface: parsedBody.surface, messages: parsedBody.messages, diff --git a/web/src/app/api/v1/ads/impression/_post.ts b/web/src/app/api/v1/ads/impression/_post.ts index a1f3e04a3d..673e376082 100644 --- a/web/src/app/api/v1/ads/impression/_post.ts +++ b/web/src/app/api/v1/ads/impression/_post.ts @@ -183,11 +183,16 @@ export async function postAdImpression(params: { p.replaceAll('[timestamp]', now), ) const pixelUrls = [impUrl, ...extraPixels] + const requestUserAgent = req.headers.get('user-agent') ?? undefined await Promise.all( pixelUrls.map(async (pixelUrl) => { try { - await fetch(pixelUrl) + await fetch(pixelUrl, { + ...(requestUserAgent + ? { headers: { 'User-Agent': requestUserAgent } } + : {}), + }) } catch (error) { logger.warn( { diff --git a/web/src/lib/ad-providers/__tests__/carbon.test.ts b/web/src/lib/ad-providers/__tests__/carbon.test.ts new file mode 100644 index 0000000000..88363426d0 --- /dev/null +++ b/web/src/lib/ad-providers/__tests__/carbon.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test' + +import { createCarbonProvider } from '../carbon' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +const logger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +} + +describe('Carbon ad provider', () => { + test('sends the CLI User-Agent as the HTTP header', async () => { + const provider = createCarbonProvider({ zoneKey: 'CVADC53U' }) + const requests: Array<{ url: string; init?: RequestInit }> = [] + const fetch = Object.assign( + async (url: string | URL | Request, init?: RequestInit) => { + requests.push({ url: String(url), init }) + return new Response( + JSON.stringify({ + ads: [ + { + statlink: '//srv.buysellads.com/click', + statimp: '//srv.buysellads.com/imp', + description: 'Ad copy', + company: 'Acme', + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ) + }, + { preconnect: () => {} }, + ) as typeof globalThis.fetch + + const result = await provider.fetchAd({ + userId: 'user-1', + userEmail: 'user@example.com', + clientIp: '203.0.113.1', + userAgent: 'Mozilla/5.0 Test Browser', + requestUserAgent: 'Freebuff-CLI/0.0.88', + messages: [], + testMode: false, + logger, + fetch, + }) + + expect(result?.ads).toHaveLength(1) + expect(requests).toHaveLength(4) + for (const request of requests) { + expect(request.url).toContain('useragent=Mozilla%2F5.0+Test+Browser') + expect(request.init?.headers).toEqual({ + 'User-Agent': 'Freebuff-CLI/0.0.88', + }) + } + }) +}) diff --git a/web/src/lib/ad-providers/carbon.ts b/web/src/lib/ad-providers/carbon.ts index f4775a00ac..7ff789bf4f 100644 --- a/web/src/lib/ad-providers/carbon.ts +++ b/web/src/lib/ad-providers/carbon.ts @@ -95,13 +95,12 @@ function normalizeCarbonAd(raw: CarbonAd): NormalizedAd | null { } } -export function createCarbonProvider(config: { - zoneKey: string -}): AdProvider { +export function createCarbonProvider(config: { zoneKey: string }): AdProvider { return { id: 'carbon', fetchAd: async (input: FetchAdInput): Promise => { - const { clientIp, userAgent, testMode, logger, fetch } = input + const { clientIp, userAgent, requestUserAgent, testMode, logger, fetch } = + input if (!clientIp || !userAgent) { logger.debug( @@ -122,7 +121,12 @@ export function createCarbonProvider(config: { const url = `${CARBON_URL_BASE}/${config.zoneKey}.json?${params.toString()}` const fetchOne = async (): Promise => { - const response = await fetch(url, { method: 'GET' }) + const response = await fetch(url, { + method: 'GET', + headers: { + 'User-Agent': requestUserAgent ?? userAgent, + }, + }) if (!response.ok) { let body: unknown try { diff --git a/web/src/lib/ad-providers/types.ts b/web/src/lib/ad-providers/types.ts index 8f6558d31f..b485a62f5d 100644 --- a/web/src/lib/ad-providers/types.ts +++ b/web/src/lib/ad-providers/types.ts @@ -53,8 +53,10 @@ export type FetchAdInput = { sessionId?: string /** Client IP, parsed from X-Forwarded-For upstream. */ clientIp?: string - /** Browser/CLI useragent string, passed through to upstream. */ + /** Browser-like useragent string, passed through to upstream. */ userAgent?: string + /** Product User-Agent header sent on provider HTTP requests. */ + requestUserAgent?: string device?: AdDeviceInfo /** Product surface requesting the ad. Providers may map this to placements. */ surface?: AdSurface From 61db07ce6ecee474e80a573c14c7731f58580e87 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 12 May 2026 14:21:28 -0700 Subject: [PATCH 3/4] Log invalid Freebuff login auth codes (#662) --- .../api/auth/[...nextauth]/auth-options.ts | 29 +++++++++ freebuff/web/src/app/login/page.tsx | 61 ++++++++++++++++++- .../src/app/onboard/__tests__/helpers.test.ts | 29 +++++++++ freebuff/web/src/app/onboard/_helpers.ts | 14 +++++ 4 files changed, 131 insertions(+), 2 deletions(-) diff --git a/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts b/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts index ae0c4f04d4..b2b4467578 100644 --- a/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts +++ b/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts @@ -15,6 +15,10 @@ import GitHubProvider from 'next-auth/providers/github' import type { NextAuthOptions } from 'next-auth' import type { Adapter } from 'next-auth/adapters' +import { + getCliAuthCodeHashPrefix, + isCliAuthCodeCandidate, +} from '@/app/onboard/_helpers' import { logger } from '@/util/logger' async function createAndLinkStripeCustomer(params: { @@ -104,6 +108,31 @@ export const authOptions: NextAuthOptions = { const authCode = potentialRedirectUrl.searchParams.get('auth_code') if (authCode) { + if (!isCliAuthCodeCandidate(authCode)) { + const searchParamKeys = Array.from( + potentialRedirectUrl.searchParams.keys(), + ).sort() + logger.warn( + { + authCodeLength: authCode.length, + authCodeTrimmedLength: authCode.trim().length, + authCodeHashPrefix: getCliAuthCodeHashPrefix(authCode), + authCodeParamCount: + potentialRedirectUrl.searchParams.getAll('auth_code').length, + searchParamKeys, + searchParamCount: searchParamKeys.length, + hasCallbackUrlParam: searchParamKeys.includes('callbackUrl'), + hasCodeParam: searchParamKeys.includes('code'), + hasRedirectParam: searchParamKeys.includes('redirect'), + dotCount: authCode.match(/\./g)?.length ?? 0, + hyphenCount: authCode.match(/-/g)?.length ?? 0, + redirectUrlOrigin: potentialRedirectUrl.origin, + baseUrl, + }, + 'Freebuff auth redirect received non-CLI-shaped auth_code', + ) + } + const onboardUrl = new URL(`${baseUrl}/onboard`) potentialRedirectUrl.searchParams.forEach((value, key) => { onboardUrl.searchParams.set(key, value) diff --git a/freebuff/web/src/app/login/page.tsx b/freebuff/web/src/app/login/page.tsx index 9a37fac3ec..311cc2931d 100644 --- a/freebuff/web/src/app/login/page.tsx +++ b/freebuff/web/src/app/login/page.tsx @@ -1,7 +1,14 @@ 'use server' import { env } from '@codebuff/common/env' +import { headers } from 'next/headers' +import { + getCliAuthCodeHashPrefix, + isAuthCodeExpired, + isCliAuthCodeCandidate, + parseAuthCode, +} from '@/app/onboard/_helpers' import { BackgroundBeams } from '@/components/background-beams' import { HeroGrid } from '@/components/hero-grid' import { LoginCard } from '@/components/login/login-card' @@ -12,7 +19,7 @@ import { CardDescription, CardContent, } from '@/components/ui/card' -import { isAuthCodeExpired, parseAuthCode } from '@/app/onboard/_helpers' +import { logger } from '@/util/logger' export default async function LoginPage({ searchParams, @@ -20,9 +27,59 @@ export default async function LoginPage({ searchParams?: Promise<{ [key: string]: string | string[] | undefined }> }) { const resolvedSearchParams = searchParams ? await searchParams : {} - const authCode = resolvedSearchParams?.auth_code as string | undefined + const rawAuthCode = resolvedSearchParams?.auth_code + const authCode = Array.isArray(rawAuthCode) ? rawAuthCode[0] : rawAuthCode + const searchParamKeys = Object.keys(resolvedSearchParams).sort() if (authCode) { + if (!isCliAuthCodeCandidate(authCode)) { + const headerStore = await headers() + logger.warn( + { + authCodeLength: authCode.length, + authCodeTrimmedLength: authCode.trim().length, + authCodeHashPrefix: getCliAuthCodeHashPrefix(authCode), + authCodeParamCount: Array.isArray(rawAuthCode) + ? rawAuthCode.length + : 1, + searchParamKeys, + searchParamCount: searchParamKeys.length, + hasCallbackUrlParam: searchParamKeys.includes('callbackUrl'), + hasCodeParam: searchParamKeys.includes('code'), + hasRedirectParam: searchParamKeys.includes('redirect'), + dotCount: authCode.match(/\./g)?.length ?? 0, + hyphenCount: authCode.match(/-/g)?.length ?? 0, + requestHost: headerStore.get('host') ?? '', + forwardedHost: headerStore.get('x-forwarded-host') ?? '', + forwardedProto: headerStore.get('x-forwarded-proto') ?? '', + originHeader: headerStore.get('origin') ?? '', + referer: headerStore.get('referer') ?? '', + userAgent: headerStore.get('user-agent') ?? '', + referrerParam: + typeof resolvedSearchParams.referrer === 'string' + ? resolvedSearchParams.referrer + : '', + utmSource: + typeof resolvedSearchParams.utm_source === 'string' + ? resolvedSearchParams.utm_source + : '', + utmMedium: + typeof resolvedSearchParams.utm_medium === 'string' + ? resolvedSearchParams.utm_medium + : '', + utmCampaign: + typeof resolvedSearchParams.utm_campaign === 'string' + ? resolvedSearchParams.utm_campaign + : '', + utmContent: + typeof resolvedSearchParams.utm_content === 'string' + ? resolvedSearchParams.utm_content + : '', + }, + 'Freebuff login received non-CLI-shaped auth_code', + ) + } + const { expiresAt } = parseAuthCode(authCode) if (expiresAt && isAuthCodeExpired(expiresAt)) { diff --git a/freebuff/web/src/app/onboard/__tests__/helpers.test.ts b/freebuff/web/src/app/onboard/__tests__/helpers.test.ts index 8123604430..04890eeb34 100644 --- a/freebuff/web/src/app/onboard/__tests__/helpers.test.ts +++ b/freebuff/web/src/app/onboard/__tests__/helpers.test.ts @@ -8,6 +8,7 @@ import { getConsumedCliAuthCodeTokenIdentifier, getConsumedCliAuthCodeTokenValue, isAuthCodeExpired, + isCliAuthCodeCandidate, isOpaqueCliAuthCodeToken, parseAuthCode, resolveCliAuthCode, @@ -114,6 +115,34 @@ describe('freebuff onboard/_helpers', () => { expect(isOpaqueCliAuthCodeToken(`${'A'.repeat(42)}.`)).toBe(false) }) + test('identifies auth code candidates by supported shapes', () => { + const opaqueToken = 'A'.repeat(41) + '-_' + const signedAuthCode = buildCliAuthCode( + testFingerprintId, + '1704067200000', + 'a'.repeat(64), + ) + const legacyAuthCode = `1234567890abcdef-1704067200000-${'b'.repeat( + 64, + )}` + + expect(isCliAuthCodeCandidate(opaqueToken)).toBe(true) + expect(isCliAuthCodeCandidate(signedAuthCode)).toBe(true) + expect(isCliAuthCodeCandidate(legacyAuthCode)).toBe(true) + expect(isCliAuthCodeCandidate(crypto.randomUUID())).toBe(false) + expect(isCliAuthCodeCandidate('F0xe_Mt2yA2az_LUXGxlBsGDIgJ')).toBe(false) + expect( + isCliAuthCodeCandidate( + buildCliAuthCode(testFingerprintId, 'not-a-number', 'a'.repeat(64)), + ), + ).toBe(false) + expect( + isCliAuthCodeCandidate( + buildCliAuthCode(testFingerprintId, '1704067200000', 'short-hash'), + ), + ).toBe(false) + }) + test('hashes auth codes for log correlation without logging the token', () => { expect(getCliAuthCodeHashPrefix('a'.repeat(43))).toBe('66d34fba71f8') expect(getCliAuthCodeHashPrefix(` ${'a'.repeat(43)}\n`)).toBe( diff --git a/freebuff/web/src/app/onboard/_helpers.ts b/freebuff/web/src/app/onboard/_helpers.ts index 58d5204a5f..35901fb112 100644 --- a/freebuff/web/src/app/onboard/_helpers.ts +++ b/freebuff/web/src/app/onboard/_helpers.ts @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto' import { genAuthCode } from '@codebuff/common/util/credentials' const OPAQUE_CLI_AUTH_CODE_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/ +const CLI_AUTH_CODE_HASH_RE = /^[a-f0-9]{64}$/i const CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX = 'cli-login:' const CONSUMED_CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX = 'cli-login-consumed:' const CONSUMED_CLI_AUTH_CODE_TOKEN_VALUE = 'consumed' @@ -23,6 +24,19 @@ export function isOpaqueCliAuthCodeToken(authCode: string): boolean { return OPAQUE_CLI_AUTH_CODE_TOKEN_RE.test(authCode.trim()) } +export function isCliAuthCodeCandidate(authCode: string): boolean { + if (isOpaqueCliAuthCodeToken(authCode)) { + return true + } + + const { fingerprintId, expiresAt, receivedHash } = parseAuthCode(authCode) + return ( + fingerprintId.length > 0 && + /^\d+$/.test(expiresAt) && + CLI_AUTH_CODE_HASH_RE.test(receivedHash) + ) +} + export function getCliAuthCodeHashPrefix(authCode: string): string { return getCliAuthCodeHash(authCode).slice(0, 12) } From 793de91d9aaa93a28a72f50e326aa252428565ac Mon Sep 17 00:00:00 2001 From: brandon chen <9735006+brandonkachen@users.noreply.github.com> Date: Tue, 12 May 2026 15:22:12 -0700 Subject: [PATCH 4/4] Add limited Freebuff DeepSeek access (#657) Co-authored-by: James Grugett --- .../components/freebuff-model-selector.tsx | 99 +- cli/src/components/session-ended-banner.tsx | 7 +- cli/src/components/waiting-room-screen.tsx | 34 +- cli/src/hooks/helpers/send-message.ts | 24 +- cli/src/hooks/use-freebuff-session.ts | 33 +- cli/src/utils/error-handling.ts | 3 + common/src/__tests__/freebuff-models.test.ts | 23 + common/src/constants/freebuff-models.ts | 41 + common/src/types/freebuff-session.ts | 9 + .../migrations/0052_black_fantastic_four.sql | 3 + .../src/db/migrations/meta/0052_snapshot.json | 3534 +++++++++++++++++ .../src/db/migrations/meta/_journal.json | 9 +- packages/internal/src/db/schema.ts | 10 + .../completions/__tests__/completions.test.ts | 44 +- web/src/app/api/v1/chat/completions/_post.ts | 134 +- .../session/__tests__/session.test.ts | 69 +- .../app/api/v1/freebuff/session/_handlers.ts | 106 +- web/src/server/free-mode-country.ts | 7 + .../free-session/__tests__/public-api.test.ts | 218 +- .../__tests__/session-view.test.ts | 3 + web/src/server/free-session/public-api.ts | 196 +- web/src/server/free-session/session-view.ts | 3 + web/src/server/free-session/store.ts | 31 +- web/src/server/free-session/types.ts | 2 + 24 files changed, 4345 insertions(+), 297 deletions(-) create mode 100644 packages/internal/src/db/migrations/0052_black_fantastic_four.sql create mode 100644 packages/internal/src/db/migrations/meta/0052_snapshot.json diff --git a/cli/src/components/freebuff-model-selector.tsx b/cli/src/components/freebuff-model-selector.tsx index 316fbeecef..8ba4138d7f 100644 --- a/cli/src/components/freebuff-model-selector.tsx +++ b/cli/src/components/freebuff-model-selector.tsx @@ -5,8 +5,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Button } from './button' import { FALLBACK_FREEBUFF_MODEL_ID, - FREEBUFF_MODELS, getFreebuffDeploymentAvailabilityLabel, + getFreebuffModelsForAccessTier, isFreebuffModelAvailable, isFreebuffPremiumModelId, } from '@codebuff/common/constants/freebuff-models' @@ -26,8 +26,6 @@ import { import type { FreebuffModelOption } from '@codebuff/common/constants/freebuff-models' import type { KeyEvent } from '@opentui/core' -const FREEBUFF_MODEL_IDS = FREEBUFF_MODELS.map((m) => m.id) - // Section grouping: premium models share one quota pool, unlimited has none. // Putting the tier on a section header lets each row drop its redundant // "Premium"/"Unlimited" chip. The shared 0/5 counter lives in the page title @@ -35,30 +33,11 @@ const FREEBUFF_MODEL_IDS = FREEBUFF_MODELS.map((m) => m.id) // list of choices grouped by tier. Empty sections are filtered so a model set // with no premium (or no unlimited) entries doesn't render an orphan header. type Section = { - key: 'premium' | 'unlimited' + key: 'premium' | 'unlimited' | 'limited' label: string models: readonly FreebuffModelOption[] } -const SECTIONS: readonly Section[] = ( - [ - { - key: 'premium', - label: 'PREMIUM', - models: FREEBUFF_MODELS.filter((m) => - isFreebuffPremiumModelId(m.id), - ), - }, - { - key: 'unlimited', - label: 'UNLIMITED', - models: FREEBUFF_MODELS.filter( - (m) => !isFreebuffPremiumModelId(m.id), - ), - }, - ] satisfies readonly Section[] -).filter((section) => section.models.length > 0) - /** * Dual-purpose model picker: * - Pre-chat landing (session 'none'): user hasn't joined any queue. Picking @@ -86,6 +65,8 @@ export const FreebuffModelSelector: React.FC = () => { const selectedModel = useFreebuffModelStore((s) => s.selectedModel) const setSelectedModel = useFreebuffModelStore((s) => s.setSelectedModel) const session = useFreebuffSessionStore((s) => s.session) + const accessTier = + session && 'accessTier' in session ? session.accessTier : 'full' const now = useNow(60_000) const deploymentAvailabilityLabel = useMemo( () => getFreebuffDeploymentAvailabilityLabel(new Date(now)), @@ -98,9 +79,48 @@ export const FreebuffModelSelector: React.FC = () => { // selected model whenever the selection changes (after a successful switch // or an external selectedModel update). const [focusedId, setFocusedId] = useState(selectedModel) + const availableModels = useMemo( + () => getFreebuffModelsForAccessTier(accessTier), + [accessTier], + ) + const availableModelIds = useMemo( + () => availableModels.map((m) => m.id), + [availableModels], + ) + const sections = useMemo(() => { + if (accessTier === 'limited') { + return [ + { + key: 'limited', + label: 'LIMITED', + models: availableModels, + }, + ] satisfies readonly Section[] + } + return ( + [ + { + key: 'premium', + label: 'PREMIUM', + models: availableModels.filter((m) => isFreebuffPremiumModelId(m.id)), + }, + { + key: 'unlimited', + label: 'UNLIMITED', + models: availableModels.filter( + (m) => !isFreebuffPremiumModelId(m.id), + ), + }, + ] satisfies readonly Section[] + ).filter((section) => section.models.length > 0) + }, [accessTier, availableModels]) useEffect(() => { - setFocusedId(selectedModel) - }, [selectedModel]) + setFocusedId( + availableModelIds.includes(selectedModel) + ? selectedModel + : availableModelIds[0]!, + ) + }, [availableModelIds, selectedModel]) useEffect(() => { // Landing-screen safety net: if the in-memory selection becomes @@ -110,11 +130,12 @@ export const FreebuffModelSelector: React.FC = () => { // preference (e.g. Kimi or DeepSeek) is preserved for the next launch. if ( (session?.status === 'none' || !session) && - !isFreebuffModelAvailable(selectedModel, new Date(now)) + (!availableModelIds.includes(selectedModel) || + !isFreebuffModelAvailable(selectedModel, new Date(now))) ) { - setSelectedModel(FALLBACK_FREEBUFF_MODEL_ID) + setSelectedModel(availableModelIds[0] ?? FALLBACK_FREEBUFF_MODEL_ID) } - }, [now, selectedModel, session, setSelectedModel]) + }, [availableModelIds, now, selectedModel, session, setSelectedModel]) const committedModelId = session?.status === 'queued' ? session.model : null const rateLimitsByModel = getRateLimitsByModel(session) @@ -128,7 +149,7 @@ export const FreebuffModelSelector: React.FC = () => { // terminals where the secondary details spill to an indented second line. const { wrapDetails, buttonOuterWidth, nameColumnWidth } = useMemo(() => { const nameLen = (m: FreebuffModelOption) => m.displayName.length - const maxNameLen = Math.max(...FREEBUFF_MODELS.map(nameLen)) + const maxNameLen = Math.max(...availableModels.map(nameLen)) const detailsParts = (model: FreebuffModelOption): number[] => { const parts = [model.tagline.length] @@ -149,8 +170,7 @@ export const FreebuffModelSelector: React.FC = () => { joinedLen(detailsParts(model)) const maxOneLineOuter = - Math.max(...FREEBUFF_MODELS.map(oneLineLen)) + - BUTTON_CHROME + Math.max(...availableModels.map(oneLineLen)) + BUTTON_CHROME if (maxOneLineOuter <= contentMaxWidth) { return { wrapDetails: false, @@ -173,7 +193,7 @@ export const FreebuffModelSelector: React.FC = () => { return parts.length === 0 ? 0 : 2 /* indent */ + joinedLen(parts) } const maxTwoLineInner = Math.max( - ...FREEBUFF_MODELS.map((m) => + ...availableModels.map((m) => Math.max(labelLineLen(m), detailsLineLen(m)), ), ) @@ -185,7 +205,7 @@ export const FreebuffModelSelector: React.FC = () => { ), nameColumnWidth: maxNameLen, } - }, [contentMaxWidth, deploymentAvailabilityLabel]) + }, [availableModels, contentMaxWidth, deploymentAvailabilityLabel]) const isJoinable = useCallback( (modelId: string) => { @@ -228,7 +248,7 @@ export const FreebuffModelSelector: React.FC = () => { } if (!direction) return const targetId = nextFreebuffModelId({ - modelIds: FREEBUFF_MODEL_IDS, + modelIds: availableModelIds, focusedId, direction, }) @@ -238,7 +258,14 @@ export const FreebuffModelSelector: React.FC = () => { setFocusedId(targetId) } }, - [pending, pick, focusedId, committedModelId, isJoinable], + [ + pending, + pick, + focusedId, + committedModelId, + isJoinable, + availableModelIds, + ], ), ) @@ -345,7 +372,7 @@ export const FreebuffModelSelector: React.FC = () => { gap: 0, }} > - {SECTIONS.map((section, sectionIdx) => ( + {sections.map((section, sectionIdx) => ( = ({ const isQuotaExhausted = premiumQuota ? premiumQuota.recentCount >= premiumQuota.limit : false + const accessTier = useFreebuffSessionStore((s) => + s.session && 'accessTier' in s.session ? s.session.accessTier : 'full', + ) + const quotaLabel = + accessTier === 'limited' ? 'limited sessions' : 'premium sessions' const bannerTitle = premiumQuota - ? `Session ended · ${formatSessionUnits(premiumQuota.recentCount)} of ${premiumQuota.limit} premium sessions used today` + ? `Session ended · ${formatSessionUnits(premiumQuota.recentCount)} of ${premiumQuota.limit} ${quotaLabel} used today` : 'Session ended' // While a request is still streaming, restart is disabled: it would diff --git a/cli/src/components/waiting-room-screen.tsx b/cli/src/components/waiting-room-screen.tsx index 455da1b2af..22a67ace6c 100644 --- a/cli/src/components/waiting-room-screen.tsx +++ b/cli/src/components/waiting-room-screen.tsx @@ -24,7 +24,10 @@ import { } from '../utils/freebuff-premium-reset' import { formatSessionUnits } from '../utils/format-session-units' import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system' -import { FREEBUFF_PREMIUM_SESSION_LIMIT } from '@codebuff/common/constants/freebuff-models' +import { + FREEBUFF_LIMITED_SESSION_LIMIT, + FREEBUFF_PREMIUM_SESSION_LIMIT, +} from '@codebuff/common/constants/freebuff-models' import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session' import type { FreebuffSessionResponse } from '../types/freebuff-session' @@ -255,6 +258,8 @@ export const WaitingRoomScreen: React.FC = ({ const [exitHover, setExitHover] = useState(false) const isQueued = session?.status === 'queued' + const accessTier = + session && 'accessTier' in session ? session.accessTier : 'full' // 'none' = user hasn't joined any queue yet. We're in the pre-chat landing // state: show the picker with live N-in-line hints and a prompt. Picking a // model triggers joinFreebuffQueue, which POSTs and transitions us to @@ -280,14 +285,22 @@ export const WaitingRoomScreen: React.FC = ({ : undefined const sharedPremiumUsed = premiumRateLimit?.recentCount ?? 0 const isPremiumExhausted = - sharedPremiumUsed >= FREEBUFF_PREMIUM_SESSION_LIMIT + sharedPremiumUsed >= + (accessTier === 'limited' + ? FREEBUFF_LIMITED_SESSION_LIMIT + : FREEBUFF_PREMIUM_SESSION_LIMIT) const premiumUsedColor = isPremiumExhausted ? theme.secondary : theme.muted // Pad the used count so the title's centered container doesn't shift width // as the count ticks from "0" → "1.3" → "2" while loading. - const sessionUnitWidth = String(FREEBUFF_PREMIUM_SESSION_LIMIT).length + 2 - const formattedSharedPremiumUsed = formatSessionUnits( - sharedPremiumUsed, - ).padStart(sessionUnitWidth) + const sessionLimit = + accessTier === 'limited' + ? FREEBUFF_LIMITED_SESSION_LIMIT + : FREEBUFF_PREMIUM_SESSION_LIMIT + const sessionLabel = + accessTier === 'limited' ? 'limited sessions' : 'premium sessions' + const sessionUnitWidth = String(sessionLimit).length + 2 + const formattedSharedPremiumUsed = + formatSessionUnits(sharedPremiumUsed).padStart(sessionUnitWidth) const premiumResetAt = getFreebuffPremiumResetAt({ rateLimitsByModel, nowMs: now, @@ -399,8 +412,8 @@ export const WaitingRoomScreen: React.FC = ({ style={{ fg: theme.muted, marginBottom: 1, wrapMode: 'word' }} > - {formattedSharedPremiumUsed} of{' '} - {FREEBUFF_PREMIUM_SESSION_LIMIT} premium sessions used + {formattedSharedPremiumUsed} of {sessionLimit} {sessionLabel}{' '} + used {' · '} @@ -540,7 +553,10 @@ export const WaitingRoomScreen: React.FC = ({ {formatSessionUnits(session.recentCount)} of {session.limit} {' '} - premium sessions today. Try again in{' '} + {session.accessTier === 'limited' + ? 'limited sessions' + : 'premium sessions'}{' '} + today. Try again in{' '} {formatRetryAfter(session.retryAfterMs)} diff --git a/cli/src/hooks/helpers/send-message.ts b/cli/src/hooks/helpers/send-message.ts index cf9063166d..0265e9fdf6 100644 --- a/cli/src/hooks/helpers/send-message.ts +++ b/cli/src/hooks/helpers/send-message.ts @@ -55,7 +55,9 @@ export type ResetEarlyReturnStateParams = { isQueuePausedRef?: MutableRefObject } -export const resetEarlyReturnState = (params: ResetEarlyReturnStateParams): void => { +export const resetEarlyReturnState = ( + params: ResetEarlyReturnStateParams, +): void => { const { setCanProcessQueue, updateChainInProgress, @@ -186,11 +188,12 @@ export const prepareUserMessage = async (params: { } } - const { attachments: imageAttachments, messageContent } = await processImagesForMessage({ - content: finalContent, - pendingImages, - projectRoot: getProjectRoot(), - }) + const { attachments: imageAttachments, messageContent } = + await processImagesForMessage({ + content: finalContent, + pendingImages, + projectRoot: getProjectRoot(), + }) const shouldInsertDivider = lastMessageMode === null || lastMessageMode !== agentMode @@ -214,7 +217,12 @@ export const prepareUserMessage = async (params: { })) // Pass original content (not finalContent) for display, but finalContent goes to agent - const userMessage = getUserMessage(content, imageAttachments, textAttachmentsForMessage, fileAttachmentsForMessage) + const userMessage = getUserMessage( + content, + imageAttachments, + textAttachmentsForMessage, + fileAttachmentsForMessage, + ) const userMessageId = userMessage.id if (imageAttachments.length > 0) { userMessage.attachments = imageAttachments @@ -381,7 +389,6 @@ export const handleRunCompletion = (params: { } if (output.type === 'error') { - if (isOutOfCreditsError(output)) { updater.setError(OUT_OF_CREDITS_MESSAGE) useChatStore.getState().setInputMode('outOfCredits') @@ -527,6 +534,7 @@ function handleFreebuffGateError( switch (kind) { case 'session_expired': case 'waiting_room_required': + case 'session_model_mismatch': // Our seat is gone mid-chat. Finalize the AI message so its streaming // indicator stops — otherwise `isComplete` stays false and the message // keeps rendering a blinking cursor forever, making the user think the diff --git a/cli/src/hooks/use-freebuff-session.ts b/cli/src/hooks/use-freebuff-session.ts index 3de3e92563..fd82a03c62 100644 --- a/cli/src/hooks/use-freebuff-session.ts +++ b/cli/src/hooks/use-freebuff-session.ts @@ -1,6 +1,7 @@ import { env } from '@codebuff/common/env' import { FALLBACK_FREEBUFF_MODEL_ID, + LIMITED_FREEBUFF_MODEL_ID, resolveFreebuffModel, } from '@codebuff/common/constants/freebuff-models' import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session' @@ -365,10 +366,14 @@ export function markFreebuffSessionCountryBlocked(params: { export function markFreebuffSessionEnded(): void { if (!IS_FREEBUFF) return controller?.abort() - const rateLimitsByModel = getRateLimitsByModel( - useFreebuffSessionStore.getState().session, - ) - controller?.apply({ status: 'ended', rateLimitsByModel }) + const current = useFreebuffSessionStore.getState().session + const rateLimitsByModel = getRateLimitsByModel(current) + controller?.apply({ + status: 'ended', + accessTier: + current && 'accessTier' in current ? current.accessTier : undefined, + rateLimitsByModel, + }) } interface UseFreebuffSessionResult { @@ -424,7 +429,12 @@ export function useFreebuffSession(): UseFreebuffSessionResult { const apply = (next: FreebuffSessionResponse) => { if (next.status === 'queued' || next.status === 'active') { + useFreebuffModelStore.getState().setSelectedModel(next.model) recordFreebuffInstanceOwner(next.instanceId) + } else if (next.status === 'none' && next.accessTier === 'limited') { + useFreebuffModelStore + .getState() + .setSelectedModel(LIMITED_FREEBUFF_MODEL_ID) } setSession(next) setError(null) @@ -529,10 +539,18 @@ export function useFreebuffSession(): UseFreebuffSessionResult { (previousStatus === 'active' || previousStatus === 'ended') && next.status === 'none' ) { + const current = useFreebuffSessionStore.getState().session const rateLimitsByModel = - next.rateLimitsByModel ?? - getRateLimitsByModel(useFreebuffSessionStore.getState().session) - apply({ status: 'ended', rateLimitsByModel }) + next.rateLimitsByModel ?? getRateLimitsByModel(current) + apply({ + status: 'ended', + accessTier: + next.accessTier ?? + (current && 'accessTier' in current + ? current.accessTier + : undefined), + rateLimitsByModel, + }) return } @@ -584,6 +602,7 @@ export function useFreebuffSession(): UseFreebuffSessionResult { if (response.status === 'none' || response.status === 'queued') { apply({ status: 'none', + accessTier: response.accessTier, queueDepthByModel: response.queueDepthByModel, rateLimitsByModel: response.rateLimitsByModel, }) diff --git a/cli/src/utils/error-handling.ts b/cli/src/utils/error-handling.ts index 742c5a5072..2d25ae14db 100644 --- a/cli/src/utils/error-handling.ts +++ b/cli/src/utils/error-handling.ts @@ -102,18 +102,21 @@ export const getCountryBlockFromFreeModeError = ( * - 428 `waiting_room_required` — no session row exists; POST /session to join. * - 429 `waiting_room_queued` — row exists but still queued. * - 409 `session_superseded` — another CLI rotated our instance id. + * - 409 `session_model_mismatch` — session tier/model no longer matches. * - 410 `session_expired` — active session's expires_at has passed. */ export type FreebuffGateErrorKind = | 'waiting_room_required' | 'waiting_room_queued' | 'session_superseded' + | 'session_model_mismatch' | 'session_expired' const FREEBUFF_GATE_STATUS: Record = { waiting_room_required: 428, waiting_room_queued: 429, session_superseded: 409, + session_model_mismatch: 409, session_expired: 410, } diff --git a/common/src/__tests__/freebuff-models.test.ts b/common/src/__tests__/freebuff-models.test.ts index 3d70bd7ea0..ee39ed975b 100644 --- a/common/src/__tests__/freebuff-models.test.ts +++ b/common/src/__tests__/freebuff-models.test.ts @@ -7,14 +7,18 @@ import { FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, FREEBUFF_GLM_MODEL_ID, FREEBUFF_KIMI_MODEL_ID, + LIMITED_FREEBUFF_MODEL_ID, FREEBUFF_MINIMAX_MODEL_ID, FREEBUFF_MODELS, SUPPORTED_FREEBUFF_MODELS, getFreebuffDeploymentAvailabilityLabel, + getFreebuffModelsForAccessTier, isFreebuffDeploymentHours, isFreebuffModelId, + isFreebuffModelAllowedForAccessTier, isFreebuffPremiumModelId, isSupportedFreebuffModelId, + resolveFreebuffModelForAccessTier, } from '../constants/freebuff-models' describe('freebuff model availability', () => { @@ -46,6 +50,25 @@ describe('freebuff model availability', () => { ) }) + test('limited access exposes only DeepSeek V4 Flash', () => { + expect(LIMITED_FREEBUFF_MODEL_ID).toBe(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID) + expect(getFreebuffModelsForAccessTier('limited').map((m) => m.id)).toEqual([ + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + ]) + expect( + isFreebuffModelAllowedForAccessTier( + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + 'limited', + ), + ).toBe(true) + expect( + isFreebuffModelAllowedForAccessTier(FREEBUFF_MINIMAX_MODEL_ID, 'limited'), + ).toBe(false) + expect( + resolveFreebuffModelForAccessTier(FREEBUFF_MINIMAX_MODEL_ID, 'limited'), + ).toBe(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID) + }) + test('only smart freebuff models can spawn the gemini-thinker subagent', () => { expect(canFreebuffModelSpawnGeminiThinker(FREEBUFF_KIMI_MODEL_ID)).toBe( true, diff --git a/common/src/constants/freebuff-models.ts b/common/src/constants/freebuff-models.ts index bafaddb055..715b258b50 100644 --- a/common/src/constants/freebuff-models.ts +++ b/common/src/constants/freebuff-models.ts @@ -39,11 +39,17 @@ export const FREEBUFF_GLM_MODEL_ID = 'z-ai/glm-5.1' export const FREEBUFF_KIMI_MODEL_ID = 'moonshotai/kimi-k2.6' export const FREEBUFF_MINIMAX_MODEL_ID = 'minimax/minimax-m2.7' export const FREEBUFF_PREMIUM_SESSION_LIMIT = 5 +export const FREEBUFF_LIMITED_SESSION_LIMIT = 5 export const FREEBUFF_PREMIUM_SESSION_RESET_TIMEZONE = 'America/Los_Angeles' export const FREEBUFF_PREMIUM_SESSION_PERIOD = 'pacific_day' +export const FREEBUFF_LIMITED_SESSION_RESET_TIMEZONE = + FREEBUFF_PREMIUM_SESSION_RESET_TIMEZONE +export const FREEBUFF_LIMITED_SESSION_PERIOD = FREEBUFF_PREMIUM_SESSION_PERIOD /** Deprecated wire compatibility field. Premium usage now resets at midnight * Pacific time rather than using a rolling hourly window. */ export const FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS = 24 +export const FREEBUFF_LIMITED_SESSION_WINDOW_HOURS = + FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS const FREEBUFF_EASTERN_TIMEZONE = 'America/New_York' const FREEBUFF_PACIFIC_TIMEZONE = 'America/Los_Angeles' @@ -135,6 +141,30 @@ export const DEFAULT_FREEBUFF_MODEL_ID: FreebuffModelId = export const FALLBACK_FREEBUFF_MODEL_ID: FreebuffModelId = FREEBUFF_MINIMAX_MODEL_ID +export const LIMITED_FREEBUFF_MODEL_ID: FreebuffModelId = + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID +export const LIMITED_FREEBUFF_MODELS = FREEBUFF_MODELS.filter( + (model) => model.id === LIMITED_FREEBUFF_MODEL_ID, +) + +export type FreebuffAccessTier = 'full' | 'limited' + +export function getFreebuffModelsForAccessTier( + accessTier: FreebuffAccessTier | null | undefined, +): readonly FreebuffModelOption[] { + if (accessTier === 'limited') return LIMITED_FREEBUFF_MODELS + return FREEBUFF_MODELS +} + +export function isFreebuffModelAllowedForAccessTier( + model: string | null | undefined, + accessTier: FreebuffAccessTier | null | undefined, +): boolean { + if (!model) return false + if (accessTier !== 'limited') return isSupportedFreebuffModelId(model) + return model === LIMITED_FREEBUFF_MODEL_ID +} + export function isFreebuffModelId( id: string | null | undefined, ): id is FreebuffModelId { @@ -148,6 +178,17 @@ export function resolveFreebuffModel( return isFreebuffModelId(id) ? id : FALLBACK_FREEBUFF_MODEL_ID } +export function resolveFreebuffModelForAccessTier( + id: string | null | undefined, + accessTier: FreebuffAccessTier | null | undefined, +): SupportedFreebuffModelId { + if (accessTier === 'limited') return LIMITED_FREEBUFF_MODEL_ID + const resolved = resolveSupportedFreebuffModel(id) + return isFreebuffModelAllowedForAccessTier(resolved, accessTier) + ? resolved + : FALLBACK_FREEBUFF_MODEL_ID +} + export function isSupportedFreebuffModelId( id: string | null | undefined, ): id is SupportedFreebuffModelId { diff --git a/common/src/types/freebuff-session.ts b/common/src/types/freebuff-session.ts index 9dbf191492..0ba7399c5b 100644 --- a/common/src/types/freebuff-session.ts +++ b/common/src/types/freebuff-session.ts @@ -1,3 +1,5 @@ +import type { FreebuffAccessTier } from '../constants/freebuff-models' + /** * Wire-level shapes returned by `/api/v1/freebuff/session`. Source of truth * for the CLI (which deserializes these) and the server (which serializes @@ -74,6 +76,7 @@ export type FreebuffSessionServerResponse = * when `getSessionState` notices the user has been swept past the * grace window. */ status: 'none' + accessTier?: FreebuffAccessTier message?: string /** Snapshot of every model's queue depth at GET time. The picker no * longer renders this (queues effectively never form at current @@ -88,6 +91,7 @@ export type FreebuffSessionServerResponse = } | { status: 'queued' + accessTier: FreebuffAccessTier instanceId: string /** Model the user is queued for. Each model has its own queue. */ model: string @@ -107,6 +111,7 @@ export type FreebuffSessionServerResponse = } | { status: 'active' + accessTier: FreebuffAccessTier instanceId: string /** Model the active session is bound to — cannot change mid-session. */ model: string @@ -128,6 +133,7 @@ export type FreebuffSessionServerResponse = * client may also synthesize a no-grace `{ status: 'ended' }` when a * poll reveals the row was swept. Both render the same UI. */ status: 'ended' + accessTier?: FreebuffAccessTier instanceId?: string admittedAt?: string expiresAt?: string @@ -165,12 +171,14 @@ export type FreebuffSessionServerResponse = * your active DeepSeek session to switch?" → on confirm, DELETE then * re-POST with the new model. */ status: 'model_locked' + accessTier?: FreebuffAccessTier currentModel: string requestedModel: string } | { /** Requested model is valid but not selectable right now. */ status: 'model_unavailable' + accessTier?: FreebuffAccessTier requestedModel: string availableHours: string } @@ -188,6 +196,7 @@ export type FreebuffSessionServerResponse = * reset. Terminal for the CLI's current poll session; the user can exit * and come back later. */ status: 'rate_limited' + accessTier?: FreebuffAccessTier /** The freebuff model the user tried to join. */ model: string /** Max premium session units permitted per Pacific day (e.g. 5). */ diff --git a/packages/internal/src/db/migrations/0052_black_fantastic_four.sql b/packages/internal/src/db/migrations/0052_black_fantastic_four.sql new file mode 100644 index 0000000000..07f2812bb4 --- /dev/null +++ b/packages/internal/src/db/migrations/0052_black_fantastic_four.sql @@ -0,0 +1,3 @@ +CREATE TYPE "public"."freebuff_access_tier" AS ENUM('full', 'limited');--> statement-breakpoint +ALTER TABLE "free_session" ADD COLUMN "access_tier" "freebuff_access_tier" DEFAULT 'full' NOT NULL;--> statement-breakpoint +ALTER TABLE "free_session_admit" ADD COLUMN "access_tier" "freebuff_access_tier" DEFAULT 'full' NOT NULL; \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/0052_snapshot.json b/packages/internal/src/db/migrations/meta/0052_snapshot.json new file mode 100644 index 0000000000..9316b9b668 --- /dev/null +++ b/packages/internal/src/db/migrations/meta/0052_snapshot.json @@ -0,0 +1,3534 @@ +{ + "id": "7740c15d-089b-41b6-942e-c6b9d3617c6a", + "prevId": "ead7b227-50a8-4758-8dbf-a5a402606f64", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ad_impression": { + "name": "ad_impression", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'gravity'" + }, + "ad_text": { + "name": "ad_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cta": { + "name": "cta", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "click_url": { + "name": "click_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imp_url": { + "name": "imp_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "extra_pixels": { + "name": "extra_pixels", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "payout": { + "name": "payout", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false + }, + "credits_granted": { + "name": "credits_granted", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grant_operation_id": { + "name": "grant_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "served_at": { + "name": "served_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "impression_fired_at": { + "name": "impression_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "clicked_at": { + "name": "clicked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_ad_impression_user": { + "name": "idx_ad_impression_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_ad_impression_imp_url": { + "name": "idx_ad_impression_imp_url", + "columns": [ + { + "expression": "imp_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ad_impression_user_id_user_id_fk": { + "name": "ad_impression_user_id_user_id_fk", + "tableFrom": "ad_impression", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ad_impression_imp_url_unique": { + "name": "ad_impression_imp_url_unique", + "nullsNotDistinct": false, + "columns": [ + "imp_url" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_config": { + "name": "agent_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "major": { + "name": "major", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 1) AS INTEGER)", + "type": "stored" + } + }, + "minor": { + "name": "minor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 2) AS INTEGER)", + "type": "stored" + } + }, + "patch": { + "name": "patch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CAST(SPLIT_PART(\"agent_config\".\"version\", '.', 3) AS INTEGER)", + "type": "stored" + } + }, + "data": { + "name": "data", + "type": "jsonb", + "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": { + "idx_agent_config_publisher": { + "name": "idx_agent_config_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_config_publisher_id_publisher_id_fk": { + "name": "agent_config_publisher_id_publisher_id_fk", + "tableFrom": "agent_config", + "tableTo": "publisher", + "columnsFrom": [ + "publisher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_config_publisher_id_id_version_pk": { + "name": "agent_config_publisher_id_id_version_pk", + "columns": [ + "publisher_id", + "id", + "version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_run": { + "name": "agent_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publisher_id": { + "name": "publisher_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '/', 1)\n ELSE NULL\n END", + "type": "stored" + } + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(split_part(agent_id, '/', 2), '@', 1)\n ELSE agent_id\n END", + "type": "stored" + } + }, + "agent_version": { + "name": "agent_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE\n WHEN agent_id ~ '^[^/@]+/[^/@]+@[^/@]+$'\n THEN split_part(agent_id, '@', 2)\n ELSE NULL\n END", + "type": "stored" + } + }, + "ancestor_run_ids": { + "name": "ancestor_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "root_run_id": { + "name": "root_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[1] ELSE id END", + "type": "stored" + } + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN array_length(ancestor_run_ids, 1) >= 1 THEN ancestor_run_ids[array_length(ancestor_run_ids, 1)] ELSE NULL END", + "type": "stored" + } + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "COALESCE(array_length(ancestor_run_ids, 1), 1)", + "type": "stored" + } + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "total_steps": { + "name": "total_steps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "direct_credits": { + "name": "direct_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_credits": { + "name": "total_credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "agent_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "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()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_agent_run_user_id": { + "name": "idx_agent_run_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_parent": { + "name": "idx_agent_run_parent", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_root": { + "name": "idx_agent_run_root", + "columns": [ + { + "expression": "root_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_agent_id": { + "name": "idx_agent_run_agent_id", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_publisher": { + "name": "idx_agent_run_publisher", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_status": { + "name": "idx_agent_run_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_ancestors_gin": { + "name": "idx_agent_run_ancestors_gin", + "columns": [ + { + "expression": "ancestor_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_agent_run_completed_publisher_agent": { + "name": "idx_agent_run_completed_publisher_agent", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_recent": { + "name": "idx_agent_run_completed_recent", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_version": { + "name": "idx_agent_run_completed_version", + "columns": [ + { + "expression": "publisher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_run_completed_user": { + "name": "idx_agent_run_completed_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_run\".\"status\" = 'completed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_run_user_id_user_id_fk": { + "name": "agent_run_user_id_user_id_fk", + "tableFrom": "agent_run", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_step": { + "name": "agent_step", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_run_id": { + "name": "agent_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN completed_at IS NOT NULL THEN EXTRACT(EPOCH FROM (completed_at - created_at)) * 1000 ELSE NULL END::integer", + "type": "stored" + } + }, + "credits": { + "name": "credits", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "child_run_ids": { + "name": "child_run_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "spawned_count": { + "name": "spawned_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "array_length(child_run_ids, 1)", + "type": "stored" + } + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "agent_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "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()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "unique_step_number_per_run": { + "name": "unique_step_number_per_run", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_run_id": { + "name": "idx_agent_step_run_id", + "columns": [ + { + "expression": "agent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_step_children_gin": { + "name": "idx_agent_step_children_gin", + "columns": [ + { + "expression": "child_run_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "agent_step_agent_run_id_agent_run_id_fk": { + "name": "agent_step_agent_run_id_agent_run_id_fk", + "tableFrom": "agent_step", + "tableTo": "agent_run", + "columnsFrom": [ + "agent_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credit_ledger": { + "name": "credit_ledger", + "schema": "", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal": { + "name": "principal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "grant_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_credit_ledger_active_balance": { + "name": "idx_credit_ledger_active_balance", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "balance", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"credit_ledger\".\"balance\" != 0 AND \"credit_ledger\".\"expires_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_org": { + "name": "idx_credit_ledger_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_credit_ledger_subscription": { + "name": "idx_credit_ledger_subscription", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credit_ledger_user_id_user_id_fk": { + "name": "credit_ledger_user_id_user_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credit_ledger_org_id_org_id_fk": { + "name": "credit_ledger_org_id_org_id_fk", + "tableFrom": "credit_ledger", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.encrypted_api_keys": { + "name": "encrypted_api_keys", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "api_key_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "encrypted_api_keys_user_id_user_id_fk": { + "name": "encrypted_api_keys_user_id_user_id_fk", + "tableFrom": "encrypted_api_keys", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "encrypted_api_keys_user_id_type_pk": { + "name": "encrypted_api_keys_user_id_type_pk", + "columns": [ + "user_id", + "type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fingerprint": { + "name": "fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sig_hash": { + "name": "sig_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_mode_country_access_cache": { + "name": "free_mode_country_access_cache", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_ip_hash": { + "name": "client_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed": { + "name": "allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cf_country": { + "name": "cf_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "geoip_country": { + "name": "geoip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_block_reason": { + "name": "country_block_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_privacy_signals": { + "name": "ip_privacy_signals", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "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": { + "idx_free_mode_country_cache_expires_at": { + "name": "idx_free_mode_country_cache_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_mode_country_access_cache_user_id_user_id_fk": { + "name": "free_mode_country_access_cache_user_id_user_id_fk", + "tableFrom": "free_mode_country_access_cache", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "free_mode_country_access_cache_user_id_client_ip_hash_pk": { + "name": "free_mode_country_access_cache_user_id_client_ip_hash_pk", + "columns": [ + "user_id", + "client_ip_hash" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_session": { + "name": "free_session", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "free_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "active_instance_id": { + "name": "active_instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_tier": { + "name": "access_tier", + "type": "freebuff_access_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cf_country": { + "name": "cf_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "geoip_country": { + "name": "geoip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_block_reason": { + "name": "country_block_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_privacy_signals": { + "name": "ip_privacy_signals", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "client_ip_hash": { + "name": "client_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_checked_at": { + "name": "country_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "admitted_at": { + "name": "admitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_free_session_queue": { + "name": "idx_free_session_queue", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_free_session_expiry": { + "name": "idx_free_session_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_session_user_id_user_id_fk": { + "name": "free_session_user_id_user_id_fk", + "tableFrom": "free_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_session_admit": { + "name": "free_session_admit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_tier": { + "name": "access_tier", + "type": "freebuff_access_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "admitted_at": { + "name": "admitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session_units": { + "name": "session_units", + "type": "numeric(3, 1)", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + } + }, + "indexes": { + "idx_free_session_admit_user_model_time": { + "name": "idx_free_session_admit_user_model_time", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "admitted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "free_session_admit_user_id_user_id_fk": { + "name": "free_session_admit_user_id_user_id_fk", + "tableFrom": "free_session_admit", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_eval_results": { + "name": "git_eval_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cost_mode": { + "name": "cost_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoner_model": { + "name": "reasoner_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_model": { + "name": "agent_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.limit_override": { + "name": "limit_override", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credits_per_block": { + "name": "credits_per_block", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "block_duration_hours": { + "name": "block_duration_hours", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "weekly_credit_limit": { + "name": "weekly_credit_limit", + "type": "integer", + "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": {}, + "foreignKeys": { + "limit_override_user_id_user_id_fk": { + "name": "limit_override_user_id_user_id_fk", + "tableFrom": "limit_override", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message": { + "name": "message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "\"message\".\"request\" -> -1", + "type": "stored" + } + }, + "reasoning_text": { + "name": "reasoning_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "numeric(100, 20)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "byok": { + "name": "byok", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttft_ms": { + "name": "ttft_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "message_user_id_idx": { + "name": "message_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_finished_at_user_id_idx": { + "name": "message_finished_at_user_id_idx", + "columns": [ + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_idx": { + "name": "message_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "message_org_id_finished_at_idx": { + "name": "message_org_id_finished_at_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_user_id_user_id_fk": { + "name": "message_user_id_user_id_fk", + "tableFrom": "message", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_org_id_org_id_fk": { + "name": "message_org_id_org_id_fk", + "tableFrom": "message", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org": { + "name": "org", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credit_limit": { + "name": "credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billing_alerts": { + "name": "billing_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "usage_alerts": { + "name": "usage_alerts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weekly_reports": { + "name": "weekly_reports", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "org_owner_id_user_id_fk": { + "name": "org_owner_id_user_id_fk", + "tableFrom": "org", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_slug_unique": { + "name": "org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "org_stripe_customer_id_unique": { + "name": "org_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_feature": { + "name": "org_feature", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_org_feature_active": { + "name": "idx_org_feature_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_feature_org_id_org_id_fk": { + "name": "org_feature_org_id_org_id_fk", + "tableFrom": "org_feature", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_feature_org_id_feature_pk": { + "name": "org_feature_org_id_feature_pk", + "columns": [ + "org_id", + "feature" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_invite": { + "name": "org_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_org_invite_token": { + "name": "idx_org_invite_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_email": { + "name": "idx_org_invite_email", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_invite_expires": { + "name": "idx_org_invite_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_invite_org_id_org_id_fk": { + "name": "org_invite_org_id_org_id_fk", + "tableFrom": "org_invite", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_invite_invited_by_user_id_fk": { + "name": "org_invite_invited_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "org_invite_accepted_by_user_id_fk": { + "name": "org_invite_accepted_by_user_id_fk", + "tableFrom": "org_invite", + "tableTo": "user", + "columnsFrom": [ + "accepted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_invite_token_unique": { + "name": "org_invite_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_member": { + "name": "org_member", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_member_org_id_org_id_fk": { + "name": "org_member_org_id_org_id_fk", + "tableFrom": "org_member", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_member_user_id_user_id_fk": { + "name": "org_member_user_id_user_id_fk", + "tableFrom": "org_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_member_org_id_user_id_pk": { + "name": "org_member_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_repo": { + "name": "org_repo", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "idx_org_repo_active": { + "name": "idx_org_repo_active", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_repo_unique": { + "name": "idx_org_repo_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_repo_org_id_org_id_fk": { + "name": "org_repo_org_id_org_id_fk", + "tableFrom": "org_repo", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_repo_approved_by_user_id_fk": { + "name": "org_repo_approved_by_user_id_fk", + "tableFrom": "org_repo", + "tableTo": "user", + "columnsFrom": [ + "approved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publisher": { + "name": "publisher", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "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": {}, + "foreignKeys": { + "publisher_user_id_user_id_fk": { + "name": "publisher_user_id_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_org_id_org_id_fk": { + "name": "publisher_org_id_org_id_fk", + "tableFrom": "publisher", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "publisher_created_by_user_id_fk": { + "name": "publisher_created_by_user_id_fk", + "tableFrom": "publisher", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "publisher_single_owner": { + "name": "publisher_single_owner", + "value": "(\"publisher\".\"user_id\" IS NOT NULL AND \"publisher\".\"org_id\" IS NULL) OR\n (\"publisher\".\"user_id\" IS NULL AND \"publisher\".\"org_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.referral": { + "name": "referral", + "schema": "", + "columns": { + "referrer_id": { + "name": "referrer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referred_id": { + "name": "referred_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "referral_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_legacy": { + "name": "is_legacy", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "referral_referrer_id_user_id_fk": { + "name": "referral_referrer_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referrer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "referral_referred_id_user_id_fk": { + "name": "referral_referred_id_user_id_fk", + "tableFrom": "referral", + "tableTo": "user", + "columnsFrom": [ + "referred_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "referral_referrer_id_referred_id_pk": { + "name": "referral_referrer_id_referred_id_pk", + "columns": [ + "referrer_id", + "referred_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "fingerprint_id": { + "name": "fingerprint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_auth_hash": { + "name": "cli_auth_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "session_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_cli_auth_code_idx": { + "name": "session_cli_auth_code_idx", + "columns": [ + { + "expression": "fingerprint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cli_auth_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"session\".\"cli_auth_hash\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_fingerprint_id_fingerprint_id_fk": { + "name": "session_fingerprint_id_fingerprint_id_fk", + "tableFrom": "session", + "tableTo": "fingerprint", + "columnsFrom": [ + "fingerprint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scheduled_tier": { + "name": "scheduled_tier", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_subscription_customer": { + "name": "idx_subscription_customer", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_user": { + "name": "idx_subscription_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscription_status": { + "name": "idx_subscription_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_user_id_user_id_fk": { + "name": "subscription_user_id_user_id_fk", + "tableFrom": "subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_failure": { + "name": "sync_failure", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_sync_failure_retry": { + "name": "idx_sync_failure_retry", + "columns": [ + { + "expression": "retry_count", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sync_failure\".\"retry_count\" < 5", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_quota_reset": { + "name": "next_quota_reset", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now() + INTERVAL '1 month'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referral_code": { + "name": "referral_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'ref-' || gen_random_uuid()" + }, + "referral_limit": { + "name": "referral_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_topup_enabled": { + "name": "auto_topup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_topup_threshold": { + "name": "auto_topup_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_topup_amount": { + "name": "auto_topup_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fallback_to_a_la_carte": { + "name": "fallback_to_a_la_carte", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_stripe_customer_id_unique": { + "name": "user_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + }, + "user_referral_code_unique": { + "name": "user_referral_code_unique", + "nullsNotDistinct": false, + "columns": [ + "referral_code" + ] + }, + "user_discord_id_unique": { + "name": "user_discord_id_unique", + "nullsNotDistinct": false, + "columns": [ + "discord_id" + ] + }, + "user_handle_unique": { + "name": "user_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.referral_status": { + "name": "referral_status", + "schema": "public", + "values": [ + "pending", + "completed" + ] + }, + "public.agent_run_status": { + "name": "agent_run_status", + "schema": "public", + "values": [ + "running", + "completed", + "failed", + "cancelled" + ] + }, + "public.agent_step_status": { + "name": "agent_step_status", + "schema": "public", + "values": [ + "running", + "completed", + "skipped" + ] + }, + "public.api_key_type": { + "name": "api_key_type", + "schema": "public", + "values": [ + "anthropic", + "gemini", + "openai" + ] + }, + "public.free_session_status": { + "name": "free_session_status", + "schema": "public", + "values": [ + "queued", + "active" + ] + }, + "public.freebuff_access_tier": { + "name": "freebuff_access_tier", + "schema": "public", + "values": [ + "full", + "limited" + ] + }, + "public.grant_type": { + "name": "grant_type", + "schema": "public", + "values": [ + "free", + "referral", + "referral_legacy", + "subscription", + "purchase", + "admin", + "organization", + "ad" + ] + }, + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.session_type": { + "name": "session_type", + "schema": "public", + "values": [ + "web", + "pat", + "cli" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "incomplete", + "incomplete_expired", + "trialing", + "active", + "past_due", + "canceled", + "unpaid", + "paused" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/internal/src/db/migrations/meta/_journal.json b/packages/internal/src/db/migrations/meta/_journal.json index d45dbc4759..924342398c 100644 --- a/packages/internal/src/db/migrations/meta/_journal.json +++ b/packages/internal/src/db/migrations/meta/_journal.json @@ -365,6 +365,13 @@ "when": 1778611718988, "tag": "0051_easy_sersi", "breakpoints": true + }, + { + "idx": 52, + "version": "7", + "when": 1778617513407, + "tag": "0052_black_fantastic_four", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/internal/src/db/schema.ts b/packages/internal/src/db/schema.ts index e6c1d013c2..0bdf007e20 100644 --- a/packages/internal/src/db/schema.ts +++ b/packages/internal/src/db/schema.ts @@ -826,6 +826,10 @@ export const freeSessionStatusEnum = pgEnum('free_session_status', [ 'queued', 'active', ]) +export const freebuffAccessTierEnum = pgEnum('freebuff_access_tier', [ + 'full', + 'limited', +]) /** * Free-user session / waiting-room state. One row per user is enforced by the @@ -853,6 +857,9 @@ export const freeSession = pgTable( * its own queue (admission picks one queued user per model per tick) and * the model is fixed for the life of an active session. */ model: text('model').notNull(), + access_tier: freebuffAccessTierEnum('access_tier') + .notNull() + .default('full'), /** Resolved country/privacy metadata from the latest successful * free-session POST country gate. Raw IP is not stored; `client_ip_hash` * is HMAC-SHA256 with the server auth secret for correlation only. */ @@ -976,6 +983,9 @@ export const freeSessionAdmit = pgTable( .notNull() .references(() => user.id, { onDelete: 'cascade' }), model: text('model').notNull(), + access_tier: freebuffAccessTierEnum('access_tier') + .notNull() + .default('full'), admitted_at: timestamp('admitted_at', { mode: 'date', withTimezone: true, diff --git a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts index b8bec42579..80ca4f02d1 100644 --- a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts +++ b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts @@ -580,7 +580,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { ) it( - 'skips duplicate country checks when an active freebuff session gate admits the request', + 'classifies country access before the active freebuff session gate', async () => { const req = new NextRequest( 'http://localhost:3000/api/v1/chat/completions', @@ -592,10 +592,10 @@ describe('/api/v1/chat/completions POST endpoint', () => { 'x-forwarded-for': '8.8.8.8', }, body: JSON.stringify({ - model: 'minimax/minimax-m2.7', + model: FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, stream: false, codebuff_metadata: { - run_id: 'run-free', + run_id: 'run-free-deepseek-flash', client_id: 'test-client-id-123', cost_mode: 'free', freebuff_instance_id: 'active-instance-123', @@ -614,8 +614,10 @@ describe('/api/v1/chat/completions POST endpoint', () => { fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, loggerWithContext: mockLoggerWithContext, - checkSessionAdmissible: async () => - ({ ok: true, reason: 'active', remainingMs: 60_000 }) as const, + checkSessionAdmissible: async (params) => { + expect(params.accessTier).toBe('limited') + return { ok: true, reason: 'active', remainingMs: 60_000 } as const + }, }) expect(response.status).toBe(200) @@ -702,7 +704,12 @@ describe('/api/v1/chat/completions POST endpoint', () => { FETCH_PATH_TEST_TIMEOUT_MS, ) - it('rejects free-mode requests when location is unknown', async () => { + it('limits unknown-location free-mode requests to DeepSeek Flash', async () => { + const checkSessionAdmissible = mock(async () => { + throw new Error( + 'limited model enforcement should run before session gate', + ) + }) // Use a TEST-NET-1 IP (RFC 5737) that geoip-lite cannot resolve, with // no cf-ipcountry header. This avoids the dev-only localhost bypass // (which kicks in when there is no cf-ipcountry AND no/loopback IP). @@ -736,17 +743,21 @@ describe('/api/v1/chat/completions POST endpoint', () => { fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, loggerWithContext: mockLoggerWithContext, - checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + checkSessionAdmissible, }) - expect(response.status).toBe(403) + expect(response.status).toBe(409) const body = await response.json() - expect(body.error).toBe('free_mode_unavailable') - expect(body.countryCode).toBe('UNKNOWN') - expect(body.countryBlockReason).toBe('unresolved_client_ip') + expect(body.error).toBe('session_model_mismatch') + expect(checkSessionAdmissible).toHaveBeenCalledTimes(0) }) - it('rejects free-mode requests from anonymized Cloudflare country codes', async () => { + it('classifies anonymized Cloudflare country codes as limited access', async () => { + const checkSessionAdmissible = mock(async () => { + throw new Error( + 'limited model enforcement should run before session gate', + ) + }) const req = new NextRequest( 'http://localhost:3000/api/v1/chat/completions', { @@ -778,14 +789,13 @@ describe('/api/v1/chat/completions POST endpoint', () => { fetch: mockFetch, insertMessageBigquery: mockInsertMessageBigquery, loggerWithContext: mockLoggerWithContext, - checkSessionAdmissible: mockCheckSessionAdmissibleAllow, + checkSessionAdmissible, }) - expect(response.status).toBe(403) + expect(response.status).toBe(409) const body = await response.json() - expect(body.error).toBe('free_mode_unavailable') - expect(body.countryCode).toBe('UNKNOWN') - expect(body.countryBlockReason).toBe('anonymized_or_unknown_country') + expect(body.error).toBe('session_model_mismatch') + expect(checkSessionAdmissible).toHaveBeenCalledTimes(0) }) it( diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index 2adaea3dde..eb7fe53948 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -1,5 +1,10 @@ import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import { BYOK_OPENROUTER_HEADER } from '@codebuff/common/constants/byok' +import { + FREEBUFF_GEMINI_PRO_MODEL_ID, + isFreebuffModelAllowedForAccessTier, + isSupportedFreebuffModelId, +} from '@codebuff/common/constants/freebuff-models' import { isFreebuffGeminiThinkerAgent, isFreebuffRootAgent, @@ -86,6 +91,7 @@ import { } from '@/llm-api/openrouter' import { checkSessionAdmissible } from '@/server/free-session/public-api' import { getCachedFreeModeCountryAccess } from '@/server/free-mode-country-access-cache' +import { getFreeModeAccessTier } from '@/server/free-mode-country' import type { SessionGateResult } from '@/server/free-session/public-api' import type { @@ -286,6 +292,7 @@ export async function postChatCompletions(params: { const userId = userInfo.id const stripeCustomerId = userInfo.stripe_customer_id ?? null + let freebuffAccessTier: 'full' | 'limited' = 'full' // Check if user is banned. // We use a clear, helpful message rather than a cryptic error because: @@ -316,6 +323,48 @@ export async function postChatCompletions(params: { logger, }) + // For free mode requests, classify the request into full or limited + // access. Disallowed countries and anonymized networks are no longer + // blocked outright; they are limited to the cheap DeepSeek Flash path. + if (isFreeModeRequest) { + const countryAccess = await resolveCountryAccess(userId, req, { + fetch, + ipinfoToken: env.IPINFO_TOKEN, + ipHashSecret: env.NEXTAUTH_SECRET, + allowLocalhost: env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev', + }) + freebuffAccessTier = getFreeModeAccessTier(countryAccess) + + if (!countryAccess.allowed || sampleFreebuffSuccess) { + logger.info( + { + cfHeader: countryAccess.cfCountry, + geoipResult: countryAccess.geoipCountry, + resolvedCountry: countryAccess.countryCode, + countryBlockReason: countryAccess.blockReason, + ipPrivacySignals: countryAccess.ipPrivacy?.signals, + clientIp: countryAccess.hasClientIp ? '[redacted]' : undefined, + }, + 'Free mode country detection', + ) + } + + if (!countryAccess.allowed) { + trackEvent({ + event: AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR, + userId, + properties: { + error: 'free_mode_not_available_in_country', + countryCode: countryAccess.countryCode, + countryBlockReason: countryAccess.blockReason, + ipPrivacySignals: countryAccess.ipPrivacy?.signals, + clientIp: countryAccess.hasClientIp ? '[redacted]' : undefined, + }, + logger, + }) + } + } + // Extract and validate agent run ID const runIdFromBody = typedBody.codebuff_metadata?.run_id if (!runIdFromBody || typeof runIdFromBody !== 'string') { @@ -446,6 +495,33 @@ export async function postChatCompletions(params: { } } + if ( + isFreeModeRequest && + freebuffAccessTier === 'limited' && + (isSupportedFreebuffModelId(typedBody.model) || + typedBody.model === FREEBUFF_GEMINI_PRO_MODEL_ID) && + !isFreebuffModelAllowedForAccessTier(typedBody.model, freebuffAccessTier) + ) { + trackEvent({ + event: AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR, + userId, + properties: { + error: 'session_model_mismatch', + model: typedBody.model, + accessTier: freebuffAccessTier, + }, + logger, + }) + return NextResponse.json( + { + error: 'session_model_mismatch', + message: + 'Limited free access is only available with DeepSeek V4 Flash.', + }, + { status: STATUS_BY_GATE_CODE.session_model_mismatch }, + ) + } + let freeModeSessionGate: SessionGateResult | null = null // Freebuff waiting-room gate. Usually enforced only when @@ -456,6 +532,7 @@ export async function postChatCompletions(params: { typedBody.codebuff_metadata?.freebuff_instance_id freeModeSessionGate = await checkSession({ userId, + accessTier: freebuffAccessTier, userEmail: userInfo.email, claimedInstanceId, requestedModel: typedBody.model, @@ -478,63 +555,6 @@ export async function postChatCompletions(params: { } } - // For free mode requests, require a resolved allowlisted country only - // when the waiting-room gate is disabled/bypassed. Active waiting-room - // sessions already passed the POST /freebuff/session country/privacy gate, - // so repeating IPinfo/GeoIP work on every chat completion just burns hot - // path capacity. - if ( - isFreeModeRequest && - (!freeModeSessionGate || freeModeSessionGate.reason === 'disabled') - ) { - const countryAccess = await resolveCountryAccess(userId, req, { - fetch, - ipinfoToken: env.IPINFO_TOKEN, - ipHashSecret: env.NEXTAUTH_SECRET, - allowLocalhost: env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev', - }) - - if (!countryAccess.allowed || sampleFreebuffSuccess) { - logger.info( - { - cfHeader: countryAccess.cfCountry, - geoipResult: countryAccess.geoipCountry, - resolvedCountry: countryAccess.countryCode, - countryBlockReason: countryAccess.blockReason, - ipPrivacySignals: countryAccess.ipPrivacy?.signals, - clientIp: countryAccess.hasClientIp ? '[redacted]' : undefined, - }, - 'Free mode country detection', - ) - } - - if (!countryAccess.allowed) { - trackEvent({ - event: AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR, - userId, - properties: { - error: 'free_mode_not_available_in_country', - countryCode: countryAccess.countryCode, - countryBlockReason: countryAccess.blockReason, - ipPrivacySignals: countryAccess.ipPrivacy?.signals, - clientIp: countryAccess.hasClientIp ? '[redacted]' : undefined, - }, - logger, - }) - - return NextResponse.json( - { - error: 'free_mode_unavailable', - message: 'Free mode is not available in your country.', - countryCode: countryAccess.countryCode ?? 'UNKNOWN', - countryBlockReason: countryAccess.blockReason, - ipPrivacySignals: countryAccess.ipPrivacy?.signals, - }, - { status: 403 }, - ) - } - } - // Rate limit free mode requests (after validation so invalid requests don't consume quota) if (isFreeModeRequest) { const rateLimitResult = checkFreeModeRateLimit(userId) diff --git a/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts b/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts index edc852f6ef..00c1d15889 100644 --- a/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts +++ b/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts @@ -7,6 +7,7 @@ import { getFreebuffSession, postFreebuffSession, } from '../_handlers' +import { FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID } from '@codebuff/common/constants/freebuff-models' import type { FreebuffSessionDeps } from '../_handlers' import type { FreeModeCountryAccess } from '@/server/free-mode-country' @@ -127,12 +128,19 @@ function makeSessionDeps(overrides: Partial = {}): SessionDeps & { endSession: async ({ userId }) => { rows.delete(userId) }, - joinOrTakeOver: async ({ userId, model, now, countryAccess }) => { + joinOrTakeOver: async ({ + userId, + model, + accessTier, + now, + countryAccess, + }) => { const r: InternalSessionRow = { user_id: userId, status: 'queued', active_instance_id: `inst-${++instanceCounter}`, model, + access_tier: accessTier, country_code: countryAccess?.countryCode ?? null, cf_country: countryAccess?.cfCountry ?? null, geoip_country: countryAccess?.geoipCountry ?? null, @@ -227,48 +235,48 @@ describe('POST /api/v1/freebuff/session', () => { expect(body.status).toBe('disabled') }) - test('returns country_blocked without joining the queue for disallowed country', async () => { + test('creates a limited DeepSeek Flash session for disallowed country', async () => { const sessionDeps = makeSessionDeps() const resp = await postFreebuffSession( - makeReq('ok', { cfCountry: 'JP' }), + makeReq('ok', { cfCountry: 'JP', model: DEFAULT_MODEL }), makeDeps(sessionDeps, 'u1'), ) - // 403 (not 200) so older CLIs that don't know `country_blocked` fall into - // their error-retry backoff instead of tight-polling. - expect(resp.status).toBe(403) + expect(resp.status).toBe(200) const body = await resp.json() - expect(body.status).toBe('country_blocked') - expect(body.countryCode).toBe('JP') - expect(body.countryBlockReason).toBe('country_not_allowed') - expect(sessionDeps.rows.size).toBe(0) + expect(body.status).toBe('queued') + expect(body.accessTier).toBe('limited') + expect(body.model).toBe(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID) + expect(sessionDeps.rows.get('u1')).toMatchObject({ + access_tier: 'limited', + country_code: 'JP', + country_block_reason: 'country_not_allowed', + }) }) - test('returns country_blocked without joining the queue when country is unknown', async () => { + test('creates a limited DeepSeek Flash session when country is unknown', async () => { const sessionDeps = makeSessionDeps() const resp = await postFreebuffSession( makeReq('ok', { cfCountry: null }), makeDeps(sessionDeps, 'u1'), ) - expect(resp.status).toBe(403) + expect(resp.status).toBe(200) const body = await resp.json() - expect(body.status).toBe('country_blocked') - expect(body.countryCode).toBe('UNKNOWN') - expect(body.countryBlockReason).toBe('missing_client_ip') - expect(sessionDeps.rows.size).toBe(0) + expect(body.status).toBe('queued') + expect(body.accessTier).toBe('limited') + expect(body.model).toBe(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID) }) - test('returns country_blocked without joining the queue for anonymized Cloudflare country', async () => { + test('creates a limited DeepSeek Flash session for anonymized Cloudflare country', async () => { const sessionDeps = makeSessionDeps() const resp = await postFreebuffSession( makeReq('ok', { cfCountry: 'T1' }), makeDeps(sessionDeps, 'u1'), ) - expect(resp.status).toBe(403) + expect(resp.status).toBe(200) const body = await resp.json() - expect(body.status).toBe('country_blocked') - expect(body.countryCode).toBe('UNKNOWN') - expect(body.countryBlockReason).toBe('anonymized_or_unknown_country') - expect(sessionDeps.rows.size).toBe(0) + expect(body.status).toBe('queued') + expect(body.accessTier).toBe('limited') + expect(body.model).toBe(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID) }) test('allows queue entry for allowed country', async () => { @@ -323,26 +331,26 @@ describe('GET /api/v1/freebuff/session', () => { expect(body.status).toBe('none') }) - test('returns country_blocked for disallowed country on GET', async () => { + test('returns limited access for disallowed country on GET', async () => { const sessionDeps = makeSessionDeps() const resp = await getFreebuffSession( makeReq('ok', { cfCountry: 'JP' }), makeDeps(sessionDeps, 'u1'), ) - expect(resp.status).toBe(403) + expect(resp.status).toBe(200) const body = await resp.json() - expect(body.status).toBe('country_blocked') - expect(body.countryCode).toBe('JP') - expect(body.countryBlockReason).toBe('country_not_allowed') + expect(body.status).toBe('none') + expect(body.accessTier).toBe('limited') }) - test('skips country recheck on GET when the stored check is recent', async () => { + test('rechecks country on GET so access tier changes are visible immediately', async () => { const sessionDeps = makeSessionDeps() sessionDeps.rows.set('u1', { user_id: 'u1', status: 'queued', active_instance_id: 'inst-1', model: DEFAULT_MODEL, + access_tier: 'full', country_code: 'US', cf_country: 'US', geoip_country: null, @@ -368,8 +376,9 @@ describe('GET /api/v1/freebuff/session', () => { ) const body = await resp.json() expect(resp.status).toBe(200) - expect(body.status).toBe('queued') - expect(countryChecks).toBe(0) + expect(body.status).toBe('none') + expect(body.accessTier).toBe('limited') + expect(countryChecks).toBe(1) }) test('returns banned 403 on GET for banned user', async () => { diff --git a/web/src/app/api/v1/freebuff/session/_handlers.ts b/web/src/app/api/v1/freebuff/session/_handlers.ts index fc468d947a..4dba0c4dc6 100644 --- a/web/src/app/api/v1/freebuff/session/_handlers.ts +++ b/web/src/app/api/v1/freebuff/session/_handlers.ts @@ -6,45 +6,38 @@ import { getSessionState, requestSession, } from '@/server/free-session/public-api' -import { getSessionRow as getStoredSessionRow } from '@/server/free-session/store' -import { - FREE_MODE_ALLOWED_COUNTRIES, - getFreeModeCountryAccess, - IPINFO_PRIVACY_CACHE_TTL_MS, -} from '@/server/free-mode-country' +import { getFreeModeAccessTier } from '@/server/free-mode-country' +import { getCachedFreeModeCountryAccess } from '@/server/free-mode-country-access-cache' import { extractApiKeyFromHeader } from '@/util/auth' import type { FreeModeCountryAccess } from '@/server/free-mode-country' -import type { - FreeSessionCountryAccessMetadata, - InternalSessionRow, -} from '@/server/free-session/types' +import type { FreeSessionCountryAccessMetadata } from '@/server/free-session/types' import type { SessionDeps } from '@/server/free-session/public-api' import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' import type { Logger } from '@codebuff/common/types/contracts/logger' import type { NextRequest } from 'next/server' -/** Early country gate. Mirrors the chat/completions check: require a resolved - * allowlisted country before joining the queue. Unknown/anonymized locations - * are treated as blocked because they commonly indicate VPN, Tor, localhost, - * or proxy traffic. - * - * Returns HTTP 403 (not 200) so older CLIs — which don't know the - * `country_blocked` status and would tight-poll on an unrecognized 200 - * body — fall into their existing `!resp.ok` error path and back off on - * the 10s error retry cadence. The new CLI parses the 403 body directly. */ +/** Resolves the caller's current free-mode country/privacy classification. + * This no longer blocks unsupported countries outright; the HTTP layer uses + * it to choose full vs limited Freebuff access. */ type GetCountryAccessFn = (req: NextRequest) => Promise async function getCountryAccess( + userId: string, req: NextRequest, deps: FreebuffSessionDeps, ): Promise { return ( deps.getCountryAccess?.(req) ?? - getFreeModeCountryAccess(req, { - ipinfoToken: env.IPINFO_TOKEN, - ipHashSecret: env.NEXTAUTH_SECRET, - allowLocalhost: env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev', + getCachedFreeModeCountryAccess({ + userId, + req, + logger: deps.logger, + options: { + ipinfoToken: env.IPINFO_TOKEN, + ipHashSecret: env.NEXTAUTH_SECRET, + allowLocalhost: env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev', + }, }) ) } @@ -63,57 +56,6 @@ function toSessionCountryAccess( } } -async function countryBlockedResponse( - req: NextRequest, - deps: FreebuffSessionDeps, -): Promise<{ - response: NextResponse | null - countryAccess: FreeModeCountryAccess -}> { - const countryAccess = await getCountryAccess(req, deps) - if (countryAccess.allowed) { - return { response: null, countryAccess } - } - return { - response: NextResponse.json( - { - status: 'country_blocked', - countryCode: countryAccess.countryCode ?? 'UNKNOWN', - countryBlockReason: countryAccess.blockReason, - ipPrivacySignals: countryAccess.ipPrivacy?.signals, - }, - { status: 403 }, - ), - countryAccess, - } -} - -function hasRecentAllowedCountryCheck( - row: InternalSessionRow | null, - now: Date, -): boolean { - if (!row?.country_checked_at || row.country_block_reason !== null) { - return false - } - if (!row.country_code || !FREE_MODE_ALLOWED_COUNTRIES.has(row.country_code)) { - return false - } - return ( - now.getTime() - row.country_checked_at.getTime() < - IPINFO_PRIVACY_CACHE_TTL_MS - ) -} - -async function shouldSkipGetCountryCheck( - userId: string, - deps: FreebuffSessionDeps, -): Promise { - const getSessionRow = deps.sessionDeps?.getSessionRow ?? getStoredSessionRow - const row = await getSessionRow(userId) - const now = deps.sessionDeps?.now?.() ?? new Date() - return hasRecentAllowedCountryCheck(row, now) -} - /** Header the CLI uses to identify which instance is polling. Used by GET to * detect when another CLI on the same account has rotated the id. */ export const FREEBUFF_INSTANCE_HEADER = 'x-freebuff-instance-id' @@ -207,11 +149,8 @@ export async function postFreebuffSession( const auth = await resolveUser(req, deps) if ('error' in auth) return auth.error - const { response: blocked, countryAccess } = await countryBlockedResponse( - req, - deps, - ) - if (blocked) return blocked + const countryAccess = await getCountryAccess(auth.userId, req, deps) + const accessTier = getFreeModeAccessTier(countryAccess) const requestedModel = req.headers.get(FREEBUFF_MODEL_HEADER) ?? '' @@ -221,6 +160,7 @@ export async function postFreebuffSession( userEmail: auth.userEmail, userBanned: auth.userBanned, model: requestedModel, + accessTier, countryAccess: toSessionCountryAccess(countryAccess), deps: deps.sessionDeps, }) @@ -256,15 +196,14 @@ export async function getFreebuffSession( if ('error' in auth) return auth.error try { - if (!(await shouldSkipGetCountryCheck(auth.userId, deps))) { - const { response: blocked } = await countryBlockedResponse(req, deps) - if (blocked) return blocked - } + const countryAccess = await getCountryAccess(auth.userId, req, deps) + const accessTier = getFreeModeAccessTier(countryAccess) const claimedInstanceId = req.headers.get(FREEBUFF_INSTANCE_HEADER) ?? undefined const state = await getSessionState({ userId: auth.userId, + accessTier, userEmail: auth.userEmail, userBanned: auth.userBanned, claimedInstanceId, @@ -274,6 +213,7 @@ export async function getFreebuffSession( return NextResponse.json( { status: 'none', + accessTier: state.accessTier, message: 'Call POST to join the waiting room.', queueDepthByModel: state.queueDepthByModel, rateLimitsByModel: state.rateLimitsByModel, diff --git a/web/src/server/free-mode-country.ts b/web/src/server/free-mode-country.ts index 6a64f6b321..32d4d4a44f 100644 --- a/web/src/server/free-mode-country.ts +++ b/web/src/server/free-mode-country.ts @@ -3,6 +3,7 @@ import { createHmac } from 'node:crypto' import geoip from 'geoip-lite' import type { NextRequest } from 'next/server' +import type { FreebuffAccessTier } from '@codebuff/common/constants/freebuff-models' import type { FreebuffCountryBlockReason, FreebuffIpPrivacySignal, @@ -60,6 +61,12 @@ export type LookupIpPrivacyFn = ( ip: string, ) => Promise +export function getFreeModeAccessTier( + countryAccess: Pick, +): FreebuffAccessTier { + return countryAccess.allowed ? 'full' : 'limited' +} + export type FreeModeCountryAccessOptions = { lookupIpPrivacy?: LookupIpPrivacyFn fetch?: typeof globalThis.fetch diff --git a/web/src/server/free-session/__tests__/public-api.test.ts b/web/src/server/free-session/__tests__/public-api.test.ts index 351e17ac07..b85c682cb3 100644 --- a/web/src/server/free-session/__tests__/public-api.test.ts +++ b/web/src/server/free-session/__tests__/public-api.test.ts @@ -1,10 +1,12 @@ import { beforeEach, describe, expect, test } from 'bun:test' import { + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, FREEBUFF_GEMINI_PRO_MODEL_ID, FREEBUFF_GLM_MODEL_ID, FREEBUFF_KIMI_MODEL_ID, + FREEBUFF_LIMITED_SESSION_LIMIT, FREEBUFF_PREMIUM_SESSION_LIMIT, FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS, } from '@codebuff/common/constants/freebuff-models' @@ -40,6 +42,7 @@ function expectedRateLimit(model: string, recentCount: number) { interface AdmitRecord { user_id: string model: string + access_tier?: 'full' | 'limited' admitted_at: Date session_units?: number } @@ -83,13 +86,14 @@ function makeDeps(overrides: Partial = {}): SessionDeps & { } return n }, - listRecentPremiumAdmits: async ({ userId, models, since }) => { + listRecentPremiumAdmits: async ({ userId, models, since, accessTier }) => { return admits .filter( (a) => a.user_id === userId && models.includes(a.model) && - a.admitted_at.getTime() >= since.getTime(), + a.admitted_at.getTime() >= since.getTime() && + (!accessTier || (a.access_tier ?? 'full') === accessTier), ) .sort((a, b) => a.admitted_at.getTime() - b.admitted_at.getTime()) .map((a) => ({ @@ -108,6 +112,7 @@ function makeDeps(overrides: Partial = {}): SessionDeps & { admits.push({ user_id: userId, model, + access_tier: row.access_tier ?? 'full', admitted_at: now, session_units: 1, }) @@ -160,7 +165,7 @@ function makeDeps(overrides: Partial = {}): SessionDeps & { } return pos }, - joinOrTakeOver: async ({ userId, model, now }) => { + joinOrTakeOver: async ({ userId, model, accessTier, now }) => { const existing = rows.get(userId) const nextInstance = newInstanceId() if (!existing) { @@ -169,6 +174,7 @@ function makeDeps(overrides: Partial = {}): SessionDeps & { status: 'queued', active_instance_id: nextInstance, model, + access_tier: accessTier, queued_at: now, admitted_at: null, expires_at: null, @@ -196,12 +202,14 @@ function makeDeps(overrides: Partial = {}): SessionDeps & { existing.model = model existing.queued_at = now } + existing.access_tier = accessTier existing.updated_at = now return existing } existing.status = 'queued' existing.active_instance_id = nextInstance existing.model = model + existing.access_tier = accessTier existing.queued_at = now existing.admitted_at = null existing.expires_at = null @@ -602,6 +610,96 @@ describe('requestSession', () => { expect(state.rateLimit).toBeUndefined() }) + test('limited access coerces any requested model to DeepSeek Flash', async () => { + const state = await requestSession({ + userId: 'u1', + model: DEFAULT_MODEL, + accessTier: 'limited', + deps, + }) + expect(state.status).toBe('queued') + if (state.status !== 'queued') throw new Error('unreachable') + expect(state.accessTier).toBe('limited') + expect(state.model).toBe('deepseek/deepseek-v4-flash') + expect(deps.rows.get('u1')?.access_tier).toBe('limited') + }) + + test('limited access re-anchors an existing full-tier Flash row', async () => { + const admittedAt = new Date(deps._now().getTime() - 10 * 60_000) + deps.rows.set('u1', { + user_id: 'u1', + status: 'active', + active_instance_id: 'full-inst', + model: 'deepseek/deepseek-v4-flash', + access_tier: 'full', + queued_at: admittedAt, + admitted_at: admittedAt, + expires_at: new Date(deps._now().getTime() + SESSION_LEN), + created_at: admittedAt, + updated_at: admittedAt, + }) + + const state = await requestSession({ + userId: 'u1', + model: 'deepseek/deepseek-v4-flash', + accessTier: 'limited', + deps, + }) + expect(state.status).toBe('queued') + if (state.status !== 'queued') throw new Error('unreachable') + expect(state.accessTier).toBe('limited') + expect(state.instanceId).not.toBe('full-inst') + expect(deps.rows.get('u1')?.access_tier).toBe('limited') + }) + + test('rate_limited: limited access blocks the next Flash session at 5 units', async () => { + const now = deps._now() + for (let i = 0; i < FREEBUFF_LIMITED_SESSION_LIMIT; i++) { + deps.admits.push({ + user_id: 'u1', + model: 'deepseek/deepseek-v4-flash', + access_tier: 'limited', + admitted_at: new Date(now.getTime() - i * 60_000), + }) + } + + const state = await requestSession({ + userId: 'u1', + model: DEFAULT_MODEL, + accessTier: 'limited', + deps, + }) + expect(state.status).toBe('rate_limited') + if (state.status !== 'rate_limited') throw new Error('unreachable') + expect(state.accessTier).toBe('limited') + expect(state.model).toBe('deepseek/deepseek-v4-flash') + expect(state.limit).toBe(FREEBUFF_LIMITED_SESSION_LIMIT) + expect(state.recentCount).toBe(FREEBUFF_LIMITED_SESSION_LIMIT) + expect(deps.rows.has('u1')).toBe(false) + }) + + test('rate_limited: full Flash sessions do not consume the limited quota', async () => { + const now = deps._now() + for (let i = 0; i < FREEBUFF_LIMITED_SESSION_LIMIT; i++) { + deps.admits.push({ + user_id: 'u1', + model: 'deepseek/deepseek-v4-flash', + access_tier: 'full', + admitted_at: new Date(now.getTime() - i * 60_000), + }) + } + + const state = await requestSession({ + userId: 'u1', + model: DEFAULT_MODEL, + accessTier: 'limited', + deps, + }) + expect(state.status).toBe('queued') + if (state.status !== 'queued') throw new Error('unreachable') + expect(state.rateLimit?.recentCount).toBe(0) + }) + test('queued DeepSeek response carries the current admit count', async () => { deps._tick(PREMIUM_OPEN_TIME) const now = deps._now() @@ -816,7 +914,11 @@ describe('getSessionState', () => { test('no row returns none with empty queue-depth snapshot', async () => { const state = await getSessionState({ userId: 'u1', deps }) - expect(state).toEqual({ status: 'none', queueDepthByModel: {} }) + expect(state).toEqual({ + status: 'none', + accessTier: 'full', + queueDepthByModel: {}, + }) }) test('no row surfaces used premium quota before joining', async () => { @@ -835,6 +937,68 @@ describe('getSessionState', () => { ).toEqual(expectedRateLimit(FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, 1)) }) + test('limited access deletes an incompatible queued row before returning none', async () => { + await requestSession({ userId: 'u1', model: DEFAULT_MODEL, deps }) + expect(deps.rows.has('u1')).toBe(true) + + const state = await getSessionState({ + userId: 'u1', + accessTier: 'limited', + deps, + }) + + expect(state).toEqual({ + status: 'none', + accessTier: 'limited', + queueDepthByModel: {}, + }) + expect(deps.rows.has('u1')).toBe(false) + }) + + test('limited access deletes a queued full-tier Flash row before returning none', async () => { + await requestSession({ + userId: 'u1', + model: FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + deps, + }) + expect(deps.rows.get('u1')?.access_tier).toBe('full') + + const state = await getSessionState({ + userId: 'u1', + accessTier: 'limited', + deps, + }) + + expect(state).toEqual({ + status: 'none', + accessTier: 'limited', + queueDepthByModel: {}, + }) + expect(deps.rows.has('u1')).toBe(false) + }) + + test('limited access deletes an incompatible active row before returning none', async () => { + await requestSession({ userId: 'u1', model: DEFAULT_MODEL, deps }) + const row = deps.rows.get('u1')! + row.status = 'active' + row.admitted_at = deps._now() + row.expires_at = new Date(deps._now().getTime() + SESSION_LEN) + + const state = await getSessionState({ + userId: 'u1', + accessTier: 'limited', + claimedInstanceId: row.active_instance_id, + deps, + }) + + expect(state).toEqual({ + status: 'none', + accessTier: 'limited', + queueDepthByModel: {}, + }) + expect(deps.rows.has('u1')).toBe(false) + }) + test('active session with matching instance id returns active', async () => { await requestSession({ userId: 'u1', model: DEFAULT_MODEL, deps }) const row = deps.rows.get('u1')! @@ -1004,7 +1168,11 @@ describe('getSessionState', () => { claimedInstanceId: row.active_instance_id, deps, }) - expect(state).toEqual({ status: 'none', queueDepthByModel: {} }) + expect(state).toEqual({ + status: 'none', + accessTier: 'full', + queueDepthByModel: {}, + }) }) }) @@ -1197,6 +1365,46 @@ describe('checkSessionAdmissible', () => { expect(result.code).toBe('session_model_mismatch') }) + test('limited active Flash session admits Flash root requests', async () => { + await requestSession({ + userId: 'u1', + model: DEFAULT_MODEL, + accessTier: 'limited', + deps, + }) + const row = deps.rows.get('u1')! + row.status = 'active' + row.admitted_at = deps._now() + row.expires_at = new Date(deps._now().getTime() + SESSION_LEN) + + const result = await checkSessionAdmissible({ + userId: 'u1', + accessTier: 'limited', + claimedInstanceId: row.active_instance_id, + requestedModel: 'deepseek/deepseek-v4-flash', + deps, + }) + expect(result.ok).toBe(true) + }) + + test('limited access rejects active full-tier non-Flash sessions', async () => { + await requestSession({ userId: 'u1', model: DEFAULT_MODEL, deps }) + const row = deps.rows.get('u1')! + row.status = 'active' + row.admitted_at = deps._now() + row.expires_at = new Date(deps._now().getTime() + SESSION_LEN) + + const result = await checkSessionAdmissible({ + userId: 'u1', + accessTier: 'limited', + claimedInstanceId: row.active_instance_id, + requestedModel: DEFAULT_MODEL, + deps, + }) + if (result.ok) throw new Error('unreachable') + expect(result.code).toBe('session_model_mismatch') + }) + test('active + wrong instance id → session_superseded', async () => { await requestSession({ userId: 'u1', model: DEFAULT_MODEL, deps }) const row = deps.rows.get('u1')! diff --git a/web/src/server/free-session/__tests__/session-view.test.ts b/web/src/server/free-session/__tests__/session-view.test.ts index a52f207600..ade55ba17b 100644 --- a/web/src/server/free-session/__tests__/session-view.test.ts +++ b/web/src/server/free-session/__tests__/session-view.test.ts @@ -67,6 +67,7 @@ describe('toSessionStateResponse', () => { }) expect(view).toEqual({ status: 'queued', + accessTier: 'full', instanceId: 'inst-1', model: TEST_MODEL, position: 3, @@ -92,6 +93,7 @@ describe('toSessionStateResponse', () => { }) expect(view).toEqual({ status: 'active', + accessTier: 'full', instanceId: 'inst-1', model: TEST_MODEL, admittedAt: admittedAt.toISOString(), @@ -115,6 +117,7 @@ describe('toSessionStateResponse', () => { }) expect(view).toEqual({ status: 'ended', + accessTier: 'full', instanceId: 'inst-1', admittedAt: admittedAt.toISOString(), expiresAt: expiresAt.toISOString(), diff --git a/web/src/server/free-session/public-api.ts b/web/src/server/free-session/public-api.ts index 68a0f59bce..ccd5c16214 100644 --- a/web/src/server/free-session/public-api.ts +++ b/web/src/server/free-session/public-api.ts @@ -1,17 +1,22 @@ import { canFreebuffModelSpawnGeminiThinker, - FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, FREEBUFF_DEPLOYMENT_HOURS_LABEL, FREEBUFF_GEMINI_PRO_MODEL_ID, + FREEBUFF_LIMITED_SESSION_LIMIT, + FREEBUFF_LIMITED_SESSION_PERIOD, + FREEBUFF_LIMITED_SESSION_RESET_TIMEZONE, + FREEBUFF_LIMITED_SESSION_WINDOW_HOURS, FREEBUFF_PREMIUM_MODEL_IDS, FREEBUFF_PREMIUM_SESSION_PERIOD, FREEBUFF_PREMIUM_SESSION_LIMIT, FREEBUFF_PREMIUM_SESSION_RESET_TIMEZONE, FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS, + isFreebuffModelAllowedForAccessTier, isFreebuffModelAvailable, isFreebuffPremiumModelId, isSupportedFreebuffModelId, - resolveSupportedFreebuffModel, + resolveFreebuffModelForAccessTier, } from '@codebuff/common/constants/freebuff-models' import { getZonedDayBounds } from '@codebuff/common/util/zoned-time' @@ -35,6 +40,7 @@ import { } from './store' import { toSessionStateResponse } from './session-view' +import type { FreebuffAccessTier } from '@codebuff/common/constants/freebuff-models' import type { FreebuffSessionRateLimit, FreebuffSessionServerResponse, @@ -49,50 +55,91 @@ function roundSessionUnits(units: number): number { return Math.round(units * 10) / 10 } -function canStartPremiumSession(snapshot: FreebuffSessionRateLimit): boolean { +function canStartSession(snapshot: FreebuffSessionRateLimit): boolean { return snapshot.recentCount < snapshot.limit } -type PremiumQuotaInfo = Omit +type SessionQuotaInfo = Omit -interface PremiumQuotaSnapshot { - info: PremiumQuotaInfo +interface SessionQuotaSnapshot { + info: SessionQuotaInfo resetsAt: Date } -async function fetchPremiumQuotaSnapshot( +interface SessionQuotaConfig { + models: readonly string[] + limit: number + period: 'pacific_day' + resetTimeZone: string + windowHours: number + accessTier?: FreebuffAccessTier +} + +function quotaConfigForModel( + model: string, + accessTier: FreebuffAccessTier, +): SessionQuotaConfig | undefined { + if (accessTier === 'full' && !isFreebuffPremiumModelId(model)) { + return undefined + } + return quotaConfigForAccessTier(accessTier) +} + +function quotaConfigForAccessTier( + accessTier: FreebuffAccessTier, +): SessionQuotaConfig { + if (accessTier === 'limited') { + return { + models: [FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID], + limit: FREEBUFF_LIMITED_SESSION_LIMIT, + period: FREEBUFF_LIMITED_SESSION_PERIOD, + resetTimeZone: FREEBUFF_LIMITED_SESSION_RESET_TIMEZONE, + windowHours: FREEBUFF_LIMITED_SESSION_WINDOW_HOURS, + accessTier, + } + } + return { + models: FREEBUFF_PREMIUM_MODEL_IDS, + limit: FREEBUFF_PREMIUM_SESSION_LIMIT, + period: FREEBUFF_PREMIUM_SESSION_PERIOD, + resetTimeZone: FREEBUFF_PREMIUM_SESSION_RESET_TIMEZONE, + windowHours: FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS, + accessTier, + } +} + +async function fetchSessionQuotaSnapshot( userId: string, + config: SessionQuotaConfig, deps: SessionDeps, -): Promise { +): Promise { const now = nowOf(deps) - const premiumDay = getZonedDayBounds( - now, - FREEBUFF_PREMIUM_SESSION_RESET_TIMEZONE, - ) + const day = getZonedDayBounds(now, config.resetTimeZone) const admits = await deps.listRecentPremiumAdmits({ userId, - since: premiumDay.startsAt, - models: FREEBUFF_PREMIUM_MODEL_IDS, + since: day.startsAt, + models: config.models, + accessTier: config.accessTier, }) const recentCount = roundSessionUnits( admits.reduce((sum, admit) => sum + admit.sessionUnits, 0), ) return { info: { - limit: FREEBUFF_PREMIUM_SESSION_LIMIT, - period: FREEBUFF_PREMIUM_SESSION_PERIOD, - resetTimeZone: FREEBUFF_PREMIUM_SESSION_RESET_TIMEZONE, - resetAt: premiumDay.resetsAt.toISOString(), - windowHours: FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS, + limit: config.limit, + period: config.period, + resetTimeZone: config.resetTimeZone, + resetAt: day.resetsAt.toISOString(), + windowHours: config.windowHours, recentCount, }, - resetsAt: premiumDay.resetsAt, + resetsAt: day.resetsAt, } } function toRateLimitInfo( model: string, - snapshot: PremiumQuotaSnapshot, + snapshot: SessionQuotaSnapshot, ): FreebuffSessionRateLimit { return { model, @@ -107,6 +154,7 @@ function toRateLimitInfo( async function fetchRateLimitSnapshot( userId: string, model: string, + accessTier: FreebuffAccessTier, deps: SessionDeps, ): Promise< | { @@ -115,8 +163,9 @@ async function fetchRateLimitSnapshot( } | undefined > { - if (!isFreebuffPremiumModelId(model)) return undefined - const snapshot = await fetchPremiumQuotaSnapshot(userId, deps) + const config = quotaConfigForModel(model, accessTier) + if (!config) return undefined + const snapshot = await fetchSessionQuotaSnapshot(userId, config, deps) return { info: toRateLimitInfo(model, snapshot), resetsAt: snapshot.resetsAt, @@ -125,11 +174,13 @@ async function fetchRateLimitSnapshot( async function fetchRateLimitsByModel( userId: string, + accessTier: FreebuffAccessTier, deps: SessionDeps, ): Promise> { - const snapshot = await fetchPremiumQuotaSnapshot(userId, deps) + const config = quotaConfigForAccessTier(accessTier) + const snapshot = await fetchSessionQuotaSnapshot(userId, config, deps) return Object.fromEntries( - FREEBUFF_PREMIUM_MODEL_IDS.map( + config.models.map( (model) => [model, toRateLimitInfo(model, snapshot)] as const, ), ) @@ -156,6 +207,7 @@ export interface SessionDeps { joinOrTakeOver: (params: { userId: string model: string + accessTier: FreebuffAccessTier now: Date countryAccess?: FreeSessionCountryAccessMetadata }) => Promise @@ -180,6 +232,7 @@ export interface SessionDeps { userId: string models: readonly string[] since: Date + accessTier?: FreebuffAccessTier }) => Promise<{ admittedAt: Date; model: string; sessionUnits: number }[]> /** Instant-admit promotion: flips a specific queued row to active. Returns * the updated row or null if the row wasn't in a queued state. */ @@ -225,6 +278,16 @@ const defaultDeps: SessionDeps = { const nowOf = (deps: SessionDeps): Date => (deps.now ?? (() => new Date()))() +function isSessionRowCompatibleWithAccessTier( + row: InternalSessionRow, + accessTier: FreebuffAccessTier, +): boolean { + if (accessTier === 'limited' && (row.access_tier ?? 'full') !== 'limited') { + return false + } + return isFreebuffModelAllowedForAccessTier(row.model, accessTier) +} + async function viewForRow( userId: string, deps: SessionDeps, @@ -257,6 +320,7 @@ export type RequestSessionResult = * session is still bound to another. The CLI must end the existing * session first (DELETE /session) before re-queueing. */ status: 'model_locked' + accessTier?: FreebuffAccessTier currentModel: string requestedModel: string } @@ -264,6 +328,7 @@ export type RequestSessionResult = /** User has hit the per-model admission quota for the current Pacific day. * See `FreebuffSessionServerResponse`'s `rate_limited` variant. */ status: 'rate_limited' + accessTier?: FreebuffAccessTier model: string limit: number period: 'pacific_day' @@ -275,6 +340,7 @@ export type RequestSessionResult = } | { status: 'model_unavailable' + accessTier?: FreebuffAccessTier requestedModel: string availableHours: string } @@ -299,6 +365,7 @@ export type RequestSessionResult = export async function requestSession(params: { userId: string model: string + accessTier?: FreebuffAccessTier userEmail?: string | null | undefined countryAccess?: FreeSessionCountryAccessMetadata /** True if the account is banned. Short-circuited here so banned bots never @@ -308,7 +375,8 @@ export async function requestSession(params: { deps?: SessionDeps }): Promise { const deps = params.deps ?? defaultDeps - const model = resolveSupportedFreebuffModel(params.model) + const accessTier = params.accessTier ?? 'full' + const model = resolveFreebuffModelForAccessTier(params.model, accessTier) const now = nowOf(deps) if (params.userBanned) { return { status: 'banned' } @@ -330,10 +398,19 @@ export async function requestSession(params: { // counts are written at promotion time, so the quota only needs to gate // fresh admissions — blocking a reclaim here would strand a user with an // active 5th session unable to reconnect after a CLI restart. - const existing = await deps.getSessionRow(params.userId) + let existing = await deps.getSessionRow(params.userId) + if (existing && !isSessionRowCompatibleWithAccessTier(existing, accessTier)) { + await deps.endSession({ + userId: params.userId, + now, + sessionLengthMs: deps.sessionLengthMs, + }) + existing = null + } const isReclaim = !!existing && existing.model === model && + (existing.access_tier ?? 'full') === accessTier && (existing.status === 'queued' || (existing.status === 'active' && !!existing.expires_at && @@ -348,8 +425,13 @@ export async function requestSession(params: { } if (!isReclaim) { - const snapshot = await fetchRateLimitSnapshot(params.userId, model, deps) - if (snapshot && !canStartPremiumSession(snapshot.info)) { + const snapshot = await fetchRateLimitSnapshot( + params.userId, + model, + accessTier, + deps, + ) + if (snapshot && !canStartSession(snapshot.info)) { const retryAfterMs = Math.max( 0, snapshot.resetsAt.getTime() - now.getTime(), @@ -357,6 +439,7 @@ export async function requestSession(params: { return { ...snapshot.info, status: 'rate_limited', + accessTier, retryAfterMs, } } @@ -367,6 +450,7 @@ export async function requestSession(params: { row = await deps.joinOrTakeOver({ userId: params.userId, model, + accessTier, now, countryAccess: params.countryAccess, }) @@ -376,6 +460,7 @@ export async function requestSession(params: { status: 'model_locked', currentModel: err.currentModel, requestedModel: model, + accessTier, } } throw err @@ -432,7 +517,12 @@ async function attachRateLimit( ) { return view } - const allRateLimitsByModel = await fetchRateLimitsByModel(userId, deps) + const accessTier = view.accessTier ?? 'full' + const allRateLimitsByModel = await fetchRateLimitsByModel( + userId, + accessTier, + deps, + ) // The ended view doesn't carry a model id, so it gets the full snapshot // unfiltered — the banner reads any entry's recentCount (they all share the // same daily premium pool). Queued/active filter out unused models so the @@ -452,9 +542,11 @@ async function attachRateLimit( } /** - * Read-only check of the caller's current state. Does not mutate or rotate - * `instance_id`. The CLI sends its currently-held `claimedInstanceId` so we - * can return `superseded` if a newer CLI on the same account took over. + * Check of the caller's current state. Does not rotate `instance_id`. The CLI + * sends its currently-held `claimedInstanceId` so we can return `superseded` + * if a newer CLI on the same account took over. Mutates only to clear rows + * that the current access tier can no longer use, so they don't leak queue or + * active capacity after the CLI receives `none`. * * Returns: * - `disabled` when the waiting room is off @@ -466,12 +558,14 @@ async function attachRateLimit( */ export async function getSessionState(params: { userId: string + accessTier?: FreebuffAccessTier userEmail?: string | null | undefined userBanned?: boolean claimedInstanceId?: string | null | undefined deps?: SessionDeps }): Promise { const deps = params.deps ?? defaultDeps + const accessTier = params.accessTier ?? 'full' if (params.userBanned) { return { status: 'banned' } } @@ -490,10 +584,11 @@ export async function getSessionState(params: { const noneResponse = async (): Promise => { const [queueDepthByModel, rateLimitsByModel] = await Promise.all([ deps.queueDepthsByModel(), - fetchRateLimitsByModel(params.userId, deps), + fetchRateLimitsByModel(params.userId, accessTier, deps), ]) return { status: 'none', + accessTier, queueDepthByModel, ...nonEmptyRateLimitsByModel( onlyUsedRateLimitsByModel(rateLimitsByModel), @@ -503,6 +598,15 @@ export async function getSessionState(params: { if (!row) return noneResponse() + if (!isSessionRowCompatibleWithAccessTier(row, accessTier)) { + await deps.endSession({ + userId: params.userId, + now: nowOf(deps), + sessionLengthMs: deps.sessionLengthMs, + }) + return noneResponse() + } + if ( row.status === 'active' && params.claimedInstanceId && @@ -568,6 +672,7 @@ export type SessionGateResult = */ export async function checkSessionAdmissible(params: { userId: string + accessTier?: FreebuffAccessTier userEmail?: string | null | undefined claimedInstanceId: string | null | undefined /** Forces a real active session row check even when the waiting room is @@ -581,6 +686,7 @@ export async function checkSessionAdmissible(params: { deps?: SessionDeps }): Promise { const deps = params.deps ?? defaultDeps + const accessTier = params.accessTier ?? 'full' if ( !params.requireActiveSession && (!deps.isWaitingRoomEnabled() || @@ -647,6 +753,28 @@ export async function checkSessionAdmissible(params: { } } + if (!isSessionRowCompatibleWithAccessTier(row, accessTier)) { + return { + ok: false, + code: 'session_model_mismatch', + message: + 'This free session is not valid for limited access. Restart freebuff to switch to DeepSeek V4 Flash.', + } + } + + if ( + accessTier === 'limited' && + params.requestedModel && + isSupportedFreebuffModelId(params.requestedModel) && + !isFreebuffModelAllowedForAccessTier(params.requestedModel, accessTier) + ) { + return { + ok: false, + code: 'session_model_mismatch', + message: 'Limited free access is only available with DeepSeek V4 Flash.', + } + } + // Smart freebuff models (Kimi, DeepSeek) can spawn the gemini-thinker // child agent which calls Gemini Pro under the hood. The cost-mode gate // already allowlists that combo; here we allow the request through against diff --git a/web/src/server/free-session/session-view.ts b/web/src/server/free-session/session-view.ts index 599b449113..091ba2e8df 100644 --- a/web/src/server/free-session/session-view.ts +++ b/web/src/server/free-session/session-view.ts @@ -27,6 +27,7 @@ export function toSessionStateResponse(params: { if (expiresAtMs > nowMs) { return { status: 'active', + accessTier: row.access_tier ?? 'full', instanceId: row.active_instance_id, model: row.model, admittedAt: (row.admitted_at ?? row.created_at).toISOString(), @@ -38,6 +39,7 @@ export function toSessionStateResponse(params: { if (graceEndsMs > nowMs) { return { status: 'ended', + accessTier: row.access_tier ?? 'full', instanceId: row.active_instance_id, admittedAt: (row.admitted_at ?? row.created_at).toISOString(), expiresAt: row.expires_at.toISOString(), @@ -50,6 +52,7 @@ export function toSessionStateResponse(params: { if (row.status === 'queued') { return { status: 'queued', + accessTier: row.access_tier ?? 'full', instanceId: row.active_instance_id, model: row.model, position, diff --git a/web/src/server/free-session/store.ts b/web/src/server/free-session/store.ts index 660f7a34a7..fdf7e85398 100644 --- a/web/src/server/free-session/store.ts +++ b/web/src/server/free-session/store.ts @@ -6,6 +6,7 @@ import { and, asc, count, desc, eq, gte, inArray, lt, sql } from 'drizzle-orm' import { FREEBUFF_ADMISSION_LOCK_ID } from './config' import type { FireworksHealth } from './fireworks-health' +import type { FreebuffAccessTier } from '@codebuff/common/constants/freebuff-models' import type { FreeSessionCountryAccessMetadata, InternalSessionRow, @@ -72,10 +73,11 @@ function countryAccessColumns( export async function joinOrTakeOver(params: { userId: string model: string + accessTier: FreebuffAccessTier now: Date countryAccess?: FreeSessionCountryAccessMetadata }): Promise { - const { userId, model, now, countryAccess } = params + const { userId, model, accessTier, now, countryAccess } = params const nextInstanceId = newInstanceId() const countryAccessUpdate = countryAccessColumns(countryAccess) @@ -113,6 +115,7 @@ export async function joinOrTakeOver(params: { status: 'queued', active_instance_id: nextInstanceId, model, + access_tier: accessTier, ...countryAccessUpdate, queued_at: now, created_at: now, @@ -137,6 +140,10 @@ export async function joinOrTakeOver(params: { WHEN ${activeUnexpired} THEN ${schema.freeSession.model} ELSE ${model} END`, + access_tier: sql`CASE + WHEN ${activeUnexpired} THEN ${schema.freeSession.access_tier} + ELSE ${accessTier}::freebuff_access_tier + END`, queued_at: sql`CASE WHEN ${activeUnexpired} THEN ${schema.freeSession.queued_at} WHEN ${schema.freeSession.status} = 'queued' AND ${sameModel} THEN ${schema.freeSession.queued_at} @@ -208,6 +215,7 @@ export async function endSession(params: { and( eq(schema.freeSessionAdmit.user_id, userId), eq(schema.freeSessionAdmit.model, row.model), + eq(schema.freeSessionAdmit.access_tier, row.access_tier ?? 'full'), ), ) .orderBy(desc(schema.freeSessionAdmit.admitted_at)) @@ -465,6 +473,7 @@ export async function admitFromQueue(params: { admitted.map((r) => ({ user_id: r.user_id, model: r.model, + access_tier: r.access_tier ?? 'full', admitted_at: now, })), ) @@ -513,6 +522,7 @@ export async function promoteQueuedUser(params: { await tx.insert(schema.freeSessionAdmit).values({ user_id: userId, model, + access_tier: row.access_tier ?? 'full', admitted_at: now, }) return row as InternalSessionRow @@ -534,9 +544,18 @@ export async function listRecentPremiumAdmits(params: { userId: string models: readonly string[] since: Date + accessTier?: FreebuffAccessTier }): Promise { - const { userId, models, since } = params + const { userId, models, since, accessTier } = params if (models.length === 0) return [] + const filters = [ + eq(schema.freeSessionAdmit.user_id, userId), + inArray(schema.freeSessionAdmit.model, [...models]), + gte(schema.freeSessionAdmit.admitted_at, since), + ] + if (accessTier) { + filters.push(eq(schema.freeSessionAdmit.access_tier, accessTier)) + } const rows = await db .select({ admitted_at: schema.freeSessionAdmit.admitted_at, @@ -544,13 +563,7 @@ export async function listRecentPremiumAdmits(params: { session_units: schema.freeSessionAdmit.session_units, }) .from(schema.freeSessionAdmit) - .where( - and( - eq(schema.freeSessionAdmit.user_id, userId), - inArray(schema.freeSessionAdmit.model, [...models]), - gte(schema.freeSessionAdmit.admitted_at, since), - ), - ) + .where(and(...filters)) .orderBy(asc(schema.freeSessionAdmit.admitted_at)) return rows.map((r) => ({ admittedAt: r.admitted_at, diff --git a/web/src/server/free-session/types.ts b/web/src/server/free-session/types.ts index eff3eb134a..afd4407e94 100644 --- a/web/src/server/free-session/types.ts +++ b/web/src/server/free-session/types.ts @@ -3,6 +3,7 @@ import type { FreebuffCountryBlockReason, FreebuffIpPrivacySignal, } from '@codebuff/common/types/freebuff-session' +import type { FreebuffAccessTier } from '@codebuff/common/constants/freebuff-models' export type FreeSessionStatus = 'queued' | 'active' @@ -21,6 +22,7 @@ export interface InternalSessionRow { active_instance_id: string /** Freebuff model id this row is queued for (or locked to, once active). */ model: string + access_tier?: FreebuffAccessTier country_code?: string | null cf_country?: string | null geoip_country?: string | null